Skip to main content
A VentStream pipeline has two halves: capture (read the source’s change stream) and project (turn each change into the documents it affects). This page explains both and how they meet.

Capture

VentStream reads the source’s native change feed — not a polling diff.

Postgres

Logical replication via the in-tree pgoutput plugin. You create a publication listing the tables to stream and the engine consumes a replication slot. Every insert/update/delete on a published table arrives as a typed event with a WAL position (LSN) as its cursor.
A replication slot is single-consumer. Exactly one agent reads a given slot. Pointing two agents at one slot is undefined behavior — give each agent a unique VS_PG_SLOT.

Neo4j

Neo4j CDC via the db.cdc.query procedures. The engine polls the change feed on an interval and persists an opaque cursor string. Use a Neo4j Enterprise release or AuraDB tier that provides db.cdc.*; the Neo4j source guide covers the current deployment requirements.
DIFF is recommended for projections (lighter transaction log, same result — the engine re-queries the graph to build each document). FULL is only needed for a raw CDC tail that ships full per-event state. See Neo4j source → DIFF vs FULL.

Project

A projection spec is optional. With no joins or denormalize spec at all, every change flows through as a flat per-row document with a deterministic id ({schema.table}:["pk",…] — see deterministic document IDs): updates overwrite the document in place, deletes remove it, and a primary-key-changing update removes the old document and writes the new one. Reach for a spec when you want composed documents — a parent with embedded children — not for correctness. A projection spec (YAML) declares the target document. The engine uses it three ways:
  1. Bootstrap — derive the initial scan that seeds every existing primary into the sink.
  2. Fan-out — on each change, find the affected primaries and recompute their documents.
  3. Delete — when a primary disappears, emit a sink delete for its document.

Postgres: joins

The Postgres spec embeds related rows into the primary’s document:
A change to orders, customers, or order_items recomputes the affected order documents. A cardinality: one related row embeds as an object; many embeds as an array.

Neo4j: denormalize specs

The Neo4j spec is Cypher. You write the body that, given a primary node p, returns the document; the engine wraps it with the fan-out anchor:
fan_out_max_hops bounds how far a change can be from a Product and still recompute its document. A change to a node 3 hops away when the cap is 2 simply isn’t reached.

Where capture meets project

The join engine sits between the source and the sink. On each event: A change event fans out to the primary documents it touches; each is recomposed by the projection spec and idempotently upserted into the sink. For Postgres the “which primaries” step uses an in-memory reverse index of foreign keys. For Neo4j it’s a Cypher query anchored on the changed element’s ID. Either way, only affected primaries are recomputed — see Fan-out for how that stays bounded even when a change touches a shared lookup node.

One bad row does not stop the stream

An event the join engine can never process — a payload or row that isn’t a JSON object, a subject that isn’t CDC-shaped, a row with no usable key — is written to the dead-letter file (VS_DLQ_PATH) with the reason prefixed join engine:, fsynced, logged as metric=join.poison, and skipped; the cursor advances past it so a restart never replays it. Only failures that are a property of the event itself are handled this way. A failure of the environment — the related-row fetcher cannot reach the source, state cannot be persisted — stays fatal and is retried by the supervisor, because the same row would compose once the source is back and dead-lettering it would silently drop data.

The scoping rule

A change propagates to the sink only if it lies on a path the spec actually declares, within the hop limit, and (for temporally-gated Neo4j edges) satisfies the spec’s WHERE. A node whose label the spec never traverses produces zero recomputations — the event is read and discarded. This is the contract: the spec is the boundary. Nothing outside it can cause a write, which is exactly what keeps the index clean and the fan-out bounded. (In a flat pipeline with no spec, the source scope — the Postgres publication, the collection or table list, the topic set — is the boundary instead.)