@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.d.ts CHANGED
@@ -6,6 +6,14 @@ import { Schema } from 'effect';
6
6
  import { TableLike } from '@voltro/database';
7
7
  import { VoltroPlugin } from '@voltro/protocol';
8
8
 
9
+ /**
10
+ * Deterministic JSON — object keys sorted at every depth, so two replicas
11
+ * that decoded the same row into differently-ordered objects still digest
12
+ * identically. `Date`s normalise to ISO (a binlog reader hands back `Date`s
13
+ * where a NOTIFY payload hands back strings), `undefined` to null.
14
+ */
15
+ export declare const canonicalJson: (value: unknown) => string;
16
+
9
17
  /**
10
18
  * Thrown at plugin-construction time (= `app.config.ts` load = boot) when the
11
19
  * cdc-out config is malformed: no sinks, a sink without `deliver`, a duplicate
@@ -73,10 +81,25 @@ export declare class CdcLeaseManager {
73
81
  release(store: DataStore): Promise<void>;
74
82
  }
75
83
 
84
+ export declare const CDCOUT_CLAIMS_TABLE = "_voltro_cdcout_claims";
85
+
76
86
  export declare const CDCOUT_LEASES_TABLE = "_voltro_cdcout_leases";
77
87
 
78
88
  export declare const CDCOUT_OUTBOX_TABLE = "_voltro_cdcout_outbox";
79
89
 
90
+ /**
91
+ * The enqueue idempotence key. `unique(pipe, changeKey)` is the whole point:
92
+ * an enqueue is an `insertIgnore` here FIRST, and only the replica whose
93
+ * `claimedBy` comes back writes the outbox row. That is what lets every
94
+ * replica try during a leadership gap without producing duplicates.
95
+ *
96
+ * `claimedBy` also makes the claim RE-ENTRANT for its owner: a replica that
97
+ * claimed and then failed the outbox insert reads its own id back on the next
98
+ * drain and finishes the job, instead of treating its own claim as somebody
99
+ * else's and dropping the change.
100
+ */
101
+ export declare const cdcOutClaimsTable: TableLike;
102
+
80
103
  export declare class CdcOutEngine {
81
104
  private readonly opts;
82
105
  private readonly now;
@@ -119,6 +142,24 @@ export declare interface CdcOutOptions {
119
142
  * (`@voltro/plugin-cdc-out#analytics`), the inspect mount
120
143
  * (`…/plugins/cdc-out--analytics/…`) and the outbox pipe keys.
121
144
  */
145
+ /**
146
+ * Namespace for this plugin's inspect endpoints. Default `cdc-out`.
147
+ *
148
+ * Set it when your app already publishes under that name — an exact tag
149
+ * collision is fatal at codegen, and this is the way out. Orthogonal to
150
+ * `name` below: `alias` REPLACES the namespace, `name` distinguishes two
151
+ * installations within it.
152
+ *
153
+ * The cost, stated because nothing else states it: the local and cloud
154
+ * dashboards fetch this plugin's panel at the DEFAULT slug, so an aliased
155
+ * install keeps working while its dashboard panel 404s. Alias to escape a
156
+ * collision, not for taste.
157
+ */
158
+ readonly alias?: string;
159
+ /**
160
+ * Discriminator for a SECOND installation of this plugin, when one app runs
161
+ * two (`@voltro/plugin-cdc-out#analytics`). Not a rename — for that use `alias`.
162
+ */
122
163
  readonly name?: string;
123
164
  /** First retry delay; doubles per attempt with jitter (default 200). */
124
165
  readonly backoffBaseMs?: number;
@@ -133,6 +174,27 @@ export declare interface CdcOutOptions {
133
174
  /** Retention for delivered/dead outbox rows, in hours (default: the
134
175
  * `CDCOUT_RETENTION_HOURS` env var, else 72). */
135
176
  readonly retentionHours?: number;
177
+ /**
178
+ * How long an enqueue claim (`_voltro_cdcout_claims`) is kept — the window
179
+ * inside which two replicas racing to enqueue the same fleet change
180
+ * collapse to one row. It only has to outlive a leadership handoff, so the
181
+ * default is `max(60_000, 4 × leaseTtlMs)`.
182
+ *
183
+ * Raising it costs one small row per change for longer and widens the span
184
+ * over which two byte-identical changes to the same row are told apart by
185
+ * their occurrence counter rather than by content. Lowering it below
186
+ * `leaseTtlMs + one heartbeat` is what re-opens the duplicate window.
187
+ */
188
+ readonly dedupWindowMs?: number;
189
+ /** How far back each replica keeps observed fleet changes in memory for a
190
+ * takeover to drain (default: `dedupWindowMs`). A handoff that takes longer
191
+ * than this loses the changes older than the window — reported as
192
+ * `droppedPending` on the `/sinks` inspect endpoint. */
193
+ readonly handoffBufferMs?: number;
194
+ /** Hard ceiling on buffered fleet changes per instance — oldest dropped
195
+ * first (default 10_000). Bounds the memory a write burst during a
196
+ * leadership gap can cost. */
197
+ readonly handoffBufferSize?: number;
136
198
  }
137
199
 
138
200
  export declare const cdcOutOutboxTable: TableLike;
@@ -152,6 +214,20 @@ export declare interface CdcSink {
152
214
  readonly outboundHost?: string;
153
215
  }
154
216
 
217
+ /** The content half of a change identity — equal iff two replicas are looking
218
+ * at the same change (or at two changes with byte-identical images, which the
219
+ * occurrence counter then separates). */
220
+ export declare const changeDigest: (input: {
221
+ readonly pipe: string;
222
+ readonly op: "insert" | "update" | "delete";
223
+ readonly key: string;
224
+ readonly next: Record<string, unknown> | null;
225
+ readonly prev: Record<string, unknown> | null;
226
+ }) => string;
227
+
228
+ /** Compose the durable identity written to `_voltro_cdcout_claims.changeKey`. */
229
+ export declare const composeChangeKey: (digest: string, occurrence: number) => string;
230
+
155
231
  /** Passed to `deliver` — abort fires when the attempt times out
156
232
  * (`deliveryTimeoutMs`) so an HTTP sink can cancel its request. */
157
233
  export declare interface DeliverContext {
@@ -192,6 +268,41 @@ export declare const enqueueBackfill: (store: DataStore, config: SinkConfig, row
192
268
  readonly now?: () => number;
193
269
  }) => Promise<number>;
194
270
 
271
+ export declare class HandoffBuffer<T> {
272
+ #private;
273
+ constructor(opts: {
274
+ readonly windowMs: number;
275
+ readonly maxEntries: number;
276
+ });
277
+ get size(): number;
278
+ /** Entries evicted (by age or ceiling) while still un-enqueued — the honest
279
+ * count of changes the buffer could NOT carry across a handoff. */
280
+ get droppedPending(): number;
281
+ append(entry: HandoffEntry<T>): void;
282
+ /** The oldest entry still awaiting enqueue, or undefined. Drained in
283
+ * observation order so the outbox ids stay commit-ordered. */
284
+ nextPending(): HandoffEntry<T> | undefined;
285
+ pendingCount(): number;
286
+ /** Forget entries older than the window (already-enqueued ones silently,
287
+ * un-enqueued ones counted). */
288
+ prune(nowMs: number): void;
289
+ }
290
+
291
+ /** One observed change, waiting to be enqueued (or already enqueued). */
292
+ export declare interface HandoffEntry<T> {
293
+ /** Pipe key — `<instance>:<table>:<sink name>`. */
294
+ readonly pipe: string;
295
+ /** The durable change identity this entry will be claimed under. Computed
296
+ * at OBSERVATION time, so re-draining an entry re-uses the same key and
297
+ * collapses instead of duplicating. */
298
+ readonly changeKey: string;
299
+ readonly change: T;
300
+ readonly observedAtMs: number;
301
+ /** Set once the entry has been resolved — enqueued by us, or found already
302
+ * claimed by another replica. */
303
+ enqueued: boolean;
304
+ }
305
+
195
306
  /** Map one ChangeEvent through a config's filter + map, or null when
196
307
  * filtered / unkeyable. */
197
308
  export declare const mapChange: (event: PluginChangeEvent, config: SinkConfig) => MappedChange | null;
@@ -210,10 +321,46 @@ export declare const memorySink: (name?: string) => CdcSink & {
210
321
  readonly delivered: ReadonlyArray<SinkRecord>;
211
322
  };
212
323
 
324
+ /** Generate the next enqueue-claim id. */
325
+ export declare const nextClaimId: () => string;
326
+
213
327
  /** Generate the next outbox id (== deliveryKey). Called SYNCHRONOUSLY in the
214
328
  * change tap, so ids carry commit order (uuidv7 is monotonic per process). */
215
329
  export declare const nextOutboxId: () => string;
216
330
 
331
+ /**
332
+ * How many times this replica has already seen each digest inside the dedup
333
+ * window. Bounded on BOTH axes — a per-change map with no ceiling is an
334
+ * unbounded table in memory:
335
+ * * by time — entries untouched for `windowMs` are pruned, and
336
+ * * by size — `maxEntries` oldest-first, so a burst of unique changes can't
337
+ * grow it without limit.
338
+ *
339
+ * `windowMs` MUST be longer than the lifetime of a claim row in the database.
340
+ * That ordering is the invariant: if a claim outlived the counter that
341
+ * produced it, the next sighting of that digest would restart at occurrence 0,
342
+ * collide with the retained claim, and be dropped as a duplicate it is not.
343
+ */
344
+ export declare class OccurrenceCounter {
345
+ #private;
346
+ constructor(opts: {
347
+ readonly windowMs: number;
348
+ readonly maxEntries: number;
349
+ });
350
+ get size(): number;
351
+ /** Record a sighting and return its occurrence index (0 for the first). */
352
+ next(digest: string, nowMs: number): number;
353
+ /**
354
+ * Align this replica with the fleet from the claims already recorded in the
355
+ * database — the counters of a process that booted mid-window would
356
+ * otherwise restart at 0 and re-key changes the incumbents have already
357
+ * keyed. Call ONCE, before the first observed change is keyed.
358
+ */
359
+ seedFromChangeKeys(changeKeys: Iterable<string>, nowMs: number): void;
360
+ /** Drop entries whose last sighting is older than the window. */
361
+ prune(nowMs: number): void;
362
+ }
363
+
217
364
  export declare interface OutboxRow {
218
365
  readonly id: string;
219
366
  readonly pipe: string;
@@ -237,6 +384,12 @@ export declare const outboxRowFor: (mapped: MappedChange, pipe: string, nowMs: n
237
384
 
238
385
  export declare type OutboxStatus = 'pending' | 'delivering' | 'delivered' | 'dead';
239
386
 
387
+ /** Split a stored `changeKey` back into its two halves (`null` when malformed). */
388
+ export declare const parseChangeKey: (changeKey: string) => {
389
+ digest: string;
390
+ occurrence: number;
391
+ } | null;
392
+
240
393
  /** Pipe identity — `<instance>:<table>:<sink name>` (the outbox `pipe` column). */
241
394
  export declare const pipeKey: (instanceName: string, config: SinkConfig) => string;
242
395