@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/dist/index.js ADDED
@@ -0,0 +1,507 @@
1
+ import { Duration as e, Effect as t, Fiber as n, Schedule as r, Schema as i } from "effect";
2
+ import { definePlugin as a } from "@voltro/protocol";
3
+ import { and as o, count as s, eq as c, generateId as l, id as u, inSet as d, integer as f, json as p, lt as m, registerRetention as h, retentionTtlMsFromEnv as g, table as _, text as v, timestamp as y } from "@voltro/database";
4
+ //#region src/errors.ts
5
+ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {}, x = class extends i.TaggedError()("CdcDeliveryError", {
6
+ sink: i.String,
7
+ reason: i.String
8
+ }) {
9
+ get message() {
10
+ return `cdc-out delivery to '${this.sink}' failed: ${this.reason}`;
11
+ }
12
+ }, S = "_voltro_cdcout_outbox", C = "_voltro_cdcout_leases", w = {
13
+ kind: "typeid",
14
+ prefix: "cdcout"
15
+ }, T = () => l(w, S), E = _(S, {
16
+ id: u({ prefix: "cdcout" }),
17
+ pipe: v(),
18
+ sourceTable: v(),
19
+ op: v(),
20
+ key: v(),
21
+ payload: p().nullable(),
22
+ status: v(),
23
+ attempts: f().default(0),
24
+ nextAttemptAt: y(),
25
+ claimedBy: v().nullable(),
26
+ claimedAt: y().nullable(),
27
+ createdAt: y(),
28
+ deliveredAt: y().nullable(),
29
+ lastError: v().nullable()
30
+ }).index(["pipe", "status"]), D = _(C, {
31
+ id: u({ scheme: "numeric" }),
32
+ key: v(),
33
+ holder: v(),
34
+ expiresAt: y(),
35
+ renewedAt: y()
36
+ }).unique("cdcoutLeaseKey", ["key"]), O = (e) => e instanceof Date ? e.getTime() : new Date(String(e)).getTime(), k = class {
37
+ opts;
38
+ held = !1;
39
+ constructor(e) {
40
+ this.opts = e;
41
+ }
42
+ get isLeader() {
43
+ return this.held;
44
+ }
45
+ async heartbeat(e) {
46
+ let t = (this.opts.now ?? Date.now)(), n = {
47
+ holder: this.opts.holder,
48
+ expiresAt: new Date(t + this.opts.ttlMs),
49
+ renewedAt: new Date(t)
50
+ };
51
+ try {
52
+ if (await e.updateMany("_voltro_cdcout_leases", n, { where: o(c("key", this.opts.key), c("holder", this.opts.holder)) }) === 1) return this.held = !0, !0;
53
+ let r = await e.insertIgnore(C, {
54
+ key: this.opts.key,
55
+ ...n
56
+ }, { conflictColumns: ["key"] });
57
+ if (r.holder === this.opts.holder) return this.held = !0, !0;
58
+ let i = await e.updateMany(C, n, { where: o(c("key", this.opts.key), c("holder", String(r.holder)), m("expiresAt", new Date(t))) });
59
+ return this.held = i === 1, this.held;
60
+ } catch {
61
+ return this.held = !1, !1;
62
+ }
63
+ }
64
+ async release(e) {
65
+ this.held = !1;
66
+ try {
67
+ await e.updateMany(C, { expiresAt: /* @__PURE__ */ new Date(0) }, { where: o(c("key", this.opts.key), c("holder", this.opts.holder)) });
68
+ } catch {}
69
+ }
70
+ }, A = (n, r, i) => t.suspend(() => {
71
+ let a = new AbortController();
72
+ return t.try(() => n.deliver(r, { signal: a.signal })).pipe(t.flatMap((e) => t.isEffect(e) ? e : t.tryPromise(() => e))).pipe(t.timeoutFail({
73
+ duration: e.millis(i.timeoutMs),
74
+ onTimeout: () => new x({
75
+ sink: n.name,
76
+ reason: `delivery timed out after ${i.timeoutMs}ms`
77
+ })
78
+ }), t.onError(() => t.sync(() => a.abort())), t.mapError((e) => e instanceof x ? e : j(n.name, e)), t.withSpan("cdcOut.deliver", { attributes: {
79
+ pipe: i.pipe,
80
+ sink: n.name,
81
+ records: r.length
82
+ } }));
83
+ }), j = (e, t) => {
84
+ let n = typeof t == "object" && t && "error" in t ? t.error : t;
85
+ return new x({
86
+ sink: e,
87
+ reason: n instanceof Error ? n.message : String(n)
88
+ });
89
+ }, M = (e, t) => {
90
+ let n = Math.min(t.baseMs * 2 ** Math.max(0, e - 1), t.maxMs);
91
+ return Math.round(n * (.5 + .5 * (t.random ?? Math.random)()));
92
+ }, N = class {
93
+ opts;
94
+ now;
95
+ stuckClaimMs;
96
+ constructor(e) {
97
+ this.opts = e, this.now = e.now ?? Date.now, this.stuckClaimMs = Math.max(2 * e.deliveryTimeoutMs, 1e4);
98
+ }
99
+ async tick(e) {
100
+ this.opts.isLeader() && await Promise.all(this.opts.pipes.map(async (t) => {
101
+ if (!t.busy) {
102
+ t.busy = !0;
103
+ try {
104
+ await this.pipeTick(e, t);
105
+ } finally {
106
+ t.busy = !1;
107
+ }
108
+ }
109
+ }));
110
+ }
111
+ async pipeTick(e, n) {
112
+ let r = this.now();
113
+ if (await e.updateMany(S, {
114
+ status: "pending",
115
+ claimedBy: null,
116
+ claimedAt: null
117
+ }, { where: o(c("pipe", n.key), c("status", "delivering"), m("claimedAt", new Date(r - this.stuckClaimMs))) }), (await e.query({
118
+ table: "_voltro_cdcout_outbox",
119
+ predicate: o(c("pipe", n.key), c("status", "delivering")),
120
+ order: [],
121
+ take: 1,
122
+ skip: void 0,
123
+ projection: ["id"]
124
+ })).length > 0) return;
125
+ let i = await e.query({
126
+ table: S,
127
+ predicate: o(c("pipe", n.key), c("status", "pending")),
128
+ order: [{
129
+ column: "id",
130
+ direction: "asc"
131
+ }],
132
+ take: n.batchSize,
133
+ skip: void 0,
134
+ projection: void 0
135
+ });
136
+ if (i.length === 0) return;
137
+ let a = [];
138
+ for (let e of i) {
139
+ if (O(e.nextAttemptAt) > r) break;
140
+ a.push(e);
141
+ }
142
+ if (a.length === 0) return;
143
+ let s = a.map((e) => e.id);
144
+ if (await e.updateMany("_voltro_cdcout_outbox", {
145
+ status: "delivering",
146
+ claimedBy: this.opts.replicaId,
147
+ claimedAt: new Date(r)
148
+ }, { where: o(c("pipe", n.key), d("id", s), c("status", "pending")) }) === 0) return;
149
+ let l = await e.query({
150
+ table: S,
151
+ predicate: o(c("pipe", n.key), d("id", s), c("status", "delivering"), c("claimedBy", this.opts.replicaId)),
152
+ order: [{
153
+ column: "id",
154
+ direction: "asc"
155
+ }],
156
+ take: void 0,
157
+ skip: void 0,
158
+ projection: void 0
159
+ });
160
+ if (l.length === 0) return;
161
+ let u = l.map((e) => ({
162
+ table: e.sourceTable,
163
+ op: e.op,
164
+ key: e.key,
165
+ data: e.payload,
166
+ deliveryKey: e.id
167
+ })), f = await t.runPromise(t.either(A(n.sink, u, {
168
+ timeoutMs: this.opts.deliveryTimeoutMs,
169
+ pipe: n.key
170
+ }))), p = this.now();
171
+ if (f._tag === "Right") {
172
+ await e.updateMany(S, {
173
+ status: "delivered",
174
+ deliveredAt: new Date(p),
175
+ claimedBy: null,
176
+ claimedAt: null,
177
+ lastError: null
178
+ }, { where: o(c("pipe", n.key), d("id", l.map((e) => e.id)), c("status", "delivering"), c("claimedBy", this.opts.replicaId)) }), n.stats.delivered += l.length, n.stats.lastDeliveryAtMs = p;
179
+ return;
180
+ }
181
+ let h = f.left.message;
182
+ for (let t of l) {
183
+ let r = t.attempts + 1;
184
+ if (r >= this.opts.maxAttempts) await e.update(S, t.id, {
185
+ status: "dead",
186
+ attempts: r,
187
+ claimedBy: null,
188
+ claimedAt: null,
189
+ lastError: h
190
+ }), n.stats.dead += 1;
191
+ else {
192
+ let n = M(r, {
193
+ baseMs: this.opts.backoffBaseMs,
194
+ maxMs: this.opts.backoffMaxMs,
195
+ ...this.opts.random === void 0 ? {} : { random: this.opts.random }
196
+ });
197
+ await e.update(S, t.id, {
198
+ status: "pending",
199
+ attempts: r,
200
+ nextAttemptAt: new Date(p + n),
201
+ claimedBy: null,
202
+ claimedAt: null,
203
+ lastError: h
204
+ });
205
+ }
206
+ n.stats.failed += 1;
207
+ }
208
+ this.opts.warn?.("cdc-out delivery failed (durable retry scheduled)", {
209
+ pipe: n.key,
210
+ sink: n.sink.name,
211
+ records: l.length,
212
+ attempts: l[0] === void 0 ? 1 : l[0].attempts + 1,
213
+ maxAttempts: this.opts.maxAttempts,
214
+ reason: h
215
+ });
216
+ }
217
+ }, P = (e = "memory") => {
218
+ let t = [];
219
+ return {
220
+ name: e,
221
+ deliver: async (e) => {
222
+ t.push([...e]);
223
+ },
224
+ get batches() {
225
+ return t;
226
+ },
227
+ get delivered() {
228
+ return t.flat();
229
+ }
230
+ };
231
+ }, F = (e, t) => {
232
+ let n;
233
+ try {
234
+ n = new URL(e).host;
235
+ } catch {
236
+ throw new b({ message: `webhookSink: invalid url '${e}'` });
237
+ }
238
+ let r = `webhook(${n})`;
239
+ return {
240
+ name: r,
241
+ outboundHost: n,
242
+ deliver: async (n, i) => {
243
+ let a = await fetch(e, {
244
+ method: "POST",
245
+ headers: {
246
+ "content-type": "application/json",
247
+ ...t?.headers ?? {}
248
+ },
249
+ body: JSON.stringify({ records: n }),
250
+ signal: i.signal
251
+ });
252
+ if (!a.ok) throw new x({
253
+ sink: r,
254
+ reason: `${a.status} ${a.statusText}`
255
+ });
256
+ }
257
+ };
258
+ }, I = 100, L = 5, R = 200, z = 3e4, B = 250, V = 1e4, H = 15e3, U = 72, W = (e) => {
259
+ throw new b({ message: e });
260
+ }, G = (e) => {
261
+ (e.sinks === void 0 || e.sinks.length === 0) && W("cdcOutPlugin: at least one sink config is required");
262
+ let t = /* @__PURE__ */ new Set();
263
+ e.sinks.forEach((e, n) => {
264
+ (typeof e.table != "string" || e.table.length === 0) && W(`cdcOutPlugin: sinks[${n}].table is required`), e.table.startsWith("_voltro_cdcout") && W(`cdcOutPlugin: sinks[${n}].table '${e.table}' is the plugin's own delivery state — it cannot be mirrored`), t.has(e.table) && W(`cdcOutPlugin: duplicate sink config for table '${e.table}' — one sink config per table per instance; for multiple sinks on one table wire a second cdcOutPlugin({ name: '…' }) instance`), t.add(e.table), (e.sink === void 0 || typeof e.sink.deliver != "function") && W(`cdcOutPlugin: sinks[${n}] (table '${e.table}') needs a sink with a deliver(batch) function`), e.batchSize !== void 0 && e.batchSize <= 0 && W(`cdcOutPlugin: sinks[${n}] (table '${e.table}') batchSize must be > 0`);
265
+ }), e.name !== void 0 && !/^[a-zA-Z0-9-]+$/.test(e.name) && W(`cdcOutPlugin: name '${e.name}' must match [a-zA-Z0-9-]+ (it lands in the inspect URL)`);
266
+ let n = [
267
+ ["maxAttempts", e.maxAttempts],
268
+ ["backoffBaseMs", e.backoffBaseMs],
269
+ ["backoffMaxMs", e.backoffMaxMs],
270
+ ["sweepIntervalMs", e.sweepIntervalMs],
271
+ ["deliveryTimeoutMs", e.deliveryTimeoutMs],
272
+ ["leaseTtlMs", e.leaseTtlMs],
273
+ ["retentionHours", e.retentionHours]
274
+ ];
275
+ for (let [e, t] of n) t !== void 0 && t < 1 && W(`cdcOutPlugin: ${e} must be >= 1`);
276
+ }, K = (e, t) => {
277
+ if (t.filter !== void 0 && !t.filter(e)) return null;
278
+ if (e.op === "delete") {
279
+ let t = String(e.old?.id ?? "");
280
+ return t === "" ? null : {
281
+ table: e.table,
282
+ op: "delete",
283
+ key: t,
284
+ data: null
285
+ };
286
+ }
287
+ let n = e.new;
288
+ if (n === null) return null;
289
+ let r = String(n.id ?? "");
290
+ return r === "" ? null : {
291
+ table: e.table,
292
+ op: e.op,
293
+ key: r,
294
+ data: t.map ? t.map(n) : n
295
+ };
296
+ }, q = (e, t) => `${e}:${t.table}:${t.sink.name}`, J = (e, t, n) => ({
297
+ id: T(),
298
+ pipe: t,
299
+ sourceTable: e.table,
300
+ op: e.op,
301
+ key: e.key,
302
+ payload: e.data,
303
+ status: "pending",
304
+ attempts: 0,
305
+ nextAttemptAt: new Date(n),
306
+ claimedBy: null,
307
+ claimedAt: null,
308
+ createdAt: new Date(n),
309
+ deliveredAt: null,
310
+ lastError: null
311
+ }), Y = async (e, t, n, r) => {
312
+ let i = q(r?.instanceName ?? "default", t), a = (r?.now ?? Date.now)(), o = n.flatMap((e) => {
313
+ let n = String(e.id ?? "");
314
+ return n === "" ? [] : [J({
315
+ table: t.table,
316
+ op: "insert",
317
+ key: n,
318
+ data: t.map ? t.map(e) : e
319
+ }, i, a)];
320
+ });
321
+ return o.length > 0 && await e.insertMany(S, o), o.length;
322
+ }, X = (i) => {
323
+ G(i);
324
+ let l = i.name ?? "default", u = i.name ? `@voltro/plugin-cdc-out#${i.name}` : "@voltro/plugin-cdc-out", f = `cdcout-${Math.random().toString(36).slice(2, 10)}`, p = i.maxAttempts ?? L, m = i.sweepIntervalMs ?? B, _ = i.leaseTtlMs ?? H, v = /* @__PURE__ */ new Map();
325
+ for (let e of i.sinks) v.set(e.table, {
326
+ config: e,
327
+ enginePipe: {
328
+ key: q(l, e),
329
+ sink: e.sink,
330
+ batchSize: e.batchSize ?? I,
331
+ busy: !1,
332
+ stats: {
333
+ delivered: 0,
334
+ failed: 0,
335
+ dead: 0,
336
+ lastDeliveryAtMs: null
337
+ }
338
+ }
339
+ });
340
+ let y = new k({
341
+ key: l,
342
+ holder: f,
343
+ ttlMs: _
344
+ }), b = new N({
345
+ pipes: [...v.values()].map((e) => e.enginePipe),
346
+ replicaId: f,
347
+ maxAttempts: p,
348
+ backoffBaseMs: i.backoffBaseMs ?? R,
349
+ backoffMaxMs: i.backoffMaxMs ?? z,
350
+ deliveryTimeoutMs: i.deliveryTimeoutMs ?? V,
351
+ isLeader: () => y.isLeader,
352
+ warn: (e, t) => C(e, t)
353
+ }), x = { current: null }, C = () => void 0, w = 0, T, O;
354
+ h({
355
+ table: S,
356
+ timeColumn: "createdAt",
357
+ ttlMs: i.retentionHours === void 0 ? g(process.env.CDCOUT_RETENTION_HOURS, U) : i.retentionHours * 36e5,
358
+ where: d("status", ["delivered", "dead"]),
359
+ label: "cdc-out outbox (delivered/dead)"
360
+ });
361
+ let A = /* @__PURE__ */ new Set();
362
+ for (let e of i.sinks) e.sink.outboundHost && A.add(e.sink.outboundHost);
363
+ let j = [
364
+ "store:changes:read",
365
+ "store:write",
366
+ "inspect:read",
367
+ ...[...A].map((e) => `network:outbound:${e}`)
368
+ ], M = [
369
+ "pending",
370
+ "delivering",
371
+ "delivered",
372
+ "dead"
373
+ ], P = async (e, t) => {
374
+ let n = {
375
+ pending: 0,
376
+ delivering: 0,
377
+ delivered: 0,
378
+ dead: 0
379
+ };
380
+ return await Promise.all(M.map(async (r) => {
381
+ let i = await e.query({
382
+ table: S,
383
+ predicate: o(c("pipe", t), c("status", r)),
384
+ order: [],
385
+ take: void 0,
386
+ skip: void 0,
387
+ projection: void 0,
388
+ aggregations: [s("n")]
389
+ });
390
+ n[r] = Number(i[0]?.n ?? 0);
391
+ })), n;
392
+ };
393
+ return a({
394
+ name: u,
395
+ description: "Declarative reverse-ETL: 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.",
396
+ permissions: j,
397
+ declaredEnv: [{
398
+ name: "CDCOUT_RETENTION_HOURS",
399
+ required: !1,
400
+ secret: !1,
401
+ description: "Hours delivered/dead outbox rows are kept before the retention sweep purges them (default 72).",
402
+ example: "72"
403
+ }],
404
+ extendSchema: { tables: [E, D] },
405
+ bindDataStore: (e) => {
406
+ x.current = e;
407
+ },
408
+ inspectEndpoints: [{
409
+ method: "GET",
410
+ path: "/sinks",
411
+ description: "Configured sinks + per-pipe outbox counts and delivery stats.",
412
+ handler: () => t.tryPromise(async () => {
413
+ let e = x.current;
414
+ return {
415
+ kind: "json",
416
+ data: { sinks: await Promise.all([...v.values()].map(async ({ config: t, enginePipe: n }) => {
417
+ let r = e === null ? {
418
+ pending: 0,
419
+ delivering: 0,
420
+ delivered: 0,
421
+ dead: 0
422
+ } : await P(e, n.key);
423
+ return {
424
+ table: t.table,
425
+ sink: t.sink.name,
426
+ pipe: n.key,
427
+ ...r,
428
+ failed: r.dead,
429
+ lastDeliveryAtMs: n.stats.lastDeliveryAtMs
430
+ };
431
+ })) }
432
+ };
433
+ })
434
+ }, {
435
+ method: "GET",
436
+ path: "/dead-letter",
437
+ description: "Outbox rows that exhausted maxAttempts (newest first, capped at 100).",
438
+ handler: () => t.tryPromise(async () => {
439
+ let e = x.current;
440
+ return e === null ? {
441
+ kind: "json",
442
+ data: { rows: [] }
443
+ } : {
444
+ kind: "json",
445
+ data: { rows: (await e.query({
446
+ table: S,
447
+ predicate: c("status", "dead"),
448
+ order: [{
449
+ column: "id",
450
+ direction: "desc"
451
+ }],
452
+ take: 100,
453
+ skip: void 0,
454
+ projection: void 0
455
+ })).map((e) => ({
456
+ deliveryKey: e.id,
457
+ pipe: e.pipe,
458
+ table: e.sourceTable,
459
+ op: e.op,
460
+ key: e.key,
461
+ attempts: e.attempts,
462
+ lastError: e.lastError,
463
+ createdAt: e.createdAt
464
+ })) }
465
+ };
466
+ })
467
+ }],
468
+ onChangeEvent: (e) => {
469
+ let n = v.get(e.table);
470
+ if (n === void 0) return t.void;
471
+ if (e.changeScope === "fleet") {
472
+ if (!y.isLeader) return t.void;
473
+ } else if (e.origin === "injected") return t.void;
474
+ let r = K(e, n.config);
475
+ if (r === null) return t.void;
476
+ let i = x.current;
477
+ if (i === null) {
478
+ let n = Date.now();
479
+ return n - w > 6e4 && (w = n, C("cdc-out: no DataStore bound — change events are NOT being mirrored", { table: e.table })), t.void;
480
+ }
481
+ let a = J(r, n.enginePipe.key, Date.now());
482
+ return t.tryPromise(() => i.insert(S, a).then(() => void 0));
483
+ },
484
+ onActivate: (n) => t.sync(() => {
485
+ C = (e, t) => n.logger.warn(e, t);
486
+ let i = t.suspend(() => {
487
+ let e = x.current;
488
+ return e === null ? t.void : t.promise(() => y.heartbeat(e).then(() => void 0));
489
+ }).pipe(t.repeat(r.spaced(e.millis(Math.max(1, Math.floor(_ / 3)))))), a = t.suspend(() => {
490
+ let e = x.current;
491
+ return e === null ? t.void : t.tryPromise(() => b.tick(e)).pipe(t.catchAll((e) => t.sync(() => C("cdc-out worker sweep failed", { reason: String(e) }))));
492
+ }).pipe(t.repeat(r.spaced(e.millis(m))));
493
+ O = t.runFork(i), T = t.runFork(a), n.logger.info("cdc-out active", {
494
+ tables: [...v.keys()],
495
+ replicaId: f,
496
+ outbox: S
497
+ });
498
+ }),
499
+ onDeactivate: () => t.gen(function* () {
500
+ T !== void 0 && (yield* n.interrupt(T)), O !== void 0 && (yield* n.interrupt(O)), T = void 0, O = void 0;
501
+ let e = x.current;
502
+ e !== null && (yield* t.promise(() => y.release(e)));
503
+ })
504
+ });
505
+ };
506
+ //#endregion
507
+ export { C as CDCOUT_LEASES_TABLE, S as CDCOUT_OUTBOX_TABLE, b as CdcConfigError, x as CdcDeliveryError, k as CdcLeaseManager, N as CdcOutEngine, D as cdcOutLeasesTable, E as cdcOutOutboxTable, X as cdcOutPlugin, A as deliverOnce, Y as enqueueBackfill, K as mapChange, P as memorySink, T as nextOutboxId, J as outboxRowFor, q as pipeKey, M as retryDelayMs, G as validateCdcOutConfig, F as webhookSink };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@voltro/plugin-cdc-out",
3
+ "version": "0.1.0",
4
+ "description": "Declarative reverse-ETL: mirror framework 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.",
5
+ "keywords": [
6
+ "voltro",
7
+ "typescript",
8
+ "framework"
9
+ ],
10
+ "license": "SEE LICENSE IN LICENSE",
11
+ "homepage": "https://voltro.dev",
12
+ "bugs": {
13
+ "email": "support@voltro.dev"
14
+ },
15
+ "author": {
16
+ "name": "Voltro UG",
17
+ "url": "https://voltro.dev"
18
+ },
19
+ "type": "module",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js",
24
+ "default": "./dist/index.js"
25
+ }
26
+ },
27
+ "main": "./dist/index.js",
28
+ "module": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "sideEffects": false,
31
+ "engines": {
32
+ "node": ">=24.0.0"
33
+ },
34
+ "dependencies": {
35
+ "@voltro/database": "0.1.0",
36
+ "@voltro/protocol": "0.1.0"
37
+ },
38
+ "peerDependencies": {
39
+ "effect": "^3.21.4"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }