@voltro/plugin-cdc-out 0.32.0 → 0.34.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 CHANGED
@@ -1,6 +1,7 @@
1
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";
2
+ import { definePlugin as a, pluginInstanceName as o } from "@voltro/protocol";
3
+ import { and as s, count as c, eq as l, generateId as u, id as d, inSet as f, integer as p, json as m, lt as h, registerRetention as ee, retentionTtlMsFromEnv as te, table as g, text as _, timestamp as v } from "@voltro/database";
4
+ import { createHash as y } from "node:crypto";
4
5
  //#region src/errors.ts
5
6
  var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {}, x = class extends i.TaggedError()("CdcDeliveryError", {
6
7
  sink: i.String,
@@ -9,31 +10,40 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
9
10
  get message() {
10
11
  return `cdc-out delivery to '${this.sink}' failed: ${this.reason}`;
11
12
  }
12
- }, S = "_voltro_cdcout_outbox", C = "_voltro_cdcout_leases", w = {
13
+ }, S = "_voltro_cdcout_outbox", C = "_voltro_cdcout_leases", w = "_voltro_cdcout_claims", T = {
13
14
  kind: "typeid",
14
15
  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 {
16
+ }, E = {
17
+ kind: "typeid",
18
+ prefix: "cdcoutclaim"
19
+ }, D = () => u(T, S), ne = () => u(E, w), O = g(S, {
20
+ id: d({ prefix: "cdcout" }),
21
+ pipe: _(),
22
+ sourceTable: _(),
23
+ op: _(),
24
+ key: _(),
25
+ payload: m().nullable(),
26
+ status: _(),
27
+ attempts: p().default(0),
28
+ nextAttemptAt: v(),
29
+ claimedBy: _().nullable(),
30
+ claimedAt: v().nullable(),
31
+ createdAt: v(),
32
+ deliveredAt: v().nullable(),
33
+ lastError: _().nullable()
34
+ }).index(["pipe", "status"]), k = g(w, {
35
+ id: d({ prefix: "cdcoutclaim" }),
36
+ pipe: _(),
37
+ changeKey: _(),
38
+ claimedBy: _(),
39
+ seenAt: v()
40
+ }).unique("cdcoutClaimKey", ["pipe", "changeKey"]).index(["seenAt"]), re = g(C, {
41
+ id: d({ scheme: "numeric" }),
42
+ key: _(),
43
+ holder: _(),
44
+ expiresAt: v(),
45
+ renewedAt: v()
46
+ }).unique("cdcoutLeaseKey", ["key"]), A = (e) => e instanceof Date ? e.getTime() : new Date(String(e)).getTime(), j = class {
37
47
  opts;
38
48
  held = !1;
39
49
  constructor(e) {
@@ -49,13 +59,13 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
49
59
  renewedAt: new Date(t)
50
60
  };
51
61
  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;
62
+ if (await e.updateMany("_voltro_cdcout_leases", n, { where: s(l("key", this.opts.key), l("holder", this.opts.holder)) }) === 1) return this.held = !0, !0;
53
63
  let r = await e.insertIgnore(C, {
54
64
  key: this.opts.key,
55
65
  ...n
56
66
  }, { conflictColumns: ["key"] });
57
67
  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))) });
68
+ let i = await e.updateMany(C, n, { where: s(l("key", this.opts.key), l("holder", String(r.holder)), h("expiresAt", new Date(t))) });
59
69
  return this.held = i === 1, this.held;
