CDC-out (reverse-ETL)
Declaratively mirror table changes outward to external sinks (webhook / Kafka / Snowflake / BigQuery) 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
}),
],
}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.- Warehouse / Kafka — 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.
- Exactly-once ENQUEUE, fleet-wide. On
changeScope: 'local'stores each replica enqueues only its OWN commits (injected cross-replica events are skipped). On'fleet'stores (postgreschangeStrategy: 'cdc'LISTEN/NOTIFY, mysql binlog) only the holder of the leader lease (_voltro_cdcout_leases, TTL-heartbeat) enqueues. Fail closed: no lease, no enqueue. - 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.
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