@voltro/plugin-cdc-out 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,296 @@
1
+ import { DataStore } from '@voltro/database';
2
+ import { Effect } from 'effect';
3
+ import { PluginChangeEvent } from '@voltro/protocol';
4
+ import { Row } from '@voltro/database';
5
+ import { Schema } from 'effect';
6
+ import { TableLike } from '@voltro/database';
7
+ import { VoltroPlugin } from '@voltro/protocol';
8
+
9
+ /**
10
+ * Thrown at plugin-construction time (= `app.config.ts` load = boot) when the
11
+ * cdc-out config is malformed: no sinks, a sink without `deliver`, a duplicate
12
+ * `table` in one instance, a non-positive numeric option, or an invalid
13
+ * webhook url. Boot aborts loudly — misconfig never ships silently.
14
+ */
15
+ export declare class CdcConfigError extends CdcConfigError_base {
16
+ }
17
+
18
+ declare const CdcConfigError_base: Schema.TaggedErrorClass<CdcConfigError, "CdcConfigError", {
19
+ readonly _tag: Schema.tag<"CdcConfigError">;
20
+ } & {
21
+ /** Human-readable detail, naming the offending `sinks[i]` / option / value. */
22
+ message: typeof Schema.String;
23
+ }>;
24
+
25
+ /**
26
+ * A single delivery attempt to a sink failed — a thrown sink error, a non-2xx
27
+ * webhook response, or the per-attempt `deliveryTimeoutMs` elapsing. The
28
+ * engine catches it, records `lastError` on the affected outbox rows, and
29
+ * schedules the retry (or dead-letters after `maxAttempts`).
30
+ */
31
+ export declare class CdcDeliveryError extends CdcDeliveryError_base {
32
+ get message(): string;
33
+ }
34
+
35
+ declare const CdcDeliveryError_base: Schema.TaggedErrorClass<CdcDeliveryError, "CdcDeliveryError", {
36
+ readonly _tag: Schema.tag<"CdcDeliveryError">;
37
+ } & {
38
+ /** The sink's `name`. */
39
+ sink: typeof Schema.String;
40
+ /** Human-readable failure detail (status line, timeout, thrown message). */
41
+ reason: typeof Schema.String;
42
+ }>;
43
+
44
+ /**
45
+ * One instance-scoped lease, heartbeat-renewed. `isLeader` is the in-memory
46
+ * view the tap + workers consult; it flips false the moment a heartbeat fails
47
+ * (fail closed: no lease, no enqueue, no delivery).
48
+ *
49
+ * Correctness mirrors the scheduler's claims table: every transition is ONE
50
+ * atomic statement, so two replicas racing resolve to exactly one holder —
51
+ * renew: UPDATE … WHERE key = ? AND holder = me
52
+ * bootstrap: INSERT … ON CONFLICT (key) DO NOTHING
53
+ * takeover: UPDATE … WHERE key = ? AND holder = <seen> AND expiresAt <= now
54
+ * The standard lease caveat applies (a paused-then-resumed old leader can
55
+ * overlap for up to one heartbeat) — which is why delivery claims are ALSO
56
+ * CAS-guarded per row in the engine.
57
+ */
58
+ export declare class CdcLeaseManager {
59
+ private readonly opts;
60
+ private held;
61
+ constructor(opts: {
62
+ readonly key: string;
63
+ readonly holder: string;
64
+ readonly ttlMs: number;
65
+ readonly now?: () => number;
66
+ });
67
+ get isLeader(): boolean;
68
+ /** Acquire or renew the lease. Returns (and records) whether THIS process
69
+ * holds it. Any store error → fail closed (false). */
70
+ heartbeat(store: DataStore): Promise<boolean>;
71
+ /** Give the lease up (graceful shutdown) — expire it so failover is
72
+ * immediate instead of waiting out the TTL. */
73
+ release(store: DataStore): Promise<void>;
74
+ }
75
+
76
+ export declare const CDCOUT_LEASES_TABLE = "_voltro_cdcout_leases";
77
+
78
+ export declare const CDCOUT_OUTBOX_TABLE = "_voltro_cdcout_outbox";
79
+
80
+ export declare class CdcOutEngine {
81
+ private readonly opts;
82
+ private readonly now;
83
+ /** Rows stuck in `delivering` longer than this are treated as a crashed
84
+ * claimant's and returned to `pending`. A LIVE worker always resolves a
85
+ * batch within `deliveryTimeoutMs`, so 2× (floored) can never steal from
86
+ * a live one. */
87
+ private readonly stuckClaimMs;
88
+ constructor(opts: CdcOutEngineOptions);
89
+ /** One sweep over every pipe. Leader-gated (fail closed): a non-leader
90
+ * replica's workers never touch the outbox. */
91
+ tick(store: DataStore): Promise<void>;
92
+ private pipeTick;
93
+ }
94
+
95
+ export declare interface CdcOutEngineOptions {
96
+ readonly pipes: ReadonlyArray<EnginePipe>;
97
+ readonly replicaId: string;
98
+ readonly maxAttempts: number;
99
+ readonly backoffBaseMs: number;
100
+ readonly backoffMaxMs: number;
101
+ readonly deliveryTimeoutMs: number;
102
+ /** Injectable clock (tests). Default `Date.now`. */
103
+ readonly now?: () => number;
104
+ /** Injectable jitter source (tests). Default `Math.random`. */
105
+ readonly random?: () => number;
106
+ readonly isLeader: () => boolean;
107
+ readonly warn?: (message: string, fields?: Record<string, unknown>) => void;
108
+ }
109
+
110
+ export declare const cdcOutLeasesTable: TableLike;
111
+
112
+ export declare interface CdcOutOptions {
113
+ readonly sinks: ReadonlyArray<SinkConfig>;
114
+ /** Delivery attempts per record before it dead-letters (default 5). */
115
+ readonly maxAttempts?: number;
116
+ /**
117
+ * Instance name — REQUIRED when wiring the plugin more than once (e.g. two
118
+ * sinks for one table = two instances). Suffixes the plugin name
119
+ * (`@voltro/plugin-cdc-out#analytics`), the inspect mount
120
+ * (`…/plugins/cdc-out--analytics/…`) and the outbox pipe keys.
121
+ */
122
+ readonly name?: string;
123
+ /** First retry delay; doubles per attempt with jitter (default 200). */
124
+ readonly backoffBaseMs?: number;
125
+ /** Retry delay ceiling (default 30_000). */
126
+ readonly backoffMaxMs?: number;
127
+ /** Outbox poll cadence of the delivery workers (default 250). */
128
+ readonly sweepIntervalMs?: number;
129
+ /** Per-attempt delivery timeout — aborts the sink call (default 10_000). */
130
+ readonly deliveryTimeoutMs?: number;
131
+ /** Leader-lease TTL; heartbeat renews at ttl/3 (default 15_000). */
132
+ readonly leaseTtlMs?: number;
133
+ /** Retention for delivered/dead outbox rows, in hours (default: the
134
+ * `CDCOUT_RETENTION_HOURS` env var, else 72). */
135
+ readonly retentionHours?: number;
136
+ }
137
+
138
+ export declare const cdcOutOutboxTable: TableLike;
139
+
140
+ export declare const cdcOutPlugin: (options: CdcOutOptions) => VoltroPlugin;
141
+
142
+ /**
143
+ * A delivery target. `deliver` may return a Promise OR an Effect — async
144
+ * connector authors and Effect-native ones both compose without wrapping.
145
+ * A throw / rejection / Effect failure marks the attempt failed; the engine
146
+ * retries with backoff and dead-letters after `maxAttempts`.
147
+ */
148
+ export declare interface CdcSink {
149
+ readonly name: string;
150
+ readonly deliver: (batch: ReadonlyArray<SinkRecord>, ctx: DeliverContext) => Promise<void> | Effect.Effect<void, unknown>;
151
+ /** Host the sink talks to — surfaced as a `network:outbound:<host>` permission. */
152
+ readonly outboundHost?: string;
153
+ }
154
+
155
+ /** Passed to `deliver` — abort fires when the attempt times out
156
+ * (`deliveryTimeoutMs`) so an HTTP sink can cancel its request. */
157
+ export declare interface DeliverContext {
158
+ readonly signal: AbortSignal;
159
+ }
160
+
161
+ /**
162
+ * Run ONE delivery attempt against a sink: supports Promise- and
163
+ * Effect-returning `deliver` implementations, fails typed
164
+ * (`CdcDeliveryError`), times out after `timeoutMs` (aborting the handed-in
165
+ * `AbortSignal` so a hung HTTP call is truly cancelled), and wraps the
166
+ * attempt in a span.
167
+ */
168
+ export declare const deliverOnce: (sink: CdcSink, batch: ReadonlyArray<SinkRecord>, opts: {
169
+ readonly timeoutMs: number;
170
+ readonly pipe: string;
171
+ }) => Effect.Effect<void, CdcDeliveryError>;
172
+
173
+ export declare interface EnginePipe {
174
+ /** Pipe identity — `<instance>:<table>:<sink name>` (the outbox `pipe` column). */
175
+ readonly key: string;
176
+ readonly sink: CdcSink;
177
+ readonly batchSize: number;
178
+ /** In-process serialisation: one in-flight batch per pipe. */
179
+ busy: boolean;
180
+ readonly stats: PipeStats;
181
+ }
182
+
183
+ /**
184
+ * Seed a pipe with existing rows (mirror of search's `backfillIndex`): every
185
+ * current row is enqueued through the SAME durable outbox as live changes —
186
+ * delivered in order, retried, dead-lettered, with a stable per-row
187
+ * `deliveryKey`. Call from a `*.startup.tsx` / CLI with the app's store.
188
+ * Rows without an `id` are skipped. Returns the number enqueued.
189
+ */
190
+ export declare const enqueueBackfill: (store: DataStore, config: SinkConfig, rows: ReadonlyArray<Record<string, unknown>>, opts?: {
191
+ readonly instanceName?: string;
192
+ readonly now?: () => number;
193
+ }) => Promise<number>;
194
+
195
+ /** Map one ChangeEvent through a config's filter + map, or null when
196
+ * filtered / unkeyable. */
197
+ export declare const mapChange: (event: PluginChangeEvent, config: SinkConfig) => MappedChange | null;
198
+
199
+ /** A change mapped through a pipe's filter + map, ready to enqueue. */
200
+ export declare interface MappedChange {
201
+ readonly table: string;
202
+ readonly op: 'insert' | 'update' | 'delete';
203
+ readonly key: string;
204
+ readonly data: Record<string, unknown> | null;
205
+ }
206
+
207
+ /** In-process sink — records every delivered batch. For dev + tests. */
208
+ export declare const memorySink: (name?: string) => CdcSink & {
209
+ readonly batches: ReadonlyArray<ReadonlyArray<SinkRecord>>;
210
+ readonly delivered: ReadonlyArray<SinkRecord>;
211
+ };
212
+
213
+ /** Generate the next outbox id (== deliveryKey). Called SYNCHRONOUSLY in the
214
+ * change tap, so ids carry commit order (uuidv7 is monotonic per process). */
215
+ export declare const nextOutboxId: () => string;
216
+
217
+ export declare interface OutboxRow {
218
+ readonly id: string;
219
+ readonly pipe: string;
220
+ readonly sourceTable: string;
221
+ readonly op: 'insert' | 'update' | 'delete';
222
+ readonly key: string;
223
+ readonly payload: Record<string, unknown> | null;
224
+ readonly status: OutboxStatus;
225
+ readonly attempts: number;
226
+ readonly nextAttemptAt: Date;
227
+ readonly claimedBy: string | null;
228
+ readonly claimedAt: Date | null;
229
+ readonly createdAt: Date;
230
+ readonly deliveredAt: Date | null;
231
+ readonly lastError: string | null;
232
+ }
233
+
234
+ /** Build one pending outbox row for a mapped change. The freshly generated
235
+ * TypeID `id` is the record's `deliveryKey`. */
236
+ export declare const outboxRowFor: (mapped: MappedChange, pipe: string, nowMs: number) => Row;
237
+
238
+ export declare type OutboxStatus = 'pending' | 'delivering' | 'delivered' | 'dead';
239
+
240
+ /** Pipe identity — `<instance>:<table>:<sink name>` (the outbox `pipe` column). */
241
+ export declare const pipeKey: (instanceName: string, config: SinkConfig) => string;
242
+
243
+ export declare interface PipeStats {
244
+ delivered: number;
245
+ failed: number;
246
+ dead: number;
247
+ lastDeliveryAtMs: number | null;
248
+ }
249
+
250
+ /** Exponential backoff with full-range-of-upper-half jitter:
251
+ * `min(base·2^(attempts-1), max) × (0.5 + 0.5·random)`. */
252
+ export declare const retryDelayMs: (attempts: number, opts: {
253
+ readonly baseMs: number;
254
+ readonly maxMs: number;
255
+ readonly random?: () => number;
256
+ }) => number;
257
+
258
+ export declare interface SinkConfig {
259
+ readonly table: string;
260
+ readonly sink: CdcSink;
261
+ /** Map a row → the outbound record body. Default: the row unchanged. */
262
+ readonly map?: (row: Record<string, unknown>) => Record<string, unknown>;
263
+ /** Skip a change when this returns false. */
264
+ readonly filter?: (event: PluginChangeEvent) => boolean;
265
+ /** Max records per delivered batch (default 100). */
266
+ readonly batchSize?: number;
267
+ }
268
+
269
+ export declare interface SinkRecord {
270
+ readonly table: string;
271
+ readonly op: 'insert' | 'update' | 'delete';
272
+ /** The source row's id. */
273
+ readonly key: string;
274
+ /** The mapped record body (null on delete). */
275
+ readonly data: Record<string, unknown> | null;
276
+ /**
277
+ * At-least-once dedupe handle: the record's durable outbox id (a TypeID —
278
+ * unique across replicas, stable across restarts and retries). A retried
279
+ * batch re-sends the SAME keys; a sink that upserts on `deliveryKey`
280
+ * collapses duplicates safely.
281
+ */
282
+ readonly deliveryKey: string;
283
+ }
284
+
285
+ /** Validate config — loud, typed boot error (`CdcConfigError`) on anything
286
+ * malformed, including a DUPLICATE `table` (never silent last-wins). */
287
+ export declare const validateCdcOutConfig: (options: CdcOutOptions) => void;
288
+
289
+ /** POST each batch as JSON to a webhook. The simplest real sink. Honors the
290
+ * engine's per-attempt abort signal, so a hung connection is cancelled when
291
+ * the delivery times out. */
292
+ export declare const webhookSink: (url: string, init?: {
293
+ readonly headers?: Record<string, string>;
294
+ }) => CdcSink;
295
+
296
+ export { }