60
70
  } catch {
61
71
  return this.held = !1, !1;
@@ -64,10 +74,100 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
64
74
  async release(e) {
65
75
  this.held = !1;
66
76
  try {
67
- await e.updateMany(C, { expiresAt: /* @__PURE__ */ new Date(0) }, { where: o(c("key", this.opts.key), c("holder", this.opts.holder)) });
77
+ await e.updateMany(C, { expiresAt: /* @__PURE__ */ new Date(0) }, { where: s(l("key", this.opts.key), l("holder", this.opts.holder)) });
68
78
  } catch {}
69
79
  }
70
- }, A = (n, r, i) => t.suspend(() => {
80
+ }, M = (e) => e == null ? "null" : e instanceof Date ? JSON.stringify(e.toISOString()) : typeof e == "bigint" ? JSON.stringify(e.toString()) : Array.isArray(e) ? `[${e.map(M).join(",")}]` : typeof e == "object" ? `{${Object.entries(e).filter(([, e]) => e !== void 0).sort(([e], [t]) => e < t ? -1 : +(e > t)).map(([e, t]) => `${JSON.stringify(e)}:${M(t)}`).join(",")}}` : JSON.stringify(e) ?? "null", N = (e) => y("sha256").update([
81
+ e.pipe,
82
+ e.op,
83
+ e.key,
84
+ M(e.next),
85
+ M(e.prev)
86
+ ].join("\0")).digest("hex").slice(0, 32), P = (e, t) => `${e}:${t}`, F = (e) => {
87
+ let t = e.lastIndexOf(":");
88
+ if (t <= 0) return null;
89
+ let n = Number(e.slice(t + 1));
90
+ return !Number.isInteger(n) || n < 0 ? null : {
91
+ digest: e.slice(0, t),
92
+ occurrence: n
93
+ };
94
+ }, ie = class {
95
+ #e = /* @__PURE__ */ new Map();
96
+ #t;
97
+ #n;
98
+ constructor(e) {
99
+ this.#t = e.windowMs, this.#n = Math.max(1, e.maxEntries);
100
+ }
101
+ get size() {
102
+ return this.#e.size;
103
+ }
104
+ next(e, t) {
105
+ let n = this.#e.get(e);
106
+ if (n === void 0) return this.#e.set(e, {
107
+ count: 1,
108
+ lastMs: t
109
+ }), this.#r(), 0;
110
+ n.lastMs = t;
111
+ let r = n.count;
112
+ return n.count += 1, this.#e.delete(e), this.#e.set(e, n), r;
113
+ }
114
+ seedFromChangeKeys(e, t) {
115
+ for (let n of e) {
116
+ let e = F(n);
117
+ if (e === null) continue;
118
+ let r = this.#e.get(e.digest), i = Math.max(r?.count ?? 0, e.occurrence + 1);
119
+ this.#e.set(e.digest, {
120
+ count: i,
121
+ lastMs: r?.lastMs ?? t
122
+ });
123
+ }
124
+ this.#r();
125
+ }
126
+ prune(e) {
127
+ let t = e - this.#t;
128
+ for (let [e, n] of this.#e) n.lastMs < t && this.#e.delete(e);
129
+ }
130
+ #r() {
131
+ for (; this.#e.size > this.#n;) {
132
+ let e = this.#e.keys().next();
133
+ if (e.done === !0) return;
134
+ this.#e.delete(e.value);
135
+ }
136
+ }
137
+ }, ae = class {
138
+ #e = [];
139
+ #t = 0;
140
+ #n;
141
+ #r;
142
+ #i = 0;
143
+ constructor(e) {
144
+ this.#n = e.windowMs, this.#r = Math.max(1, e.maxEntries);
145
+ }
146
+ get size() {
147
+ return this.#e.length;
148
+ }
149
+ get droppedPending() {
150
+ return this.#i;
151
+ }
152
+ append(e) {
153
+ for (this.#e.push(e); this.#e.length > this.#r;) {
154
+ let e = this.#e.shift();
155
+ e !== void 0 && !e.enqueued && (this.#i += 1), this.#t > 0 && --this.#t;
156
+ }
157
+ }
158
+ nextPending() {
159
+ for (; this.#t < this.#e.length && this.#e[this.#t].enqueued;) this.#t += 1;
160
+ return this.#e[this.#t];
161
+ }
162
+ pendingCount() {
163
+ return this.#e.reduce((e, t) => t.enqueued ? e : e + 1, 0);
164
+ }
165
+ prune(e) {
166
+ let t = e - this.#n, n = [], r = 0;
167
+ for (let [e, i] of this.#e.entries()) i.observedAtMs >= t ? (e < this.#t && (r += 1), n.push(i)) : i.enqueued || (this.#i += 1);
168
+ this.#e = n, this.#t = r;
169
+ }
170
+ }, I = (n, r, i) => t.suspend(() => {
71
171
  let a = new AbortController();
72
172
  return t.try(() => n.deliver(r, { signal: a.signal })).pipe(t.flatMap((e) => t.isEffect(e) ? e : t.tryPromise(() => e))).pipe(t.timeoutFail({
73
173
  duration: e.millis(i.timeoutMs),
@@ -75,21 +175,21 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
75
175
  sink: n.name,
76
176
  reason: `delivery timed out after ${i.timeoutMs}ms`
77
177
  })
78
- }), t.onError(() => t.sync(() => a.abort())), t.mapError((e) => e instanceof x ? e : j(n.name, e)), t.withSpan("cdcOut.deliver", { attributes: {
178
+ }), t.onError(() => t.sync(() => a.abort())), t.mapError((e) => e instanceof x ? e : L(n.name, e)), t.withSpan("cdcOut.deliver", { attributes: {
79
179
  pipe: i.pipe,
80
180
  sink: n.name,
81
181
  records: r.length
82
182
  } }));
83
- }), j = (e, t) => {
183
+ }), L = (e, t) => {
84
184
  let n = typeof t == "object" && t && "error" in t ? t.error : t;
85
185
  return new x({
86
186
  sink: e,
87
187
  reason: n instanceof Error ? n.message : String(n)
88
188
  });
89
- }, M = (e, t) => {
189
+ }, R = (e, t) => {
90
190
  let n = Math.min(t.baseMs * 2 ** Math.max(0, e - 1), t.maxMs);
91
191
  return Math.round(n * (.5 + .5 * (t.random ?? Math.random)()));
92
- }, N = class {
192
+ }, oe = class {
93
193
  opts;
94
194
  now;
95
195
  stuckClaimMs;
@@ -114,9 +214,9 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
114
214
  status: "pending",
115
215
  claimedBy: null,
116
216
  claimedAt: null
117
- }, { where: o(c("pipe", n.key), c("status", "delivering"), m("claimedAt", new Date(r - this.stuckClaimMs))) }), (await e.query({
217
+ }, { where: s(l("pipe", n.key), l("status", "delivering"), h("claimedAt", new Date(r - this.stuckClaimMs))) }), (await e.query({
118
218
  table: "_voltro_cdcout_outbox",
119
- predicate: o(c("pipe", n.key), c("status", "delivering")),
219
+ predicate: s(l("pipe", n.key), l("status", "delivering")),
120
220
  order: [],
121
221
  take: 1,
122
222
  skip: void 0,
@@ -124,7 +224,7 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
124
224
  })).length > 0) return;
