graphddb 0.3.0 → 0.3.1

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,916 @@
1
+ import {
2
+ ChangeCaptureRegistry,
3
+ ClientManager,
4
+ MetadataRegistry,
5
+ buildConditionExpression,
6
+ buildDeleteInput,
7
+ buildMaintenanceGraph,
8
+ buildPutInput,
9
+ isProjectionDefinition,
10
+ resolveModelClass
11
+ } from "./chunk-YIXXTGZ6.js";
12
+
13
+ // src/cdc/prng.ts
14
+ var SeededRandom = class {
15
+ state;
16
+ constructor(seed) {
17
+ this.state = seed >>> 0 || 2654435769;
18
+ }
19
+ /** Next float in [0, 1). */
20
+ next() {
21
+ this.state |= 0;
22
+ this.state = this.state + 1831565813 | 0;
23
+ let t = Math.imul(this.state ^ this.state >>> 15, 1 | this.state);
24
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
25
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
26
+ }
27
+ /** Integer in [min, max] inclusive. */
28
+ int(min, max) {
29
+ return min + Math.floor(this.next() * (max - min + 1));
30
+ }
31
+ /** Bernoulli trial: true with probability `p`. */
32
+ chance(p) {
33
+ if (p <= 0) return false;
34
+ if (p >= 1) return true;
35
+ return this.next() < p;
36
+ }
37
+ /** In-place Fisher–Yates shuffle, deterministic for the seed state. */
38
+ shuffle(items) {
39
+ for (let i = items.length - 1; i > 0; i--) {
40
+ const j = this.int(0, i);
41
+ const tmp = items[i];
42
+ items[i] = items[j];
43
+ items[j] = tmp;
44
+ }
45
+ return items;
46
+ }
47
+ };
48
+ function hashString(value) {
49
+ let h = 2166136261;
50
+ for (let i = 0; i < value.length; i++) {
51
+ h ^= value.charCodeAt(i);
52
+ h = Math.imul(h, 16777619);
53
+ }
54
+ return h >>> 0;
55
+ }
56
+ function shardIdFor(pk, shardCount) {
57
+ const index = hashString(pk) % shardCount;
58
+ return `shard-${String(index).padStart(4, "0")}`;
59
+ }
60
+
61
+ // src/cdc/emulator.ts
62
+ function resolveToClass(model) {
63
+ try {
64
+ return resolveModelClass(model);
65
+ } catch {
66
+ return model;
67
+ }
68
+ }
69
+ var EPOCH = Date.parse("2020-01-01T00:00:00.000Z");
70
+ var CdcEmulator = class {
71
+ mode;
72
+ clockMode;
73
+ batchSize;
74
+ maxRetries;
75
+ startingPosition;
76
+ shardCount;
77
+ seed;
78
+ rng;
79
+ faultSpec = {};
80
+ concurrentRecompute = null;
81
+ concurrentRecomputeHook = null;
82
+ /** Per-shard monotonic sequence counter. */
83
+ seqCounters = /* @__PURE__ */ new Map();
84
+ /** Per-shard delivery queue (queued/replay modes). */
85
+ queues = /* @__PURE__ */ new Map();
86
+ /** Per-shard last successfully checkpointed sequence number. */
87
+ checkpointMap = /* @__PURE__ */ new Map();
88
+ /**
89
+ * Per-shard set of acked sequence numbers (as ints) not yet folded into the
90
+ * checkpoint. The checkpoint advances over the contiguous prefix of acked
91
+ * sequences (ReportBatchItemFailures semantics): a sequence is checkpointed
92
+ * only once every sequence at or before it has been acked.
93
+ */
94
+ ackedSeqs = /* @__PURE__ */ new Map();
95
+ /** Per-shard count of sequences ever assigned, to bound the prefix scan. */
96
+ maxSeqSeen = /* @__PURE__ */ new Map();
97
+ /** Recorded event log (record mode and always, for replay()). */
98
+ log = [];
99
+ /** Dead-letter buffer. */
100
+ dlq = [];
101
+ handler = null;
102
+ /** Virtual clock (ms since EPOCH). */
103
+ clockMs = 0;
104
+ /** Pending async work from inline-mode captures, awaited by callers. */
105
+ inflight = Promise.resolve();
106
+ detachCapture = null;
107
+ models;
108
+ constructor(opts = {}) {
109
+ this.mode = opts.mode ?? "inline";
110
+ this.clockMode = opts.clock ?? (this.mode === "queued" ? "virtual" : "real");
111
+ this.batchSize = opts.batchSize ?? 100;
112
+ this.maxRetries = opts.maxRetries ?? 3;
113
+ this.startingPosition = opts.startingPosition ?? "TRIM_HORIZON";
114
+ this.shardCount = opts.shardCount ?? 8;
115
+ this.seed = opts.seed ?? 1;
116
+ this.rng = new SeededRandom(this.seed);
117
+ this.models = (opts.models ?? []).map(resolveToClass);
118
+ for (const model of this.models) {
119
+ ChangeCaptureRegistry.enableStream(model);
120
+ }
121
+ this.detachCapture = ChangeCaptureRegistry.register((record) => {
122
+ this.onRawWrite(record);
123
+ });
124
+ }
125
+ // ── seam → ChangeEvent mapping (spec §4, §5) ─────────────────────────────
126
+ onRawWrite(record) {
127
+ if (record.model && this.models.length > 0) {
128
+ const enabled = this.models.some((m) => m.name === record.model);
129
+ if (!enabled) return;
130
+ }
131
+ const event = this.toChangeEvent(record);
132
+ this.log.push(event);
133
+ if (this.mode === "record" || this.mode === "replay") {
134
+ return;
135
+ }
136
+ this.enqueue(event);
137
+ if (this.mode === "inline") {
138
+ this.inflight = this.inflight.then(() => this.pump());
139
+ }
140
+ }
141
+ toChangeEvent(record) {
142
+ const shardId = shardIdFor(record.keys.pk, this.shardCount);
143
+ const seq = (this.seqCounters.get(shardId) ?? 0) + 1;
144
+ this.seqCounters.set(shardId, seq);
145
+ this.maxSeqSeen.set(shardId, seq);
146
+ const eventName = this.deriveEventName(record);
147
+ const newImage = eventName === "REMOVE" ? void 0 : record.newItem ?? void 0;
148
+ const oldImage = record.oldItem ?? void 0;
149
+ return {
150
+ eventName,
151
+ table: record.table,
152
+ ...record.model ? { model: record.model } : {},
153
+ keys: { pk: record.keys.pk, sk: record.keys.sk },
154
+ ...oldImage ? { oldImage } : {},
155
+ ...newImage ? { newImage } : {},
156
+ approximateCreationTime: this.nowIso(),
157
+ sequenceNumber: String(seq).padStart(20, "0"),
158
+ shardId
159
+ };
160
+ }
161
+ /** Parse a zero-padded sequence string back to its integer value. */
162
+ seqToInt(seq) {
163
+ return Number.parseInt(seq, 10);
164
+ }
165
+ /** Render an integer sequence as the zero-padded string form. */
166
+ intToSeq(n) {
167
+ return String(n).padStart(20, "0");
168
+ }
169
+ /**
170
+ * Advance a shard's checkpoint over the contiguous prefix of acked sequence
171
+ * numbers. Starting just past the current checkpoint, consume every
172
+ * consecutive acked sequence; stop at the first gap (an un-acked sequence
173
+ * still pending redelivery). Consumed sequences are removed from the acked
174
+ * set to bound its size.
175
+ */
176
+ advanceCheckpoint(shardId) {
177
+ const acked = this.ackedSeqs.get(shardId);
178
+ if (!acked || acked.size === 0) return;
179
+ const current = this.checkpointMap.get(shardId);
180
+ let next = current === void 0 ? 0 : this.seqToInt(current);
181
+ const maxSeq = this.maxSeqSeen.get(shardId) ?? 0;
182
+ let advanced = false;
183
+ while (next + 1 <= maxSeq && acked.has(next + 1)) {
184
+ next += 1;
185
+ acked.delete(next);
186
+ advanced = true;
187
+ }
188
+ if (advanced) this.checkpointMap.set(shardId, this.intToSeq(next));
189
+ }
190
+ deriveEventName(record) {
191
+ if (record.op === "delete") return "REMOVE";
192
+ return record.oldItem ? "MODIFY" : "INSERT";
193
+ }
194
+ nowIso() {
195
+ if (this.clockMode === "virtual") {
196
+ return new Date(EPOCH + this.clockMs).toISOString();
197
+ }
198
+ return (/* @__PURE__ */ new Date()).toISOString();
199
+ }
200
+ // ── enqueue + delivery (spec §6) ─────────────────────────────────────────
201
+ enqueue(event) {
202
+ const queue = this.queues.get(event.shardId) ?? [];
203
+ let dueAt = this.clockMode === "virtual" ? this.clockMs : 0;
204
+ if (this.faultSpec.delay) {
205
+ const { min, max } = this.faultSpec.delay;
206
+ dueAt += this.rng.int(min, max);
207
+ }
208
+ queue.push({ event, dueAt, attempts: 0, droppedOnce: false });
209
+ this.queues.set(event.shardId, queue);
210
+ }
211
+ /**
212
+ * Deliver buffered, due records to the consumer, advancing per-shard
213
+ * checkpoints on success and redelivering failures. In `queued`/`replay`
214
+ * modes call this (or {@link advanceClock}) to drive delivery; `inline` calls
215
+ * it automatically after each capture.
216
+ */
217
+ async pump(maxBatches = Number.POSITIVE_INFINITY) {
218
+ if (!this.handler) return;
219
+ let delivered = 0;
220
+ let madeProgress = true;
221
+ while (madeProgress && delivered < maxBatches) {
222
+ madeProgress = false;
223
+ for (const shardId of [...this.queues.keys()]) {
224
+ const batch = this.collectBatch(shardId);
225
+ if (batch.length === 0) continue;
226
+ await this.deliverBatch(shardId, batch);
227
+ delivered += 1;
228
+ madeProgress = true;
229
+ if (delivered >= maxBatches) break;
230
+ }
231
+ }
232
+ }
233
+ /**
234
+ * Pull up to `batchSize` due records from a shard, preserving ascending
235
+ * sequence order (spec §6). When the `reorder` fault is on, the within-shard
236
+ * order is deliberately shuffled to exercise invariant detection.
237
+ */
238
+ collectBatch(shardId) {
239
+ const queue = this.queues.get(shardId);
240
+ if (!queue || queue.length === 0) return [];
241
+ const ready = [];
242
+ const now = this.clockMode === "virtual" ? this.clockMs : Number.POSITIVE_INFINITY;
243
+ const remaining = [];
244
+ for (const pd of queue) {
245
+ if (ready.length < this.batchSize && pd.dueAt <= now) {
246
+ ready.push(pd);
247
+ } else {
248
+ remaining.push(pd);
249
+ }
250
+ }
251
+ this.queues.set(shardId, remaining);
252
+ if (this.faultSpec.reorder && ready.length > 1) {
253
+ this.rng.shuffle(ready);
254
+ }
255
+ return ready;
256
+ }
257
+ async deliverBatch(shardId, pending) {
258
+ const delivered = [];
259
+ const droppedThisRound = [];
260
+ for (const pd of pending) {
261
+ if (!pd.droppedOnce && this.faultSpec.dropThenRedeliver && this.rng.chance(this.faultSpec.dropThenRedeliver)) {
262
+ pd.droppedOnce = true;
263
+ droppedThisRound.push(pd);
264
+ continue;
265
+ }
266
+ delivered.push(pd);
267
+ if (this.faultSpec.duplicate && this.rng.chance(this.faultSpec.duplicate)) {
268
+ delivered.push(pd);
269
+ }
270
+ }
271
+ if (droppedThisRound.length > 0) {
272
+ const queue = this.queues.get(shardId) ?? [];
273
+ queue.unshift(...droppedThisRound);
274
+ this.queues.set(shardId, queue);
275
+ }
276
+ if (delivered.length === 0) return;
277
+ const records = delivered.map((pd) => pd.event);
278
+ if (this.concurrentRecompute && this.concurrentRecomputeHook) {
279
+ for (const event of records) {
280
+ await this.concurrentRecomputeHook(this.concurrentRecompute, event);
281
+ }
282
+ }
283
+ const batch = { records };
284
+ const result = await this.handler(batch) ?? {};
285
+ const failures = new Set(result.batchItemFailures ?? []);
286
+ if (this.faultSpec.partialBatchFailure) {
287
+ for (const pd of delivered) {
288
+ if (this.rng.chance(this.faultSpec.partialBatchFailure)) {
289
+ failures.add(pd.event.sequenceNumber);
290
+ }
291
+ }
292
+ }
293
+ const requeue = [];
294
+ const seen = /* @__PURE__ */ new Set();
295
+ const acked = this.ackedSeqs.get(shardId) ?? /* @__PURE__ */ new Set();
296
+ for (const pd of delivered) {
297
+ const seq = pd.event.sequenceNumber;
298
+ if (failures.has(seq)) {
299
+ pd.attempts += 1;
300
+ if (pd.attempts > this.maxRetries) {
301
+ this.dlq.push(pd.event);
302
+ } else if (!seen.has(seq)) {
303
+ requeue.push(pd);
304
+ seen.add(seq);
305
+ }
306
+ } else {
307
+ acked.add(this.seqToInt(seq));
308
+ }
309
+ }
310
+ this.ackedSeqs.set(shardId, acked);
311
+ this.advanceCheckpoint(shardId);
312
+ if (requeue.length > 0) {
313
+ const queue = this.queues.get(shardId) ?? [];
314
+ requeue.sort(
315
+ (a, b) => a.event.sequenceNumber < b.event.sequenceNumber ? -1 : 1
316
+ );
317
+ queue.unshift(...requeue);
318
+ this.queues.set(shardId, queue);
319
+ }
320
+ }
321
+ // ── public API (spec §10) ────────────────────────────────────────────────
322
+ /** Register the consumer. Returns an unsubscribe handle. */
323
+ subscribe(handler) {
324
+ this.handler = handler;
325
+ if (this.startingPosition === "LATEST") {
326
+ this.queues.clear();
327
+ }
328
+ return () => {
329
+ if (this.handler === handler) this.handler = null;
330
+ };
331
+ }
332
+ /** Await any inline-mode async deliveries triggered by writes. */
333
+ async flush() {
334
+ await this.inflight;
335
+ }
336
+ /**
337
+ * Advance the virtual clock by `ms` and deliver records whose due-time has
338
+ * arrived (spec §7). Reproduces throttle T / sweep period / staleness S time
339
+ * behavior deterministically (AC4).
340
+ */
341
+ async advanceClock(ms) {
342
+ if (this.clockMode !== "virtual") {
343
+ throw new Error('advanceClock requires clock: "virtual"');
344
+ }
345
+ this.clockMs += ms;
346
+ await this.pump();
347
+ }
348
+ /** Current virtual time (ms since the emulator epoch). */
349
+ now() {
350
+ return this.clockMs;
351
+ }
352
+ /** Configure fault injection (spec §8). Merges into the current spec. */
353
+ fault(spec) {
354
+ this.faultSpec = { ...this.faultSpec, ...spec };
355
+ }
356
+ /**
357
+ * Force a recompute to race a mark for the given ref (spec §8, §7). The
358
+ * harness supplies the actual recompute via {@link onConcurrentRecompute};
359
+ * this records which ref to race and arms the hook.
360
+ */
361
+ injectConcurrentRecompute(ref) {
362
+ this.concurrentRecompute = ref;
363
+ }
364
+ /**
365
+ * Register the callback the emulator invokes (before each record reaches the
366
+ * consumer) when a concurrent recompute has been injected. This is how the
367
+ * test harness wires its recompute into the delivery race.
368
+ */
369
+ onConcurrentRecompute(hook) {
370
+ this.concurrentRecomputeHook = hook;
371
+ }
372
+ /** Snapshot the recorded event log (spec §10). */
373
+ record() {
374
+ return { seed: this.seed, events: this.log.map((e) => ({ ...e })) };
375
+ }
376
+ /**
377
+ * Replay an event log through the delivery pipeline, optionally shuffled and
378
+ * duplicated (spec §10, AC5). Used to prove **replay equivalence**: starting
379
+ * from any global order plus duplicates, a consumer that respects the
380
+ * per-shard ordering contract (spec §6) reaches a final aggregate equal to a
381
+ * full recompute from source.
382
+ *
383
+ * The shuffle models cross-shard reordering and at-least-once chaos at the
384
+ * delivery boundary. It must NOT, however, break the within-shard ordering
385
+ * guarantee that real DynamoDB Streams provides and that incremental
386
+ * aggregation depends on — so after shuffling/duplicating, each shard's queue
387
+ * is re-sorted into ascending original `sequenceNumber` order before delivery.
388
+ * The original `sequenceNumber` (assigned at record time) is preserved so it
389
+ * remains the source-of-truth ordering the consumer can rely on.
390
+ */
391
+ async replay(log, opts = {}) {
392
+ const replayRng = new SeededRandom(log.seed ?? this.seed);
393
+ let events = log.events.map((e) => ({ ...e }));
394
+ if (opts.shuffle) {
395
+ events = replayRng.shuffle(events);
396
+ }
397
+ const expanded = [];
398
+ for (const e of events) {
399
+ expanded.push(e);
400
+ if (opts.duplicate && replayRng.chance(opts.duplicate)) {
401
+ expanded.push({ ...e });
402
+ }
403
+ }
404
+ this.queues.clear();
405
+ this.seqCounters.clear();
406
+ this.ackedSeqs.clear();
407
+ this.maxSeqSeen.clear();
408
+ this.checkpointMap.clear();
409
+ for (const e of expanded) {
410
+ const shardId = shardIdFor(e.keys.pk, this.shardCount);
411
+ const resequenced = { ...e, shardId };
412
+ const queue = this.queues.get(shardId) ?? [];
413
+ queue.push({ event: resequenced, dueAt: 0, attempts: 0, droppedOnce: false });
414
+ this.queues.set(shardId, queue);
415
+ const seqInt = this.seqToInt(resequenced.sequenceNumber);
416
+ if (seqInt > (this.maxSeqSeen.get(shardId) ?? 0)) {
417
+ this.maxSeqSeen.set(shardId, seqInt);
418
+ }
419
+ }
420
+ for (const [shardId, queue] of this.queues) {
421
+ queue.sort(
422
+ (a, b) => a.event.sequenceNumber < b.event.sequenceNumber ? -1 : a.event.sequenceNumber > b.event.sequenceNumber ? 1 : 0
423
+ );
424
+ this.queues.set(shardId, queue);
425
+ }
426
+ await this.pump();
427
+ }
428
+ /** Per-shard checkpoints (last acked sequence number). */
429
+ checkpoints() {
430
+ return Object.fromEntries(this.checkpointMap);
431
+ }
432
+ /** Dead-lettered events (exceeded maxRetries). */
433
+ deadLetters() {
434
+ return this.dlq.map((e) => ({ ...e }));
435
+ }
436
+ /** Reset delivery state and re-seed (keeps the subscription + model opt-in). */
437
+ reset() {
438
+ this.seqCounters.clear();
439
+ this.queues.clear();
440
+ this.checkpointMap.clear();
441
+ this.ackedSeqs.clear();
442
+ this.maxSeqSeen.clear();
443
+ this.log = [];
444
+ this.dlq = [];
445
+ this.clockMs = 0;
446
+ this.inflight = Promise.resolve();
447
+ this.faultSpec = {};
448
+ this.concurrentRecompute = null;
449
+ this.rng = new SeededRandom(this.seed);
450
+ }
451
+ /** Tear down: detach from the core seam and disable model streaming. */
452
+ close() {
453
+ this.detachCapture?.();
454
+ this.detachCapture = null;
455
+ for (const model of this.models) {
456
+ ChangeCaptureRegistry.disableStream(model);
457
+ }
458
+ this.handler = null;
459
+ }
460
+ };
461
+ function createCdcEmulator(opts = {}) {
462
+ return new CdcEmulator(opts);
463
+ }
464
+
465
+ // src/relation/maintenance-projection.ts
466
+ var PATH_FIELD_RE = /^\$\.(?:entity|input)\.([A-Za-z_$][\w$]*)$/;
467
+ function pathField(path) {
468
+ const m = PATH_FIELD_RE.exec(path);
469
+ if (m !== null) return m[1];
470
+ return path;
471
+ }
472
+ function applyTransform(op, args, value) {
473
+ if (op === "identity") return value;
474
+ if (op === "preview") {
475
+ const n = args[0];
476
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
477
+ throw new Error(
478
+ `maintenance projection: a 'preview' projection has a non-positive-integer length bound (${JSON.stringify(n)}).`
479
+ );
480
+ }
481
+ if (value === null || value === void 0) return value;
482
+ return String(value).slice(0, n);
483
+ }
484
+ throw new Error(`maintenance projection: unknown projection transform op '${op}'.`);
485
+ }
486
+ function projectFrom(project, source) {
487
+ const out = {};
488
+ for (const [attr, transform] of Object.entries(project)) {
489
+ const field = pathField(transform.path);
490
+ out[attr] = applyTransform(transform.op, transform.args, source[field]);
491
+ }
492
+ return out;
493
+ }
494
+ function compareDesc(a, b) {
495
+ if (a === b) return 0;
496
+ if (a === void 0 || a === null) return 1;
497
+ if (b === void 0 || b === null) return -1;
498
+ return a > b ? -1 : 1;
499
+ }
500
+ function orderAndTrimCollection(items, collection) {
501
+ let next = [...items];
502
+ const orderBy = collection.orderBy;
503
+ if (orderBy !== void 0) {
504
+ const orderField = pathField(orderBy);
505
+ const dir = collection.orderDir ?? "DESC";
506
+ next.sort(
507
+ (a, b) => dir === "ASC" ? compareDesc(b[orderField], a[orderField]) : compareDesc(a[orderField], b[orderField])
508
+ );
509
+ if (collection.maxItems !== void 0 && next.length > collection.maxItems) {
510
+ next = next.slice(0, collection.maxItems);
511
+ }
512
+ } else if (collection.maxItems !== void 0 && next.length > collection.maxItems) {
513
+ next = next.slice(next.length - collection.maxItems);
514
+ }
515
+ return next;
516
+ }
517
+ function collectionIdentityAttr(effect) {
518
+ return Object.keys(effect.project)[0];
519
+ }
520
+
521
+ // src/cdc/maintenance-drain.ts
522
+ var MAINT_OUTBOX_PK_PREFIX = "OUTBOX#MAINT#";
523
+ var OWNER_ATTR = "__maintOwner__";
524
+ var RELATION_ATTR = "__maintRelation__";
525
+ var TRIGGER_ATTR = "__maintTrigger__";
526
+ function ownerKey(ownerClass, effect, source) {
527
+ const keyInput = {};
528
+ for (const [ownerField, srcPath] of Object.entries(effect.keys)) {
529
+ keyInput[ownerField] = source[pathField(srcPath)];
530
+ }
531
+ const { TableName, Key } = buildDeleteInput(
532
+ ownerClass,
533
+ keyInput
534
+ );
535
+ return { TableName, Key };
536
+ }
537
+ var MaintenanceDrain = class {
538
+ graph;
539
+ /** Owner entity name → its model class (for the owner-row key derivation). */
540
+ ownerByName = /* @__PURE__ */ new Map();
541
+ /** Per-event-id de-dup within a single handler invocation (at-least-once). */
542
+ appliedSeq = /* @__PURE__ */ new Set();
543
+ /** Count of owner-row writes applied (test/diagnostic). */
544
+ applied = 0;
545
+ constructor(opts = {}) {
546
+ const classes = (opts.models ?? []).map((m) => {
547
+ try {
548
+ return resolveModelClass(m);
549
+ } catch {
550
+ return m;
551
+ }
552
+ });
553
+ const views = opts.views ?? [];
554
+ const allClasses = [...classes, ...views.map((v) => v.viewClass)];
555
+ const scoped = allClasses.length > 0 ? new Map(allClasses.map((c) => [c, MetadataRegistry.get(c)])) : void 0;
556
+ this.graph = buildMaintenanceGraph(scoped, views);
557
+ for (const item of this.graph.items) {
558
+ this.ownerByName.set(item.ownerEntity, item.ownerClass);
559
+ }
560
+ }
561
+ /** The CDC {@link ChangeHandler} to subscribe to an emulator / Streams source. */
562
+ handler = async (batch) => {
563
+ const failures = [];
564
+ for (const event of batch.records) {
565
+ if (!event.keys.pk.startsWith(MAINT_OUTBOX_PK_PREFIX)) continue;
566
+ if (event.eventName === "REMOVE") continue;
567
+ const image = event.newImage;
568
+ if (image === void 0) continue;
569
+ try {
570
+ await this.applyOne(event, image);
571
+ } catch (e) {
572
+ failures.push(event.sequenceNumber);
573
+ if (process.env.GRAPHDDB_DRAIN_DEBUG) {
574
+ console.error("DRAIN ERROR", event.keys.pk, e);
575
+ }
576
+ }
577
+ }
578
+ return failures.length > 0 ? { batchItemFailures: failures } : {};
579
+ };
580
+ /** Resolve one maintenance-outbox event to its effect and apply the owner-row write. */
581
+ async applyOne(event, image) {
582
+ const eventId = `${event.shardId}#${event.sequenceNumber}`;
583
+ if (this.appliedSeq.has(eventId)) return;
584
+ const ownerEntity = String(image[OWNER_ATTR] ?? "");
585
+ const relationProperty = String(image[RELATION_ATTR] ?? "");
586
+ const trigger = String(image[TRIGGER_ATTR] ?? "");
587
+ const item = this.resolve(trigger, ownerEntity, relationProperty);
588
+ if (item === void 0) {
589
+ this.appliedSeq.add(eventId);
590
+ return;
591
+ }
592
+ const effect = item.effect;
593
+ const ownerClass = item.ownerClass;
594
+ const { TableName, Key } = ownerKey(ownerClass, effect, image);
595
+ const executor = ClientManager.getExecutor();
596
+ if (effect.kind === "counter") {
597
+ if (effect.value.op === "count") {
598
+ await executor.update({
599
+ TableName,
600
+ Key,
601
+ UpdateExpression: "ADD #a0 :a0",
602
+ ExpressionAttributeNames: { "#a0": effect.attribute },
603
+ ExpressionAttributeValues: { ":a0": effect.delta ?? 0 }
604
+ });
605
+ } else {
606
+ await this.applyMax(TableName, Key, effect.attribute, effect.value.field, image);
607
+ }
608
+ } else if (effect.kind === "membership") {
609
+ await this.applyMembership(TableName, Key, item, effect, image);
610
+ } else if (effect.kind === "snapshot") {
611
+ const projection = projectFrom(effect.project, image);
612
+ await this.applySnapshot(TableName, Key, projection);
613
+ } else {
614
+ await this.applyCollection(TableName, Key, item, image);
615
+ }
616
+ this.appliedSeq.add(eventId);
617
+ this.applied += 1;
618
+ }
619
+ /** Resolve a maintenance-outbox event back to the declared {@link MaintainItem}. */
620
+ resolve(trigger, ownerEntity, relationProperty) {
621
+ for (const item of this.graph.effectsFor(trigger)) {
622
+ const itemOwnerName = item.ownerClass.name;
623
+ if ((itemOwnerName === ownerEntity || item.ownerEntity === ownerEntity) && item.relationProperty === relationProperty) {
624
+ return item;
625
+ }
626
+ }
627
+ return void 0;
628
+ }
629
+ /** A `snapshot` SET of each projected attribute onto the owner row (idempotent). */
630
+ async applySnapshot(TableName, Key, projection) {
631
+ const names = {};
632
+ const values = {};
633
+ const sets = [];
634
+ let i = 0;
635
+ for (const [attr, value] of Object.entries(projection)) {
636
+ names[`#m${i}`] = attr;
637
+ values[`:m${i}`] = value;
638
+ sets.push(`#m${i} = :m${i}`);
639
+ i += 1;
640
+ }
641
+ if (sets.length === 0) return;
642
+ await ClientManager.getExecutor().update({
643
+ TableName,
644
+ Key,
645
+ UpdateExpression: `SET ${sets.join(", ")}`,
646
+ ExpressionAttributeNames: names,
647
+ ExpressionAttributeValues: values
648
+ });
649
+ }
650
+ /**
651
+ * A sparse-view **membership** write (#133): evaluate the membership predicate against
652
+ * the source image; PUT the view row (its projection + key fields) when the predicate
653
+ * holds, DELETE it when it flips false. A source `removed` event always deletes (the
654
+ * source no longer exists, so its view row must disappear regardless of the predicate).
655
+ *
656
+ * Both ops are idempotent under at-least-once redelivery: a repeated PUT writes the same
657
+ * row, a repeated DELETE is a no-op. The view row is keyed by the source identity
658
+ * (`effect.keys`), so the PUT/DELETE always targets the SAME physical row the predicate
659
+ * gates.
660
+ */
661
+ async applyMembership(TableName, Key, item, effect, image) {
662
+ void TableName;
663
+ const ownerClass = item.ownerClass;
664
+ const isRemoved = item.trigger.endsWith(".removed");
665
+ const holds = !isRemoved && evaluateMembership(effect.predicate, image);
666
+ if (holds) {
667
+ const keyInput2 = {};
668
+ for (const [ownerField, srcPath] of Object.entries(effect.keys)) {
669
+ keyInput2[ownerField] = image[pathField(srcPath)];
670
+ }
671
+ const projection = projectFrom(effect.project, image);
672
+ const putItem = { ...keyInput2, ...projection };
673
+ const putInput = buildPutInput(ownerClass, putItem);
674
+ await ClientManager.getExecutor().put(putInput);
675
+ return;
676
+ }
677
+ const keyInput = {};
678
+ for (const [ownerField, srcPath] of Object.entries(effect.keys)) {
679
+ keyInput[ownerField] = image[pathField(srcPath)];
680
+ }
681
+ const deleteInput = buildDeleteInput(ownerClass, keyInput);
682
+ await ClientManager.getExecutor().delete(deleteInput);
683
+ }
684
+ /**
685
+ * A running `max`: read the owner row, and `SET` the attribute to the source value
686
+ * only when it is greater (or absent). Implemented as a conditional update so a
687
+ * redelivered / out-of-order older value never regresses the stored max — idempotent
688
+ * and commutative under at-least-once delivery.
689
+ */
690
+ async applyMax(TableName, Key, attribute, sourceField, image) {
691
+ const candidate = image[sourceField];
692
+ if (candidate === void 0 || candidate === null) return;
693
+ const cond = buildConditionExpression({ attributeNotExists: attribute });
694
+ try {
695
+ await ClientManager.getExecutor().update({
696
+ TableName,
697
+ Key,
698
+ UpdateExpression: "SET #a0 = :v",
699
+ ConditionExpression: `attribute_not_exists(#a0) OR #a0 < :v`,
700
+ ExpressionAttributeNames: { "#a0": attribute },
701
+ ExpressionAttributeValues: { ":v": candidate }
702
+ });
703
+ } catch (e) {
704
+ void cond;
705
+ if (isConditionFailure(e)) return;
706
+ throw e;
707
+ }
708
+ }
709
+ /**
710
+ * A bounded `collection`: read the current list, apply the event (append for a
711
+ * `created`/`updated` source, splice for a `removed`), de-duplicate by the projected
712
+ * identity key, order by `orderBy` (in `orderDir`, default DESC), trim to `maxItems`, and write the whole
713
+ * list back. The read-modify-write is exactly what a single synchronous
714
+ * `UpdateExpression` cannot do — the reason a bounded/ordered collection is a stream
715
+ * maintainer (Phase 1 sync was append-only).
716
+ */
717
+ async applyCollection(TableName, Key, item, image) {
718
+ const effect = item.effect;
719
+ if (effect.kind !== "collection") return;
720
+ const field = effect.collection.field;
721
+ const executor = ClientManager.getExecutor();
722
+ const res = await executor.execute({
723
+ type: "GetItem",
724
+ tableName: TableName,
725
+ keyCondition: Key,
726
+ consistentRead: true
727
+ });
728
+ const current = res.items[0];
729
+ const existing = Array.isArray(current?.[field]) ? [...current[field]] : [];
730
+ const projected = projectFrom(effect.project, image);
731
+ const idAttr = collectionIdentityAttr(effect);
732
+ const idOf = (row) => idAttr ? row[idAttr] : void 0;
733
+ const isRemoved = item.trigger.endsWith(".removed");
734
+ let next;
735
+ if (isRemoved) {
736
+ next = existing.filter((row) => idOf(row) !== idOf(projected));
737
+ } else {
738
+ next = existing.filter((row) => idOf(row) !== idOf(projected));
739
+ next.push(projected);
740
+ }
741
+ next = orderAndTrimCollection(next, effect.collection);
742
+ await executor.update({
743
+ TableName,
744
+ Key,
745
+ UpdateExpression: "SET #c = :list",
746
+ ExpressionAttributeNames: { "#c": field },
747
+ ExpressionAttributeValues: { ":list": next }
748
+ });
749
+ }
750
+ /** Owner-row writes applied so far (test/diagnostic). */
751
+ appliedCount() {
752
+ return this.applied;
753
+ }
754
+ /** Clear the per-delivery de-dup set + counters (test reset). */
755
+ reset() {
756
+ this.appliedSeq = /* @__PURE__ */ new Set();
757
+ this.applied = 0;
758
+ }
759
+ };
760
+ function evaluateMembership(predicate, image) {
761
+ const field = pathField(predicate.path);
762
+ const present = Object.prototype.hasOwnProperty.call(image, field);
763
+ const value = image[field];
764
+ switch (predicate.op) {
765
+ case "exists":
766
+ return present && value !== null && value !== void 0;
767
+ case "notExists":
768
+ return !present || value === null || value === void 0;
769
+ case "truthy":
770
+ return Boolean(value);
771
+ case "falsy":
772
+ return !value;
773
+ case "eq":
774
+ return value === predicate.value;
775
+ case "ne":
776
+ return value !== predicate.value;
777
+ default:
778
+ return false;
779
+ }
780
+ }
781
+ function isConditionFailure(e) {
782
+ const name = e?.name ?? "";
783
+ const msg = e instanceof Error ? e.message : String(e);
784
+ return name === "ConditionalCheckFailedException" || /ConditionalCheckFailed|condition/i.test(msg);
785
+ }
786
+ function createMaintenanceDrain(opts = {}) {
787
+ return new MaintenanceDrain(opts);
788
+ }
789
+ function createMaintenanceDrainHandler(opts = {}) {
790
+ return createMaintenanceDrain(opts).handler;
791
+ }
792
+
793
+ // src/cdc/projection-sink.ts
794
+ var EVENT_FOR_CHANGE = {
795
+ INSERT: "created",
796
+ MODIFY: "updated",
797
+ REMOVE: "removed"
798
+ };
799
+ var InMemoryProjectionSink = class {
800
+ /** The current sink contents, keyed by the idempotency key. */
801
+ records = /* @__PURE__ */ new Map();
802
+ /** Total upsert CALLS (including redelivered ones) — distinguishes calls from records. */
803
+ upserts = 0;
804
+ upsert(key, record) {
805
+ this.records.set(key, record);
806
+ this.upserts += 1;
807
+ }
808
+ delete(key) {
809
+ this.records.delete(key);
810
+ }
811
+ /** The number of distinct sink records (the converged projection size). */
812
+ size() {
813
+ return this.records.size;
814
+ }
815
+ /** The total upsert calls (≥ size when redelivery occurred). */
816
+ upsertCount() {
817
+ return this.upserts;
818
+ }
819
+ /** Reset the sink (test). */
820
+ reset() {
821
+ this.records.clear();
822
+ this.upserts = 0;
823
+ }
824
+ };
825
+ var ProjectionSinkDrain = class {
826
+ sink;
827
+ /** Source-entity CLASS name (the `event.model` the capture seam records) → projections. */
828
+ bySource = /* @__PURE__ */ new Map();
829
+ /** Per-(shard,seq) de-dup within one handler invocation (at-least-once). */
830
+ applied = /* @__PURE__ */ new Set();
831
+ constructor(opts) {
832
+ this.sink = opts.sink;
833
+ for (const proj of opts.projections) {
834
+ if (!isProjectionDefinition(proj)) {
835
+ throw new Error(
836
+ "ProjectionSinkDrain: a non-projection definition was supplied. Build projections with defineProjection(...)."
837
+ );
838
+ }
839
+ const sourceName = proj.sourceClass.name;
840
+ const bucket = this.bySource.get(sourceName);
841
+ if (bucket) bucket.push(proj);
842
+ else this.bySource.set(sourceName, [proj]);
843
+ }
844
+ }
845
+ /** The CDC {@link ChangeHandler} to subscribe to an emulator / Streams source. */
846
+ handler = async (batch) => {
847
+ const failures = [];
848
+ for (const event of batch.records) {
849
+ try {
850
+ await this.applyOne(event);
851
+ } catch (e) {
852
+ failures.push(event.sequenceNumber);
853
+ if (process.env.GRAPHDDB_PROJECTION_DEBUG) {
854
+ console.error("PROJECTION SINK ERROR", event.keys.pk, e);
855
+ }
856
+ }
857
+ }
858
+ return failures.length > 0 ? { batchItemFailures: failures } : {};
859
+ };
860
+ /** Project one source event into the sink for every matching projection. */
861
+ async applyOne(event) {
862
+ const sourceName = event.model;
863
+ if (sourceName === void 0) return;
864
+ const projections = this.bySource.get(sourceName);
865
+ if (projections === void 0 || projections.length === 0) return;
866
+ const lifecycleEvent = EVENT_FOR_CHANGE[event.eventName];
867
+ const image = event.eventName === "REMOVE" ? event.oldImage : event.newImage;
868
+ if (image === void 0) return;
869
+ const eventId = `${event.shardId}#${event.sequenceNumber}`;
870
+ if (this.applied.has(eventId)) return;
871
+ for (const proj of projections) {
872
+ if (!proj.on.includes(lifecycleEvent)) continue;
873
+ const key = image[proj.idempotencyKey];
874
+ if (key === void 0 || key === null) {
875
+ throw new Error(
876
+ `defineProjection '${proj.name}': source event carries no idempotency key attribute '${proj.idempotencyKey}'. Every projected source row must carry it (it folds at-least-once delivery to one sink upsert).`
877
+ );
878
+ }
879
+ const keyStr = String(key);
880
+ if (lifecycleEvent === "removed") {
881
+ await this.sink.delete?.(keyStr);
882
+ continue;
883
+ }
884
+ const record = Object.keys(proj.project).length === 0 ? image : projectFrom(proj.project, image);
885
+ await this.sink.upsert(keyStr, record);
886
+ }
887
+ this.applied.add(eventId);
888
+ }
889
+ /** Clear the per-delivery de-dup set (test reset). */
890
+ reset() {
891
+ this.applied = /* @__PURE__ */ new Set();
892
+ }
893
+ };
894
+ function createProjectionSinkDrain(opts) {
895
+ return new ProjectionSinkDrain(opts);
896
+ }
897
+ function createProjectionSinkHandler(opts) {
898
+ return createProjectionSinkDrain(opts).handler;
899
+ }
900
+
901
+ export {
902
+ CdcEmulator,
903
+ createCdcEmulator,
904
+ pathField,
905
+ projectFrom,
906
+ compareDesc,
907
+ orderAndTrimCollection,
908
+ MAINT_OUTBOX_PK_PREFIX,
909
+ MaintenanceDrain,
910
+ createMaintenanceDrain,
911
+ createMaintenanceDrainHandler,
912
+ InMemoryProjectionSink,
913
+ ProjectionSinkDrain,
914
+ createProjectionSinkDrain,
915
+ createProjectionSinkHandler
916
+ };