CDC-out (reverse-ETL)
Declaratively mirror table changes outward to external sinks (webhook, Kafka, plus a CdcSink interface for custom sinks) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered.
CDC-out — declarative reverse-ETL
@voltro/plugin-cdc-out streams your table changes OUTWARD to an external
sink. Declare { table → sink, map } and the plugin mirrors every change
through a durable outbox in your app's own database — no separate
Debezium / Fivetran pipeline. The data-team wedge: app data in the warehouse,
declared in code, composed onto the change stream you already have.
Wiring
// app.config.ts
import { cdcOutPlugin, webhookSink } from '@voltro/plugin-cdc-out'
export default {
type: 'api' as const,
name: 'api',
plugins: [
cdcOutPlugin({
sinks: [
{
table: 'orders',
sink: webhookSink('https://warehouse.example.com/ingest'),
// Map a row → the outbound record body (default: the row unchanged).
map: (row) => ({ id: row.id, total: row.total, status: row.status }),
// Optional: only mirror some changes.
filter: (event) => event.op !== 'delete',
batchSize: 500, // max records per delivered batch (default 100)
},
],
maxAttempts: 5, // per-record delivery attempts before dead-letter
backoffBaseMs: 200, // first retry delay; doubles per attempt, jittered
deliveryTimeoutMs: 10_000, // per-attempt timeout — aborts the sink call
leaseTtlMs: 15_000, // leader lease; heartbeat renews at ttl/3
// Fleet-scope handoff (see "Delivery guarantees"):
dedupWindowMs: 60_000, // how long an enqueue claim is kept — must exceed leaseTtlMs
handoffBufferMs: 60_000, // how far back each replica buffers for a takeover to drain
handoffBufferSize: 10_000, // hard ceiling on buffered changes per replica
}),
],
}One sink config per table per instance — a duplicate table is a loud boot
error. For multiple sinks on one table, wire a second instance with a
name (cdcOutPlugin({ name: 'analytics', sinks: […] })); it suffixes the
plugin name (@voltro/plugin-cdc-out#analytics) and the inspect mount
(/_voltro/inspect/plugins/cdc-out--analytics/…).
Sinks
memorySink()— in-process, records every delivered batch. Dev + tests.webhookSink(url, { headers? })— POSTs each batch as{ records: [...] }JSON, honoring the engine's per-attempt abort signal. Its host is declared as anetwork:outbound:<host>permission automatically.kafkaSink({ topic })— from@voltro/plugin-queue, producing through the same provider your consumers use: message key = the row id (one row's changes stay ordered in one partition), value = the change record, and thex-voltro-delivery-keyheader carries the dedupe handle below.- Warehouse (Snowflake / BigQuery / …) — implement the
CdcSinkinterface ({ name, deliver(batch, ctx), outboundHost? }).delivermay return a Promise or an Effect — both compose without wrapping. The engine is connector-agnostic; the sink is the only thing that changes.
Delivery guarantees — exactly what holds
Every mirrored change becomes a row in _voltro_cdcout_outbox (contributed
via extendSchema, migrated by voltro dev). The row's TypeID id is the
record's deliveryKey — unique across replicas, stable across restarts and
retries.
Enqueue is de-duplicated per observed change, fleet-wide, across leadership handovers — with one hole: a replica that dies between winning a change's claim and inserting its outbox row loses that change, because the claim survives and nothing rescans orphan claims. (Those are two statements with no transaction around them, which is why this does not say "exactly-once".)
On
changeScope: 'local'stores each replica enqueues only its OWN commits (injected cross-replica events are skipped), so a change is observed by exactly one process and needs nothing further.On
'fleet'stores (postgreschangeStrategy: 'cdc'LISTEN/NOTIFY, mysql binlog) EVERY replica sees the full stream, so every replica buffers it in memory (handoffBufferMs, capped athandoffBufferSizeentries, oldest dropped first). The holder of the leader lease (_voltro_cdcout_leases, TTL-heartbeat) enqueues as it goes; a replica that WINS the lease drains the window its predecessor never got to. Enqueue is idempotent per change identity: each change is keyed by achangeKeyevery replica computes alike — a digest of(pipe, op, row id, new image, old image)plus an occurrence counter that keeps two byte-identical changes to one row apart — and claimed in_voltro_cdcout_claimsunderunique(pipe, changeKey). So the rows the dying leader already wrote collapse instead of duplicating, and the ones it never reached are written by its successor.A replica that boots into a fleet that is already running adopts the fleet's occurrence counters from the claims already in the database before it keys anything — otherwise its first sighting of an already-claimed change would key occurrence 0, collide, and be dropped as a duplicate it is not.
GET /_voltro/inspect/plugins/cdc-out/sinksreportshandoff.seededandhandoff.awaitingSeedso you can see that happen rather than assume it.What still bounds it, stated plainly: a change no surviving replica observed is gone (the transport delivered it only to the dead process); a handoff that takes longer than
handoffBufferMsloses whatever aged out of the buffer, and that count is reported ashandoff.droppedonGET /_voltro/inspect/plugins/cdc-out/sinksrather than dropped silently.dedupWindowMs(defaultmax(60_000, 4 × leaseTtlMs)) is how long a claim is kept; it must exceedleaseTtlMs— a claim that expires mid-handoff is a duplicate window, and the plugin refuses to boot with one.At-least-once FROM ENQUEUE. The tap is post-commit — a crash in the narrow window between commit and the outbox insert loses that one event; the plugin does not claim better. From enqueue on, delivery survives restarts, retries with exponential backoff + jitter, and re-sends the SAME
deliveryKeys — a sink that upserts on them dedupes safely.Ordered per pipe. One in-flight batch per pipe, consumed strictly in commit (id) order; the queue is head-blocking, so a batch waiting out its retry backoff is never overtaken.
Dead-letter, never silent drop. A record that exhausts
maxAttemptsmoves to statusdeadwith its last error — queryable atGET /_voltro/inspect/plugins/cdc-out/dead-letter— and unblocks the pipe.Bounded storage. Delivered/dead rows are purged by the framework retention sweep after
retentionHours(default 72, envCDCOUT_RETENTION_HOURS); pending rows are never purged. Enqueue claims are short-lived by design — purged afterdedupWindowMs, which only has to outlive a leadership handoff.
Multi-tenancy
The change tap fires for every tenant's rows, and a sink is app-global,
developer-authored config: whatever map returns (default: the raw row,
including tenantId) is mirrored to ONE external endpoint. Use filter /
map to scope or strip tenant data, and only ever point sinks at
operator-controlled URLs — never at a tenant-supplied one.
Backfill
Seed a pipe with existing rows once — they flow through the SAME durable
outbox as live changes (ordered, retried, dead-lettered, stable per-row
deliveryKeys). Call from a *.startup.tsx or a CLI with the app's store:
import { enqueueBackfill } from '@voltro/plugin-cdc-out'
import { sinkConfig } from './cdc'
const n = await enqueueBackfill(store, sinkConfig, rows) // every current row, enqueued as an insert