125
225
  let i = await e.query({
126
226
  table: S,
127
- predicate: o(c("pipe", n.key), c("status", "pending")),
227
+ predicate: s(l("pipe", n.key), l("status", "pending")),
128
228
  order: [{
129
229
  column: "id",
130
230
  direction: "asc"
@@ -136,19 +236,19 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
136
236
  if (i.length === 0) return;
137
237
  let a = [];
138
238
  for (let e of i) {
139
- if (O(e.nextAttemptAt) > r) break;
239
+ if (A(e.nextAttemptAt) > r) break;
140
240
  a.push(e);
141
241
  }
142
242
  if (a.length === 0) return;
143
- let s = a.map((e) => e.id);
243
+ let o = a.map((e) => e.id);
144
244
  if (await e.updateMany("_voltro_cdcout_outbox", {
145
245
  status: "delivering",
146
246
  claimedBy: this.opts.replicaId,
147
247
  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({
248
+ }, { where: s(l("pipe", n.key), f("id", o), l("status", "pending")) }) === 0) return;
249
+ let c = await e.query({
150
250
  table: S,
151
- predicate: o(c("pipe", n.key), d("id", s), c("status", "delivering"), c("claimedBy", this.opts.replicaId)),
251
+ predicate: s(l("pipe", n.key), f("id", o), l("status", "delivering"), l("claimedBy", this.opts.replicaId)),
152
252
  order: [{
153
253
  column: "id",
154
254
  direction: "asc"
@@ -157,39 +257,39 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
157
257
  skip: void 0,
158
258
  projection: void 0
159
259
  });
160
- if (l.length === 0) return;
161
- let u = l.map((e) => ({
260
+ if (c.length === 0) return;
261
+ let u = c.map((e) => ({
162
262
  table: e.sourceTable,
163
263
  op: e.op,
164
264
  key: e.key,
165
265
  data: e.payload,
166
266
  deliveryKey: e.id
167
- })), f = await t.runPromise(t.either(A(n.sink, u, {
267
+ })), d = await t.runPromise(t.either(I(n.sink, u, {
168
268
  timeoutMs: this.opts.deliveryTimeoutMs,
169
269
  pipe: n.key
170
270
  }))), p = this.now();
171
- if (f._tag === "Right") {
271
+ if (d._tag === "Right") {
172
272
  await e.updateMany(S, {
173
273
  status: "delivered",
174
274
  deliveredAt: new Date(p),
175
275
  claimedBy: null,
176
276
  claimedAt: null,
177
277
  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;
278
+ }, { where: s(l("pipe", n.key), f("id", c.map((e) => e.id)), l("status", "delivering"), l("claimedBy", this.opts.replicaId)) }), n.stats.delivered += c.length, n.stats.lastDeliveryAtMs = p;
179
279
  return;
180
280
  }
181
- let h = f.left.message;
182
- for (let t of l) {
281
+ let m = d.left.message;
282
+ for (let t of c) {
183
283
  let r = t.attempts + 1;
184
284
  if (r >= this.opts.maxAttempts) await e.update(S, t.id, {
185
285
  status: "dead",
186
286
  attempts: r,
187
287
  claimedBy: null,
188
288
  claimedAt: null,
189
- lastError: h
289
+ lastError: m
190
290
  }), n.stats.dead += 1;
191
291
  else {
192
- let n = M(r, {
292
+ let n = R(r, {
193
293
  baseMs: this.opts.backoffBaseMs,
194
294
  maxMs: this.opts.backoffMaxMs,
195
295
  ...this.opts.random === void 0 ? {} : { random: this.opts.random }
@@ -200,7 +300,7 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
200
300
  nextAttemptAt: new Date(p + n),
201
301
  claimedBy: null,
202
302
  claimedAt: null,
203
- lastError: h
303
+ lastError: m
204
304
  });
205
305
  }
206
306
  n.stats.failed += 1;
@@ -208,13 +308,13 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
208
308
  this.opts.warn?.("cdc-out delivery failed (durable retry scheduled)", {
209
309
  pipe: n.key,
210
310
  sink: n.sink.name,
211
- records: l.length,
212
- attempts: l[0] === void 0 ? 1 : l[0].attempts + 1,
311
+ records: c.length,
312
+ attempts: c[0] === void 0 ? 1 : c[0].attempts + 1,
213
313
  maxAttempts: this.opts.maxAttempts,
214
- reason: h
314
+ reason: m
215
315
  });
216
316
  }
217
- }, P = (e = "memory") => {
317
+ }, z = (e = "memory") => {
218
318
  let t = [];
219
319
  return {
220
320
  name: e,
@@ -228,7 +328,7 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
228
328
  return t.flat();
229
329
  }
230
330
  };
231
- }, F = (e, t) => {
331
+ }, B = (e, t) => {
232
332
  let n;
233
333
  try {
234
334
  n = new URL(e).host;
@@ -255,14 +355,14 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
255
355
  });
256
356
  }
257
357
  };
258
- }, I = 100, L = 5, R = 200, z = 3e4, B = 250, V = 1e4, H = 15e3, U = 72, W = (e) => {
358
+ }, se = 100, ce = 5, le = 200, ue = 3e4, de = 250, fe = 1e4, V = 15e3, pe = 72, me = 6e4, he = 1e4, ge = 1e3, H = (e) => {
259
359
  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");
360
+ }, U = (e) => {
361
+ (e.sinks === void 0 || e.sinks.length === 0) && H("cdcOutPlugin: at least one sink config is required");
262
362
  let t = /* @__PURE__ */ new Set();
263
363
  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)`);
364
+ (typeof e.table != "string" || e.table.length === 0) && H(`cdcOutPlugin: sinks[${n}].table is required`), e.table.startsWith("_voltro_cdcout") && H(`cdcOutPlugin: sinks[${n}].table '${e.table}' is the plugin's own delivery state — it cannot be mirrored`), t.has(e.table) && H(`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") && H(`cdcOutPlugin: sinks[${n}] (table '${e.table}') needs a sink with a deliver(batch) function`), e.batchSize !== void 0 && e.batchSize <= 0 && H(`cdcOutPlugin: sinks[${n}] (table '${e.table}') batchSize must be > 0`);
365
+ }), e.name !== void 0 && !/^[a-zA-Z0-9-]+$/.test(e.name) && H(`cdcOutPlugin: name '${e.name}' must match [a-zA-Z0-9-]+ (it lands in the inspect URL)`);
266
366
  let n = [
267
367
  ["maxAttempts", e.maxAttempts],
268
368
  ["backoffBaseMs", e.backoffBaseMs],
@@ -270,10 +370,15 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
270
370
  ["sweepIntervalMs", e.sweepIntervalMs],
271
371
  ["deliveryTimeoutMs", e.deliveryTimeoutMs],
272
372
  ["leaseTtlMs", e.leaseTtlMs],
273
- ["retentionHours", e.retentionHours]
373
+ ["retentionHours", e.retentionHours],
374
+ ["dedupWindowMs", e.dedupWindowMs],
375
+ ["handoffBufferMs", e.handoffBufferMs],
376
+ ["handoffBufferSize", e.handoffBufferSize]
274
377
  ];
275
- for (let [e, t] of n) t !== void 0 && t < 1 && W(`cdcOutPlugin: ${e} must be >= 1`);
276
- }, K = (e, t) => {
378
+ for (let [e, t] of n) t !== void 0 && t < 1 && H(`cdcOutPlugin: ${e} must be >= 1`);
379
+ let r = e.leaseTtlMs ?? V;
380
+ e.dedupWindowMs !== void 0 && e.dedupWindowMs <= r && H(`cdcOutPlugin: dedupWindowMs (${e.dedupWindowMs}) must exceed leaseTtlMs (${r}) — a claim that expires inside a leadership handoff lets two replicas enqueue the same change`);
381
+ }, W = (e, t) => {
277
382
  if (t.filter !== void 0 && !t.filter(e)) return null;
278
383
  if (e.op === "delete") {
279
384
  let t = String(e.old?.id ?? "");
@@ -293,8 +398,8 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
293
398
  key: r,
294
399
  data: t.map ? t.map(n) : n
295
400
  };
296
- }, q = (e, t) => `${e}:${t.table}:${t.sink.name}`, J = (e, t, n) => ({
297
- id: T(),
401
+ }, G = (e, t) => `${e}:${t.table}:${t.sink.name}`, K = (e, t, n) => ({
402
+ id: D(),
298
403
  pipe: t,
299
404
  sourceTable: e.table,
300
405
  op: e.op,
@@ -308,10 +413,10 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
308
413
  createdAt: new Date(n),
309
414
  deliveredAt: null,
310
415
  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) => {
416
+ }), q = async (e, t, n, r) => {
417
+ let i = G(r?.instanceName ?? "default", t), a = (r?.now ?? Date.now)(), o = n.flatMap((e) => {
313
418
  let n = String(e.id ?? "");
314
- return n === "" ? [] : [J({
419
+ return n === "" ? [] : [K({
315
420
  table: t.table,
316
421
  op: "insert",
317
422
  key: n,
@@ -319,15 +424,19 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
319
424
  }, i, a)];
320
425
  });
321
426
  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, {
427
+ }, J = "@voltro/plugin-cdc-out", Y = (i) => {
428
+ U(i);
429
+ let u = i.name ?? "default", d = o({
430
+ base: J,
431
+ alias: i.alias,
432
+ instance: i.name
433
+ }), p = `cdcout-${Math.random().toString(36).slice(2, 10)}`, m = i.maxAttempts ?? ce, g = i.sweepIntervalMs ?? de, _ = i.leaseTtlMs ?? V, v = i.dedupWindowMs ?? Math.max(me, 4 * _), y = i.handoffBufferMs ?? v, b = i.handoffBufferSize ?? he, x = /* @__PURE__ */ new Map();
434
+ for (let e of i.sinks) x.set(e.table, {
326
435
  config: e,
327
436
  enginePipe: {
328
- key: q(l, e),
437
+ key: G(u, e),
329
438
  sink: e.sink,
330
- batchSize: e.batchSize ?? I,
439
+ batchSize: e.batchSize ?? se,
331
440
  busy: !1,
332
441
  stats: {
333
442
  delivered: 0,
@@ -337,63 +446,149 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
337
446
  }
338
447
  }
339
448
  });
340
- let y = new k({
341
- key: l,
342
- holder: f,
449
+ let C = new j({
450
+ key: u,
451
+ holder: p,
343
452
  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({
453
+ }), T = new oe({
454
+ pipes: [...x.values()].map((e) => e.enginePipe),
455
+ replicaId: p,
456
+ maxAttempts: m,
457
+ backoffBaseMs: i.backoffBaseMs ?? le,
458
+ backoffMaxMs: i.backoffMaxMs ?? ue,
459
+ deliveryTimeoutMs: i.deliveryTimeoutMs ?? fe,
460
+ isLeader: () => C.isLeader,
461
+ warn: (e, t) => D(e, t)
462
+ }), E = { current: null }, D = () => void 0, A = 0, M, F, I = new ie({
463
+ windowMs: 2 * v,
464
+ maxEntries: Math.max(b, 1e3) * 4
465
+ }), L = new ae({
466
+ windowMs: y,
467
+ maxEntries: b
468
+ }), R = !1, z = 0, B = !1, H = null, q = 0, Y = !1, _e = !1, X = [], Z = 0;
469
+ ee({
470
+ source: "plugin",
355
471
  table: S,
356
472
  timeColumn: "createdAt",
357
- ttlMs: i.retentionHours === void 0 ? g(process.env.CDCOUT_RETENTION_HOURS, U) : i.retentionHours * 36e5,
358
- where: d("status", ["delivered", "dead"]),
473
+ ttlMs: i.retentionHours === void 0 ? te(process.env.CDCOUT_RETENTION_HOURS, pe) : i.retentionHours * 36e5,
474
+ where: f("status", ["delivered", "dead"]),
359
475
  label: "cdc-out outbox (delivered/dead)"
476
+ }), ee({
477
+ source: "plugin",
478
+ table: w,
479
+ timeColumn: "seenAt",
480
+ ttlMs: v,
481
+ label: "cdc-out enqueue claims (dedup window)"
360
482
  });
361
- let A = /* @__PURE__ */ new Set();
362
- for (let e of i.sinks) e.sink.outboundHost && A.add(e.sink.outboundHost);
363
- let j = [
483
+ let ve = /* @__PURE__ */ new Set();
484
+ for (let e of i.sinks) e.sink.outboundHost && ve.add(e.sink.outboundHost);
485
+ let ye = [
364
486
  "store:changes:read",
365
487
  "store:write",
366
488
  "inspect:read",
367
- ...[...A].map((e) => `network:outbound:${e}`)
368
- ], M = [
489
+ ...[...ve].map((e) => `network:outbound:${e}`)
490
+ ], be = [
369
491
  "pending",
370
492
  "delivering",
371
493
  "delivered",
372
494
  "dead"
373
- ], P = async (e, t) => {
495
+ ], xe = async (e, t) => {
374
496
  let n = {
375
497
  pending: 0,
376
498
  delivering: 0,
377
499
  delivered: 0,
378
500
  dead: 0
379
501
  };
380
- return await Promise.all(M.map(async (r) => {
502
+ return await Promise.all(be.map(async (r) => {
381
503
  let i = await e.query({
382
504
  table: S,
383
- predicate: o(c("pipe", t), c("status", r)),
505
+ predicate: s(l("pipe", t), l("status", r)),
384
506
  order: [],
385
507
  take: void 0,
386
508
  skip: void 0,
387
509
  projection: void 0,
388
- aggregations: [s("n")]
510
+ aggregations: [c("n")]
389
511
  });
390
512
  n[r] = Number(i[0]?.n ?? 0);
391
513
  })), n;
514
+ }, Se = (e) => {
515
+ let t = Date.now();
516
+ t - A > 6e4 && (A = t, D("cdc-out: no DataStore bound — change events are NOT being mirrored", { table: e }));
517
+ }, Ce = async (e, t, n) => (await e.insertIgnore(w, {
518
+ id: ne(),
519
+ pipe: t,
520
+ changeKey: n,
521
+ claimedBy: p,
522
+ seenAt: /* @__PURE__ */ new Date()
523
+ }, { conflictColumns: ["pipe", "changeKey"] })).claimedBy === p, Q = async () => {
524
+ if (R) return;
525
+ let e = E.current;
526
+ if (e !== null) {
527
+ R = !0;
528
+ try {
529
+ for (;;) {
530
+ let t = L.nextPending();
531
+ if (t === void 0 || !C.isLeader) return;
532
+ await Ce(e, t.pipe, t.changeKey) && await e.insert(S, K(t.change, t.pipe, Date.now())), t.enqueued = !0;
533
+ }
534
+ } finally {
535
+ R = !1;
536
+ }
537
+ }
538
+ }, we = (e) => {
539
+ if (Y = !0, !B) {
540
+ for (X.push(e); X.length > b;) X.shift(), Z += 1;
541
+ return;
542
+ }
543
+ L.append({
544
+ pipe: e.pipe,
545
+ changeKey: P(e.digest, I.next(e.digest, e.observedAtMs)),
546
+ change: e.change,
547
+ observedAtMs: e.observedAtMs,
548
+ enqueued: !1
549
+ });
550
+ }, Te = (e, t) => {
551
+ I.seedFromChangeKeys(e, t), B = !0;
552
+ for (let e of X.splice(0, X.length)) we(e);
553
+ }, $ = async () => {
554
+ if (B) return;
555
+ let e = E.current;
556
+ if (e !== null) {
557
+ if (H === null) {
558
+ if (q !== 0 && Date.now() - q < ge) return;
559
+ let t = !Y;
560
+ H = (async () => {
561
+ let n = await e.query({
562
+ table: w,
563
+ predicate: void 0,
564
+ order: [{
565
+ column: "seenAt",
566
+ direction: "desc"
567
+ }],
568
+ take: Math.max(b, 1e3),
569
+ skip: void 0,
570
+ projection: ["changeKey"]
571
+ });
572
+ !t && !_e && (_e = !0, D("cdc-out: occurrence counters start from this replica's own stream — the claims snapshot was only reachable after it began observing, and counting it could duplicate a change", { replicaId: p })), Te(t ? n.flatMap((e) => typeof e.changeKey == "string" ? [e.changeKey] : []) : [], Date.now());
573
+ })().catch((e) => {
574
+ q = Date.now(), D("cdc-out: could not seed change-occurrence counters — enqueue is held until it lands", { reason: String(e) });
575
+ }).finally(() => {
576
+ H = null;
577
+ });
578
+ }
579
+ await H;
580
+ }
581
+ }, Ee = (e) => {
582
+ let t = e - y;
583
+ for (; X.length > 0 && X[0].observedAtMs < t;) X.shift(), Z += 1;
584
+ }, De = async (e, t) => {
585
+ t - z < Math.max(1e3, Math.floor(v / 4)) || (z = t, await e.deleteMany(w, { where: h("seenAt", new Date(t - v)) }));
392
586
  };
393
587
  return a({
394
- name: u,
588
+ name: d,
589
+ baseName: J,
395
590
  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,
591
+ permissions: ye,
397
592
  declaredEnv: [{
398
593
  name: "CDCOUT_RETENTION_HOURS",
399
594
  required: !1,
@@ -401,34 +596,50 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
401
596
  description: "Hours delivered/dead outbox rows are kept before the retention sweep purges them (default 72).",
402
597
  example: "72"
403
598
  }],
404
- extendSchema: { tables: [E, D] },
599
+ extendSchema: { tables: [
600
+ O,
601
+ re,
602
+ k
603
+ ] },
405
604
  bindDataStore: (e) => {
406
- x.current = e;
605
+ E.current = e, $();
407
606
  },
408
607
  inspectEndpoints: [{
409
608
  method: "GET",
410
609
  path: "/sinks",
411
610
  description: "Configured sinks + per-pipe outbox counts and delivery stats.",
412
611
  handler: () => t.tryPromise(async () => {
413
- let e = x.current;
612
+ let e = E.current;
414
613
  return {
415
614
  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
- })) }
615
+ data: {
616
+ sinks: await Promise.all([...x.values()].map(async ({ config: t, enginePipe: n }) => {
617
+ let r = e === null ? {
618
+ pending: 0,
619
+ delivering: 0,
620
+ delivered: 0,
621
+ dead: 0
622
+ } : await xe(e, n.key);
623
+ return {
624
+ table: t.table,
625
+ sink: t.sink.name,
626
+ pipe: n.key,
627
+ ...r,
628
+ failed: r.dead,
629
+ lastDeliveryAtMs: n.stats.lastDeliveryAtMs
630
+ };
631
+ })),
632
+ handoff: {
633
+ leader: C.isLeader,
634
+ seeded: B,
635
+ awaitingSeed: X.length,
636
+ buffered: L.size,
637
+ pending: L.pendingCount(),
638
+ dropped: L.droppedPending + Z,
639
+ windowMs: y,
640
+ dedupWindowMs: v
641
+ }
642
+ }
432
643
  };
433
644
  })
434
645
  }, {
@@ -436,7 +647,7 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
436
647
  path: "/dead-letter",
437
648
  description: "Outbox rows that exhausted maxAttempts (newest first, capped at 100).",
438
649
  handler: () => t.tryPromise(async () => {
439
- let e = x.current;
650
+ let e = E.current;
440
651
  return e === null ? {
441
652
  kind: "json",
442
653
  data: { rows: [] }
@@ -444,7 +655,7 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
444
655
  kind: "json",
445
656
  data: { rows: (await e.query({
446
657
  table: S,
447
- predicate: c("status", "dead"),
658
+ predicate: l("status", "dead"),
448
659
  order: [{
449
660
  column: "id",
450
661
  direction: "desc"
@@ -466,42 +677,61 @@ var b = class extends i.TaggedError()("CdcConfigError", { message: i.String }) {
466
677
  })
467
678
  }],
468
679
  onChangeEvent: (e) => {
469
- let n = v.get(e.table);
680
+ let n = x.get(e.table);
470
681
  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);
682
+ let r = W(e, n.config);
475
683
  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;
684
+ if (e.changeScope === "fleet") {
685
+ let i = Date.now(), a = N({
686
+ pipe: n.enginePipe.key,
687
+ op: r.op,
688
+ key: r.key,
689
+ next: e.new,
690
+ prev: e.old
691
+ });
692
+ return we({
693
+ pipe: n.enginePipe.key,
694
+ digest: a,
695
+ change: r,
696
+ observedAtMs: i
697
+ }), E.current === null ? (Se(e.table), t.void) : t.promise(() => $()).pipe(t.flatMap(() => C.isLeader ? t.tryPromise(() => Q()) : t.void));
480
698
  }
481
- let a = J(r, n.enginePipe.key, Date.now());
699
+ if (e.origin === "injected") return t.void;
700
+ let i = E.current;
701
+ if (i === null) return Se(e.table), t.void;
702
+ let a = K(r, n.enginePipe.key, Date.now());
482
703
  return t.tryPromise(() => i.insert(S, a).then(() => void 0));
483
704
  },
484
705
  onActivate: (n) => t.sync(() => {
485
- C = (e, t) => n.logger.warn(e, t);
706
+ D = (e, t) => n.logger.warn(e, t);
486
707
  let i = t.suspend(() => {
487
- let e = x.current;
488
- return e === null ? t.void : t.promise(() => y.heartbeat(e).then(() => void 0));
708
+ let e = E.current;
709
+ return e === null ? t.void : t.promise(async () => {
710
+ await $();
711
+ let t = C.isLeader;
712
+ await C.heartbeat(e) && !t && await Q().catch((e) => {
713
+ D("cdc-out: handoff drain failed (retried on the next sweep)", { reason: String(e) });
714
+ });
715
+ });
489
716
  }).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,
717
+ let e = E.current;
718
+ return e === null ? t.void : t.tryPromise(async () => {
719
+ let t = Date.now();
720
+ await $(), Ee(t), L.prune(t), I.prune(t), await De(e, t), C.isLeader && await Q(), await T.tick(e);
721
+ }).pipe(t.catchAll((e) => t.sync(() => D("cdc-out worker sweep failed", { reason: String(e) }))));
722
+ }).pipe(t.repeat(r.spaced(e.millis(g))));
723
+ F = t.runFork(i), M = t.runFork(a), n.logger.info("cdc-out active", {
724
+ tables: [...x.keys()],
725
+ replicaId: p,
496
726
  outbox: S
497
727
  });
498
728
  }),
499
729
  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)));
730
+ M !== void 0 && (yield* n.interrupt(M)), F !== void 0 && (yield* n.interrupt(F)), M = void 0, F = void 0;
731
+ let e = E.current;
732
+ e !== null && (yield* t.promise(() => C.release(e)));
503
733
  })
504
734
  });
505
735
  };
506
736
  //#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 };
737
+ export { w as CDCOUT_CLAIMS_TABLE, C as CDCOUT_LEASES_TABLE, S as CDCOUT_OUTBOX_TABLE, b as CdcConfigError, x as CdcDeliveryError, j as CdcLeaseManager, oe as CdcOutEngine, ae as HandoffBuffer, ie as OccurrenceCounter, M as canonicalJson, k as cdcOutClaimsTable, re as cdcOutLeasesTable, O as cdcOutOutboxTable, Y as cdcOutPlugin, N as changeDigest, P as composeChangeKey, I as deliverOnce, q as enqueueBackfill, W as mapChange, z as memorySink, ne as nextClaimId, D as nextOutboxId, K as outboxRowFor, F as parseChangeKey, G as pipeKey, R as retryDelayMs, U as validateCdcOutConfig, B as webhookSink };