graphddb 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,461 @@
1
+ import {
2
+ ChangeCaptureRegistry,
3
+ resolveModelClass
4
+ } from "./chunk-347U24SB.js";
5
+
6
+ // src/cdc/prng.ts
7
+ var SeededRandom = class {
8
+ state;
9
+ constructor(seed) {
10
+ this.state = seed >>> 0 || 2654435769;
11
+ }
12
+ /** Next float in [0, 1). */
13
+ next() {
14
+ this.state |= 0;
15
+ this.state = this.state + 1831565813 | 0;
16
+ let t = Math.imul(this.state ^ this.state >>> 15, 1 | this.state);
17
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
18
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
19
+ }
20
+ /** Integer in [min, max] inclusive. */
21
+ int(min, max) {
22
+ return min + Math.floor(this.next() * (max - min + 1));
23
+ }
24
+ /** Bernoulli trial: true with probability `p`. */
25
+ chance(p) {
26
+ if (p <= 0) return false;
27
+ if (p >= 1) return true;
28
+ return this.next() < p;
29
+ }
30
+ /** In-place Fisher–Yates shuffle, deterministic for the seed state. */
31
+ shuffle(items) {
32
+ for (let i = items.length - 1; i > 0; i--) {
33
+ const j = this.int(0, i);
34
+ const tmp = items[i];
35
+ items[i] = items[j];
36
+ items[j] = tmp;
37
+ }
38
+ return items;
39
+ }
40
+ };
41
+ function hashString(value) {
42
+ let h = 2166136261;
43
+ for (let i = 0; i < value.length; i++) {
44
+ h ^= value.charCodeAt(i);
45
+ h = Math.imul(h, 16777619);
46
+ }
47
+ return h >>> 0;
48
+ }
49
+ function shardIdFor(pk, shardCount) {
50
+ const index = hashString(pk) % shardCount;
51
+ return `shard-${String(index).padStart(4, "0")}`;
52
+ }
53
+
54
+ // src/cdc/emulator.ts
55
+ function resolveToClass(model) {
56
+ try {
57
+ return resolveModelClass(model);
58
+ } catch {
59
+ return model;
60
+ }
61
+ }
62
+ var EPOCH = Date.parse("2020-01-01T00:00:00.000Z");
63
+ var CdcEmulator = class {
64
+ mode;
65
+ clockMode;
66
+ batchSize;
67
+ maxRetries;
68
+ startingPosition;
69
+ shardCount;
70
+ seed;
71
+ rng;
72
+ faultSpec = {};
73
+ concurrentRecompute = null;
74
+ concurrentRecomputeHook = null;
75
+ /** Per-shard monotonic sequence counter. */
76
+ seqCounters = /* @__PURE__ */ new Map();
77
+ /** Per-shard delivery queue (queued/replay modes). */
78
+ queues = /* @__PURE__ */ new Map();
79
+ /** Per-shard last successfully checkpointed sequence number. */
80
+ checkpointMap = /* @__PURE__ */ new Map();
81
+ /**
82
+ * Per-shard set of acked sequence numbers (as ints) not yet folded into the
83
+ * checkpoint. The checkpoint advances over the contiguous prefix of acked
84
+ * sequences (ReportBatchItemFailures semantics): a sequence is checkpointed
85
+ * only once every sequence at or before it has been acked.
86
+ */
87
+ ackedSeqs = /* @__PURE__ */ new Map();
88
+ /** Per-shard count of sequences ever assigned, to bound the prefix scan. */
89
+ maxSeqSeen = /* @__PURE__ */ new Map();
90
+ /** Recorded event log (record mode and always, for replay()). */
91
+ log = [];
92
+ /** Dead-letter buffer. */
93
+ dlq = [];
94
+ handler = null;
95
+ /** Virtual clock (ms since EPOCH). */
96
+ clockMs = 0;
97
+ /** Pending async work from inline-mode captures, awaited by callers. */
98
+ inflight = Promise.resolve();
99
+ detachCapture = null;
100
+ models;
101
+ constructor(opts = {}) {
102
+ this.mode = opts.mode ?? "inline";
103
+ this.clockMode = opts.clock ?? (this.mode === "queued" ? "virtual" : "real");
104
+ this.batchSize = opts.batchSize ?? 100;
105
+ this.maxRetries = opts.maxRetries ?? 3;
106
+ this.startingPosition = opts.startingPosition ?? "TRIM_HORIZON";
107
+ this.shardCount = opts.shardCount ?? 8;
108
+ this.seed = opts.seed ?? 1;
109
+ this.rng = new SeededRandom(this.seed);
110
+ this.models = (opts.models ?? []).map(resolveToClass);
111
+ for (const model of this.models) {
112
+ ChangeCaptureRegistry.enableStream(model);
113
+ }
114
+ this.detachCapture = ChangeCaptureRegistry.register((record) => {
115
+ this.onRawWrite(record);
116
+ });
117
+ }
118
+ // ── seam → ChangeEvent mapping (spec §4, §5) ─────────────────────────────
119
+ onRawWrite(record) {
120
+ if (record.model && this.models.length > 0) {
121
+ const enabled = this.models.some((m) => m.name === record.model);
122
+ if (!enabled) return;
123
+ }
124
+ const event = this.toChangeEvent(record);
125
+ this.log.push(event);
126
+ if (this.mode === "record" || this.mode === "replay") {
127
+ return;
128
+ }
129
+ this.enqueue(event);
130
+ if (this.mode === "inline") {
131
+ this.inflight = this.inflight.then(() => this.pump());
132
+ }
133
+ }
134
+ toChangeEvent(record) {
135
+ const shardId = shardIdFor(record.keys.pk, this.shardCount);
136
+ const seq = (this.seqCounters.get(shardId) ?? 0) + 1;
137
+ this.seqCounters.set(shardId, seq);
138
+ this.maxSeqSeen.set(shardId, seq);
139
+ const eventName = this.deriveEventName(record);
140
+ const newImage = eventName === "REMOVE" ? void 0 : record.newItem ?? void 0;
141
+ const oldImage = record.oldItem ?? void 0;
142
+ return {
143
+ eventName,
144
+ table: record.table,
145
+ ...record.model ? { model: record.model } : {},
146
+ keys: { pk: record.keys.pk, sk: record.keys.sk },
147
+ ...oldImage ? { oldImage } : {},
148
+ ...newImage ? { newImage } : {},
149
+ approximateCreationTime: this.nowIso(),
150
+ sequenceNumber: String(seq).padStart(20, "0"),
151
+ shardId
152
+ };
153
+ }
154
+ /** Parse a zero-padded sequence string back to its integer value. */
155
+ seqToInt(seq) {
156
+ return Number.parseInt(seq, 10);
157
+ }
158
+ /** Render an integer sequence as the zero-padded string form. */
159
+ intToSeq(n) {
160
+ return String(n).padStart(20, "0");
161
+ }
162
+ /**
163
+ * Advance a shard's checkpoint over the contiguous prefix of acked sequence
164
+ * numbers. Starting just past the current checkpoint, consume every
165
+ * consecutive acked sequence; stop at the first gap (an un-acked sequence
166
+ * still pending redelivery). Consumed sequences are removed from the acked
167
+ * set to bound its size.
168
+ */
169
+ advanceCheckpoint(shardId) {
170
+ const acked = this.ackedSeqs.get(shardId);
171
+ if (!acked || acked.size === 0) return;
172
+ const current = this.checkpointMap.get(shardId);
173
+ let next = current === void 0 ? 0 : this.seqToInt(current);
174
+ const maxSeq = this.maxSeqSeen.get(shardId) ?? 0;
175
+ let advanced = false;
176
+ while (next + 1 <= maxSeq && acked.has(next + 1)) {
177
+ next += 1;
178
+ acked.delete(next);
179
+ advanced = true;
180
+ }
181
+ if (advanced) this.checkpointMap.set(shardId, this.intToSeq(next));
182
+ }
183
+ deriveEventName(record) {
184
+ if (record.op === "delete") return "REMOVE";
185
+ return record.oldItem ? "MODIFY" : "INSERT";
186
+ }
187
+ nowIso() {
188
+ if (this.clockMode === "virtual") {
189
+ return new Date(EPOCH + this.clockMs).toISOString();
190
+ }
191
+ return (/* @__PURE__ */ new Date()).toISOString();
192
+ }
193
+ // ── enqueue + delivery (spec §6) ─────────────────────────────────────────
194
+ enqueue(event) {
195
+ const queue = this.queues.get(event.shardId) ?? [];
196
+ let dueAt = this.clockMode === "virtual" ? this.clockMs : 0;
197
+ if (this.faultSpec.delay) {
198
+ const { min, max } = this.faultSpec.delay;
199
+ dueAt += this.rng.int(min, max);
200
+ }
201
+ queue.push({ event, dueAt, attempts: 0, droppedOnce: false });
202
+ this.queues.set(event.shardId, queue);
203
+ }
204
+ /**
205
+ * Deliver buffered, due records to the consumer, advancing per-shard
206
+ * checkpoints on success and redelivering failures. In `queued`/`replay`
207
+ * modes call this (or {@link advanceClock}) to drive delivery; `inline` calls
208
+ * it automatically after each capture.
209
+ */
210
+ async pump(maxBatches = Number.POSITIVE_INFINITY) {
211
+ if (!this.handler) return;
212
+ let delivered = 0;
213
+ let madeProgress = true;
214
+ while (madeProgress && delivered < maxBatches) {
215
+ madeProgress = false;
216
+ for (const shardId of [...this.queues.keys()]) {
217
+ const batch = this.collectBatch(shardId);
218
+ if (batch.length === 0) continue;
219
+ await this.deliverBatch(shardId, batch);
220
+ delivered += 1;
221
+ madeProgress = true;
222
+ if (delivered >= maxBatches) break;
223
+ }
224
+ }
225
+ }
226
+ /**
227
+ * Pull up to `batchSize` due records from a shard, preserving ascending
228
+ * sequence order (spec §6). When the `reorder` fault is on, the within-shard
229
+ * order is deliberately shuffled to exercise invariant detection.
230
+ */
231
+ collectBatch(shardId) {
232
+ const queue = this.queues.get(shardId);
233
+ if (!queue || queue.length === 0) return [];
234
+ const ready = [];
235
+ const now = this.clockMode === "virtual" ? this.clockMs : Number.POSITIVE_INFINITY;
236
+ const remaining = [];
237
+ for (const pd of queue) {
238
+ if (ready.length < this.batchSize && pd.dueAt <= now) {
239
+ ready.push(pd);
240
+ } else {
241
+ remaining.push(pd);
242
+ }
243
+ }
244
+ this.queues.set(shardId, remaining);
245
+ if (this.faultSpec.reorder && ready.length > 1) {
246
+ this.rng.shuffle(ready);
247
+ }
248
+ return ready;
249
+ }
250
+ async deliverBatch(shardId, pending) {
251
+ const delivered = [];
252
+ const droppedThisRound = [];
253
+ for (const pd of pending) {
254
+ if (!pd.droppedOnce && this.faultSpec.dropThenRedeliver && this.rng.chance(this.faultSpec.dropThenRedeliver)) {
255
+ pd.droppedOnce = true;
256
+ droppedThisRound.push(pd);
257
+ continue;
258
+ }
259
+ delivered.push(pd);
260
+ if (this.faultSpec.duplicate && this.rng.chance(this.faultSpec.duplicate)) {
261
+ delivered.push(pd);
262
+ }
263
+ }
264
+ if (droppedThisRound.length > 0) {
265
+ const queue = this.queues.get(shardId) ?? [];
266
+ queue.unshift(...droppedThisRound);
267
+ this.queues.set(shardId, queue);
268
+ }
269
+ if (delivered.length === 0) return;
270
+ const records = delivered.map((pd) => pd.event);
271
+ if (this.concurrentRecompute && this.concurrentRecomputeHook) {
272
+ for (const event of records) {
273
+ await this.concurrentRecomputeHook(this.concurrentRecompute, event);
274
+ }
275
+ }
276
+ const batch = { records };
277
+ const result = await this.handler(batch) ?? {};
278
+ const failures = new Set(result.batchItemFailures ?? []);
279
+ if (this.faultSpec.partialBatchFailure) {
280
+ for (const pd of delivered) {
281
+ if (this.rng.chance(this.faultSpec.partialBatchFailure)) {
282
+ failures.add(pd.event.sequenceNumber);
283
+ }
284
+ }
285
+ }
286
+ const requeue = [];
287
+ const seen = /* @__PURE__ */ new Set();
288
+ const acked = this.ackedSeqs.get(shardId) ?? /* @__PURE__ */ new Set();
289
+ for (const pd of delivered) {
290
+ const seq = pd.event.sequenceNumber;
291
+ if (failures.has(seq)) {
292
+ pd.attempts += 1;
293
+ if (pd.attempts > this.maxRetries) {
294
+ this.dlq.push(pd.event);
295
+ } else if (!seen.has(seq)) {
296
+ requeue.push(pd);
297
+ seen.add(seq);
298
+ }
299
+ } else {
300
+ acked.add(this.seqToInt(seq));
301
+ }
302
+ }
303
+ this.ackedSeqs.set(shardId, acked);
304
+ this.advanceCheckpoint(shardId);
305
+ if (requeue.length > 0) {
306
+ const queue = this.queues.get(shardId) ?? [];
307
+ requeue.sort(
308
+ (a, b) => a.event.sequenceNumber < b.event.sequenceNumber ? -1 : 1
309
+ );
310
+ queue.unshift(...requeue);
311
+ this.queues.set(shardId, queue);
312
+ }
313
+ }
314
+ // ── public API (spec §10) ────────────────────────────────────────────────
315
+ /** Register the consumer. Returns an unsubscribe handle. */
316
+ subscribe(handler) {
317
+ this.handler = handler;
318
+ if (this.startingPosition === "LATEST") {
319
+ this.queues.clear();
320
+ }
321
+ return () => {
322
+ if (this.handler === handler) this.handler = null;
323
+ };
324
+ }
325
+ /** Await any inline-mode async deliveries triggered by writes. */
326
+ async flush() {
327
+ await this.inflight;
328
+ }
329
+ /**
330
+ * Advance the virtual clock by `ms` and deliver records whose due-time has
331
+ * arrived (spec §7). Reproduces throttle T / sweep period / staleness S time
332
+ * behavior deterministically (AC4).
333
+ */
334
+ async advanceClock(ms) {
335
+ if (this.clockMode !== "virtual") {
336
+ throw new Error('advanceClock requires clock: "virtual"');
337
+ }
338
+ this.clockMs += ms;
339
+ await this.pump();
340
+ }
341
+ /** Current virtual time (ms since the emulator epoch). */
342
+ now() {
343
+ return this.clockMs;
344
+ }
345
+ /** Configure fault injection (spec §8). Merges into the current spec. */
346
+ fault(spec) {
347
+ this.faultSpec = { ...this.faultSpec, ...spec };
348
+ }
349
+ /**
350
+ * Force a recompute to race a mark for the given ref (spec §8, §7). The
351
+ * harness supplies the actual recompute via {@link onConcurrentRecompute};
352
+ * this records which ref to race and arms the hook.
353
+ */
354
+ injectConcurrentRecompute(ref) {
355
+ this.concurrentRecompute = ref;
356
+ }
357
+ /**
358
+ * Register the callback the emulator invokes (before each record reaches the
359
+ * consumer) when a concurrent recompute has been injected. This is how the
360
+ * test harness wires its recompute into the delivery race.
361
+ */
362
+ onConcurrentRecompute(hook) {
363
+ this.concurrentRecomputeHook = hook;
364
+ }
365
+ /** Snapshot the recorded event log (spec §10). */
366
+ record() {
367
+ return { seed: this.seed, events: this.log.map((e) => ({ ...e })) };
368
+ }
369
+ /**
370
+ * Replay an event log through the delivery pipeline, optionally shuffled and
371
+ * duplicated (spec §10, AC5). Used to prove **replay equivalence**: starting
372
+ * from any global order plus duplicates, a consumer that respects the
373
+ * per-shard ordering contract (spec §6) reaches a final aggregate equal to a
374
+ * full recompute from source.
375
+ *
376
+ * The shuffle models cross-shard reordering and at-least-once chaos at the
377
+ * delivery boundary. It must NOT, however, break the within-shard ordering
378
+ * guarantee that real DynamoDB Streams provides and that incremental
379
+ * aggregation depends on — so after shuffling/duplicating, each shard's queue
380
+ * is re-sorted into ascending original `sequenceNumber` order before delivery.
381
+ * The original `sequenceNumber` (assigned at record time) is preserved so it
382
+ * remains the source-of-truth ordering the consumer can rely on.
383
+ */
384
+ async replay(log, opts = {}) {
385
+ const replayRng = new SeededRandom(log.seed ?? this.seed);
386
+ let events = log.events.map((e) => ({ ...e }));
387
+ if (opts.shuffle) {
388
+ events = replayRng.shuffle(events);
389
+ }
390
+ const expanded = [];
391
+ for (const e of events) {
392
+ expanded.push(e);
393
+ if (opts.duplicate && replayRng.chance(opts.duplicate)) {
394
+ expanded.push({ ...e });
395
+ }
396
+ }
397
+ this.queues.clear();
398
+ this.seqCounters.clear();
399
+ this.ackedSeqs.clear();
400
+ this.maxSeqSeen.clear();
401
+ this.checkpointMap.clear();
402
+ for (const e of expanded) {
403
+ const shardId = shardIdFor(e.keys.pk, this.shardCount);
404
+ const resequenced = { ...e, shardId };
405
+ const queue = this.queues.get(shardId) ?? [];
406
+ queue.push({ event: resequenced, dueAt: 0, attempts: 0, droppedOnce: false });
407
+ this.queues.set(shardId, queue);
408
+ const seqInt = this.seqToInt(resequenced.sequenceNumber);
409
+ if (seqInt > (this.maxSeqSeen.get(shardId) ?? 0)) {
410
+ this.maxSeqSeen.set(shardId, seqInt);
411
+ }
412
+ }
413
+ for (const [shardId, queue] of this.queues) {
414
+ queue.sort(
415
+ (a, b) => a.event.sequenceNumber < b.event.sequenceNumber ? -1 : a.event.sequenceNumber > b.event.sequenceNumber ? 1 : 0
416
+ );
417
+ this.queues.set(shardId, queue);
418
+ }
419
+ await this.pump();
420
+ }
421
+ /** Per-shard checkpoints (last acked sequence number). */
422
+ checkpoints() {
423
+ return Object.fromEntries(this.checkpointMap);
424
+ }
425
+ /** Dead-lettered events (exceeded maxRetries). */
426
+ deadLetters() {
427
+ return this.dlq.map((e) => ({ ...e }));
428
+ }
429
+ /** Reset delivery state and re-seed (keeps the subscription + model opt-in). */
430
+ reset() {
431
+ this.seqCounters.clear();
432
+ this.queues.clear();
433
+ this.checkpointMap.clear();
434
+ this.ackedSeqs.clear();
435
+ this.maxSeqSeen.clear();
436
+ this.log = [];
437
+ this.dlq = [];
438
+ this.clockMs = 0;
439
+ this.inflight = Promise.resolve();
440
+ this.faultSpec = {};
441
+ this.concurrentRecompute = null;
442
+ this.rng = new SeededRandom(this.seed);
443
+ }
444
+ /** Tear down: detach from the core seam and disable model streaming. */
445
+ close() {
446
+ this.detachCapture?.();
447
+ this.detachCapture = null;
448
+ for (const model of this.models) {
449
+ ChangeCaptureRegistry.disableStream(model);
450
+ }
451
+ this.handler = null;
452
+ }
453
+ };
454
+ function createCdcEmulator(opts = {}) {
455
+ return new CdcEmulator(opts);
456
+ }
457
+
458
+ export {
459
+ CdcEmulator,
460
+ createCdcEmulator
461
+ };
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node