@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.
- package/CHANGELOG.md +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +347 -0
- package/dist/index.d.ts +296 -0
- package/dist/index.js +507 -0
- package/package.json +44 -0
package/dist/index.d.ts
ADDED
|
@@ -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 { }
|