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.
- package/README.md +1 -0
- package/dist/chunk-PNIZS37E.js +916 -0
- package/dist/{chunk-QEOFIXTN.js → chunk-VST3WOK3.js} +235 -556
- package/dist/{chunk-6AIAHP3A.js → chunk-YIXXTGZ6.js} +898 -1
- package/dist/cli.js +2 -2
- package/dist/index.d.ts +477 -3
- package/dist/index.js +412 -17
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +2 -2
- package/dist/{types-xJRn5qkv.d.ts → types-DuJ08bgc.d.ts} +675 -64
- package/package.json +1 -1
- package/dist/chunk-QQNP43JL.js +0 -461
|
@@ -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
|
+
};
|