effect-dynamodb 1.28.0 → 1.30.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.
Files changed (61) hide show
  1. package/dist/Batch.d.ts.map +1 -1
  2. package/dist/Batch.js +3 -2
  3. package/dist/Batch.js.map +1 -1
  4. package/dist/Entity.d.ts +24 -1
  5. package/dist/Entity.d.ts.map +1 -1
  6. package/dist/Entity.js +29 -15
  7. package/dist/Entity.js.map +1 -1
  8. package/dist/EventStore.d.ts +8 -2
  9. package/dist/EventStore.d.ts.map +1 -1
  10. package/dist/EventStore.js +35 -12
  11. package/dist/EventStore.js.map +1 -1
  12. package/dist/Transaction.d.ts.map +1 -1
  13. package/dist/Transaction.js +5 -2
  14. package/dist/Transaction.js.map +1 -1
  15. package/dist/change-feed/ChangeFeed.d.ts +203 -0
  16. package/dist/change-feed/ChangeFeed.d.ts.map +1 -0
  17. package/dist/change-feed/ChangeFeed.js +498 -0
  18. package/dist/change-feed/ChangeFeed.js.map +1 -0
  19. package/dist/change-feed/CheckpointStore.d.ts +113 -0
  20. package/dist/change-feed/CheckpointStore.d.ts.map +1 -0
  21. package/dist/change-feed/CheckpointStore.js +247 -0
  22. package/dist/change-feed/CheckpointStore.js.map +1 -0
  23. package/dist/change-feed/DynamoStreamsClient.d.ts +71 -0
  24. package/dist/change-feed/DynamoStreamsClient.d.ts.map +1 -0
  25. package/dist/change-feed/DynamoStreamsClient.js +92 -0
  26. package/dist/change-feed/DynamoStreamsClient.js.map +1 -0
  27. package/dist/change-feed/Errors.d.ts +84 -0
  28. package/dist/change-feed/Errors.d.ts.map +1 -0
  29. package/dist/change-feed/Errors.js +40 -0
  30. package/dist/change-feed/Errors.js.map +1 -0
  31. package/dist/change-feed/index.d.ts +36 -0
  32. package/dist/change-feed/index.d.ts.map +1 -0
  33. package/dist/change-feed/index.js +36 -0
  34. package/dist/change-feed/index.js.map +1 -0
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/internal/EntityOps.d.ts +1 -1
  40. package/dist/internal/EntityOps.d.ts.map +1 -1
  41. package/dist/internal/EntityOps.js.map +1 -1
  42. package/dist/internal/EventProjection.d.ts +220 -29
  43. package/dist/internal/EventProjection.d.ts.map +1 -1
  44. package/dist/internal/EventProjection.js +221 -308
  45. package/dist/internal/EventProjection.js.map +1 -1
  46. package/dist/internal/MultiProjection.d.ts +255 -0
  47. package/dist/internal/MultiProjection.d.ts.map +1 -0
  48. package/dist/internal/MultiProjection.js +377 -0
  49. package/dist/internal/MultiProjection.js.map +1 -0
  50. package/dist/internal/ProjectionEngine.d.ts +253 -0
  51. package/dist/internal/ProjectionEngine.d.ts.map +1 -0
  52. package/dist/internal/ProjectionEngine.js +850 -0
  53. package/dist/internal/ProjectionEngine.js.map +1 -0
  54. package/dist/internal/TransactWriteOps.d.ts.map +1 -1
  55. package/dist/internal/TransactWriteOps.js +12 -3
  56. package/dist/internal/TransactWriteOps.js.map +1 -1
  57. package/dist/internal/TransactableOps.d.ts +44 -1
  58. package/dist/internal/TransactableOps.d.ts.map +1 -1
  59. package/dist/internal/TransactableOps.js +52 -2
  60. package/dist/internal/TransactableOps.js.map +1 -1
  61. package/package.json +14 -3
@@ -0,0 +1,850 @@
1
+ /**
2
+ * The row engine behind EventStore projections (#151, #153, #154, #155).
3
+ *
4
+ * One engine serves single-source projections (`EventStore.projection`, one
5
+ * source and a scalar checkpoint) and multi-source projections
6
+ * (`EventStore.multiProjection`, a checkpoint per source instance): a row is
7
+ * read strongly consistent, each contributing instance's events are ordered,
8
+ * deduplicated and gap-filled from the store, folded, and written once —
9
+ * conditioned on the row's revision (or, without one, its checkpoint) — as
10
+ * the row, a tombstone (#154), or either together with outbox items in one
11
+ * transaction (#155).
12
+ *
13
+ * Internal: the public shapes live in `EventProjection.ts` / `MultiProjection.ts`.
14
+ *
15
+ * @internal
16
+ */
17
+ import { ConcurrentModification, isAwsConditionalCheckFailed, isAwsTransactionCancelled, ProjectionSourceConflict, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
18
+ import { DateTime, Duration as Dur, Effect, Option, Random, Result, Schema as S, Schedule, } from "effect";
19
+ import { DynamoClient } from "../DynamoClient.js";
20
+ import { decodeMarshalledItem } from "../Entity.js";
21
+ import { fromAttributeMap, toAttributeMap } from "../Marshaller.js";
22
+ import { resolveTtlAttributeName } from "../Table.js";
23
+ import { condition as entityCondition } from "./EntityCombinators.js";
24
+ import { composePrimaryKey, generateTimestampPrimitive, validateAndBuildPutItem, } from "./TransactableOps.js";
25
+ // ---------------------------------------------------------------------------
26
+ // Removal (#154)
27
+ // ---------------------------------------------------------------------------
28
+ const RemoveRowTypeId = Symbol.for("effect-dynamodb/EventStore/RemoveRow");
29
+ /**
30
+ * Returned from a projection's `evolve`, removes the row: the library writes a
31
+ * tombstone at the row's key that keeps its checkpoints (and revision), so a
32
+ * redelivery can't recreate it. A later event starts the row again from
33
+ * `initial`. Requires `removal` on the projection.
34
+ */
35
+ export const removeRow = { [RemoveRowTypeId]: RemoveRowTypeId };
36
+ /** @internal */
37
+ export const isRemoveRow = (u) => typeof u === "object" && u !== null && RemoveRowTypeId in u;
38
+ /** The `__edd_e__` of a read model's tombstones. */
39
+ export const tombstoneMarker = (entityType) => `${entityType}#tombstone`;
40
+ /** The `__edd_e__` of a read model's outbox items. */
41
+ export const outboxMarker = (entityType) => `${entityType}#outbox`;
42
+ /** Attribute names the engine owns on tombstones and outbox items. */
43
+ export const REMOVED_AT = "__edd_removed__";
44
+ const CLAIM_TOKEN = "__edd_claim__";
45
+ const CLAIMED_UNTIL = "__edd_claimed_until__";
46
+ const ROW_PK = "__edd_row_pk__";
47
+ const ROW_SK = "__edd_row_sk__";
48
+ /** The engine's own lost-race signal. */
49
+ class LostRace {
50
+ key;
51
+ constructor(key) {
52
+ this.key = key;
53
+ }
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // The engine
57
+ // ---------------------------------------------------------------------------
58
+ /** A key's identity: its fields, sorted, as JSON. */
59
+ export const keyId = (key) => JSON.stringify(Object.entries(key).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
60
+ /** `K | Option<K>` → `Option<K>`. */
61
+ export const addressed = (key) => Option.isOption(key) ? key : Option.some(key);
62
+ /** A map checkpoint's key. */
63
+ export const instanceKey = (source, streamId) => `${source}#${streamId}`;
64
+ /** @internal */
65
+ export const modelFields = (model) => {
66
+ const fields = model.fields;
67
+ return typeof fields === "object" && fields !== null ? Object.keys(fields) : undefined;
68
+ };
69
+ /** @internal */
70
+ export const makeEngine = (config) => {
71
+ const { readModel } = config;
72
+ const entityType = readModel.entityType;
73
+ const fields = modelFields(readModel.model);
74
+ const createdAtField = readModel.systemFields.createdAt;
75
+ /** A domain field's stored attribute name. */
76
+ const dbName = (field) => {
77
+ const probe = { [field]: 1 };
78
+ readModel._renameToDynamo(probe);
79
+ return Object.keys(probe)[0] ?? field;
80
+ };
81
+ const primary = readModel.indexes.primary;
82
+ const call = (name, f) => Effect.try({
83
+ try: f,
84
+ catch: (cause) => new ValidationError({ entityType, operation: `${config.label}.${name}`, cause }),
85
+ });
86
+ /** The checkpoint and revision fields a tombstone keeps. */
87
+ const keptFields = (() => {
88
+ const kept = new Set();
89
+ if (config.revision !== undefined)
90
+ kept.add(config.revision.field);
91
+ const layout = config.checkpoints;
92
+ if (layout.kind === "scalar")
93
+ kept.add(layout.field);
94
+ else {
95
+ if (layout.map !== undefined)
96
+ kept.add(layout.map);
97
+ for (const f of Object.values(layout.fields)) {
98
+ kept.add(f.field);
99
+ kept.add(f.instanceField);
100
+ }
101
+ }
102
+ return [...kept];
103
+ })();
104
+ // Resolved when it runs: an entity's table is set when it is registered on
105
+ // one, which may come after the projection is defined.
106
+ // Rows may be tombstones (#154): the entity's `create` takes one's place,
107
+ // keeping these fields — the checkpoints — and the revision one higher.
108
+ if (config.removal !== undefined) {
109
+ ;
110
+ readModel._projectionTombstones = {
111
+ kept: keptFields.filter((f) => f !== config.revision?.field),
112
+ revision: config.revision?.field,
113
+ };
114
+ }
115
+ const tableName = Effect.suspend(() => Effect.map(readModel._tableTag, (tc) => tc.name));
116
+ /** The row, read strongly consistent — raw, so a tombstone reads as one. */
117
+ const readRow = (key) => Effect.gen(function* () {
118
+ const client = yield* DynamoClient;
119
+ const TableName = yield* tableName;
120
+ const result = yield* client.getItem({
121
+ TableName,
122
+ Key: toAttributeMap(composePrimaryKey(readModel, key)),
123
+ ConsistentRead: true,
124
+ });
125
+ const item = result.Item;
126
+ if (item === undefined)
127
+ return Option.none();
128
+ if (item.__edd_e__?.S === tombstoneMarker(entityType)) {
129
+ const raw = fromAttributeMap(item);
130
+ const values = {};
131
+ for (const f of keptFields) {
132
+ const v = raw[dbName(f)];
133
+ if (v !== undefined)
134
+ values[f] = v;
135
+ }
136
+ return Option.some({ values, tombstone: true, createdAt: undefined });
137
+ }
138
+ const decoded = (yield* decodeMarshalledItem(readModel, item));
139
+ let values = decoded;
140
+ if (fields !== undefined) {
141
+ values = {};
142
+ for (const f of fields)
143
+ if (decoded[f] !== undefined)
144
+ values[f] = decoded[f];
145
+ }
146
+ return Option.some({
147
+ values: readModel._attachPrototype?.(values) ?? values,
148
+ tombstone: false,
149
+ createdAt: createdAtField === null ? undefined : decoded[createdAtField],
150
+ });
151
+ });
152
+ /**
153
+ * `claimed` records, per field-mapped source, the instance that owns its
154
+ * field in this attempt — so two instances of one source never both take it.
155
+ */
156
+ const slotOf = (row, source, streamIdStr, claimed) => {
157
+ const values = Option.isSome(row) ? row.value.values : undefined;
158
+ const layout = config.checkpoints;
159
+ if (layout.kind === "scalar") {
160
+ const v = values?.[layout.field];
161
+ return { kind: "scalar", at: v === undefined ? 0 : Number(v) - layout.offset };
162
+ }
163
+ const mapped = layout.fields[source];
164
+ const fromMap = () => {
165
+ const map = (values?.[layout.map] ?? {});
166
+ const v = map[instanceKey(source, streamIdStr)];
167
+ return { kind: "map", at: v === undefined ? 0 : Number(v) };
168
+ };
169
+ if (mapped === undefined)
170
+ return fromMap();
171
+ const stored = values?.[mapped.instanceField];
172
+ const owner = claimed.get(source) ?? (stored === null || stored === "" ? undefined : stored);
173
+ const scalar = values?.[mapped.field];
174
+ // No owner recorded: a new row, or a legacy one — adopted by this instance.
175
+ if (owner === undefined || owner === streamIdStr) {
176
+ claimed.set(source, streamIdStr);
177
+ return { kind: "field", at: scalar === undefined ? 0 : Number(scalar) - mapped.offset };
178
+ }
179
+ if (layout.map !== undefined)
180
+ return fromMap();
181
+ return new ProjectionSourceConflict({
182
+ entityType,
183
+ source,
184
+ recorded: String(owner),
185
+ arriving: streamIdStr,
186
+ });
187
+ };
188
+ /** The row's revision, when the projection keeps one. */
189
+ const revisionOf = (row) => {
190
+ if (config.revision === undefined || Option.isNone(row))
191
+ return undefined;
192
+ const v = row.value.values[config.revision.field];
193
+ return v === undefined ? undefined : Number(v);
194
+ };
195
+ /** The revision the next write stores: one higher, or `initial` for a row without one. */
196
+ const nextRevisionOf = (row) => {
197
+ const current = revisionOf(row);
198
+ return current === undefined ? config.revision.initial : current + 1;
199
+ };
200
+ /** Write the instances' new checkpoints (and the next revision) onto `target`. */
201
+ const stamp = (target, row, advanced) => {
202
+ const layout = config.checkpoints;
203
+ const previous = Option.isSome(row) ? row.value.values : undefined;
204
+ if (layout.kind === "scalar") {
205
+ for (const a of advanced)
206
+ target[layout.field] = a.version + layout.offset;
207
+ }
208
+ else {
209
+ // Carry every field the layout owns over from the row, then advance.
210
+ for (const f of Object.values(layout.fields)) {
211
+ if (target[f.field] === undefined && previous?.[f.field] !== undefined)
212
+ target[f.field] = previous[f.field];
213
+ if (target[f.instanceField] === undefined && previous?.[f.instanceField] !== undefined) {
214
+ target[f.instanceField] = previous[f.instanceField];
215
+ }
216
+ }
217
+ const map = layout.map === undefined
218
+ ? {}
219
+ : { ...(previous?.[layout.map] ?? {}) };
220
+ for (const a of advanced) {
221
+ if (a.slot.kind === "field") {
222
+ const mapped = layout.fields[a.source];
223
+ target[mapped.field] = a.version + mapped.offset;
224
+ target[mapped.instanceField] = a.streamIdStr;
225
+ }
226
+ else
227
+ map[instanceKey(a.source, a.streamIdStr)] = a.version;
228
+ }
229
+ // An empty map stays absent on a row that had none.
230
+ if (layout.map !== undefined &&
231
+ (Object.keys(map).length > 0 || previous?.[layout.map] !== undefined))
232
+ target[layout.map] = map;
233
+ }
234
+ if (config.revision !== undefined)
235
+ target[config.revision.field] = nextRevisionOf(row);
236
+ };
237
+ /** The write's condition — the revision read, else the scalar checkpoint read. */
238
+ const guard = (row) => {
239
+ if (config.revision !== undefined) {
240
+ const rev = revisionOf(row);
241
+ const name = dbName(config.revision.field);
242
+ return rev === undefined
243
+ ? {
244
+ input: Option.isSome(row) ? { attributeNotExists: config.revision.field } : undefined,
245
+ raw: { expression: "attribute_not_exists(#g)", names: { "#g": name }, values: {} },
246
+ }
247
+ : {
248
+ input: { eq: { [config.revision.field]: rev } },
249
+ raw: {
250
+ expression: "#g = :g",
251
+ names: { "#g": name },
252
+ values: toAttributeMap({ ":g": rev }),
253
+ },
254
+ };
255
+ }
256
+ const layout = config.checkpoints;
257
+ if (Option.isNone(row)) {
258
+ return {
259
+ input: undefined,
260
+ raw: {
261
+ expression: "attribute_not_exists(#g)",
262
+ names: { "#g": primary.pk.field },
263
+ values: {},
264
+ },
265
+ };
266
+ }
267
+ const stored = row.value.values[layout.field];
268
+ // A row written before the projection: no checkpoint yet.
269
+ if (stored === undefined) {
270
+ return {
271
+ input: { attributeNotExists: layout.field },
272
+ raw: {
273
+ expression: "attribute_not_exists(#g)",
274
+ names: { "#g": dbName(layout.field) },
275
+ values: {},
276
+ },
277
+ };
278
+ }
279
+ return {
280
+ input: { eq: { [layout.field]: stored } },
281
+ raw: {
282
+ expression: "#g = :g",
283
+ names: { "#g": dbName(layout.field) },
284
+ values: toAttributeMap({ ":g": stored }),
285
+ },
286
+ };
287
+ };
288
+ const outboxTable = Effect.gen(function* () {
289
+ if (config.outbox?.table === undefined)
290
+ return yield* tableName;
291
+ const tc = (yield* config.outbox.table.Tag);
292
+ return tc.name;
293
+ });
294
+ /** The outbox items' key attribute names (the row table's, or the outbox table's). */
295
+ const outboxKeys = () => config.outbox?.table === undefined
296
+ ? { pk: primary.pk.field, sk: primary.sk.field }
297
+ : { pk: config.outbox.pk, sk: config.outbox.sk };
298
+ const pad = (n, width) => String(n).padStart(width, "0");
299
+ const outboxItems = (rowKey, revision, enqueued) => Effect.forEach(enqueued, (e, i) => Effect.gen(function* () {
300
+ const outbox = config.outbox;
301
+ const encoded = yield* S.encodeUnknownEffect(outbox.schema)(e.effect).pipe(Effect.mapError((cause) => new ValidationError({ entityType, operation: `${config.label}.outbox`, cause })));
302
+ const keys = outboxKeys();
303
+ const rowPk = String(rowKey[primary.pk.field]);
304
+ const rowSk = String(rowKey[primary.sk.field]);
305
+ const sk = `${rowSk}#outbox#${pad(revision, 12)}#${pad(i, 4)}`;
306
+ const item = {
307
+ [keys.pk]: rowPk,
308
+ [keys.sk]: sk,
309
+ __edd_e__: outboxMarker(entityType),
310
+ [ROW_PK]: rowPk,
311
+ [ROW_SK]: rowSk,
312
+ effect: encoded,
313
+ source: e.source,
314
+ streamId: e.event.streamId,
315
+ version: e.event.version,
316
+ eventType: e.event.eventType,
317
+ };
318
+ if (outbox.index !== undefined) {
319
+ item[outbox.index.pk] = outboxMarker(entityType);
320
+ item[outbox.index.sk] = `${rowPk}#${sk}`;
321
+ }
322
+ return toAttributeMap(item);
323
+ }));
324
+ /** A lost race, from a single write's or a transaction's failure. */
325
+ const lostRaceOf = (error, transaction) => {
326
+ const cause = error?.cause;
327
+ if (!transaction)
328
+ return isAwsConditionalCheckFailed(cause);
329
+ return (isAwsTransactionCancelled(cause) &&
330
+ cause.CancellationReasons?.[0]?.Code === "ConditionalCheckFailed");
331
+ };
332
+ /** The row (or tombstone) and any outbox items, written as one conditional unit. */
333
+ const writeRow = (key, target, kept, row, enqueued, revision) => Effect.gen(function* () {
334
+ const client = yield* DynamoClient;
335
+ const TableName = yield* tableName;
336
+ const rowKey = composePrimaryKey(readModel, key);
337
+ const condition = guard(row);
338
+ const createdAt = Option.isSome(row) ? row.value.createdAt : undefined;
339
+ // The row's item: the entity's own `put` (no outbox), or built for a transaction.
340
+ let rowItem;
341
+ if (Option.isNone(target)) {
342
+ // A tombstone: the key, the kept checkpoint fields, the revision.
343
+ const tc = (yield* readModel._tableTag);
344
+ const now = yield* DateTime.now;
345
+ const tomb = {
346
+ ...rowKey,
347
+ __edd_e__: tombstoneMarker(entityType),
348
+ [REMOVED_AT]: DateTime.formatIso(now),
349
+ };
350
+ for (const [f, v] of Object.entries(kept))
351
+ tomb[dbName(f)] = v;
352
+ if (config.removal?.ttl !== undefined) {
353
+ tomb[resolveTtlAttributeName(tc)] = Math.floor((DateTime.toEpochMillis(now) + Dur.toMillis(config.removal.ttl)) / 1000);
354
+ }
355
+ rowItem = toAttributeMap(tomb);
356
+ }
357
+ else if (enqueued.length > 0) {
358
+ const built = yield* validateAndBuildPutItem(readModel, target.value, `${config.label}.write`);
359
+ const item = { ...built.item };
360
+ if (createdAtField !== null && DateTime.isDateTime(createdAt)) {
361
+ item[dbName(createdAtField)] = generateTimestampPrimitive(DateTime.toUtc(createdAt), readModel.systemFields.createdAtEncoding);
362
+ }
363
+ rowItem = toAttributeMap(item);
364
+ }
365
+ if (rowItem === undefined) {
366
+ // The row alone, through the entity: `put` conditioned on the guard
367
+ // (keeping the row's `createdAt`), or `create` for a new row.
368
+ const value = target.pipe(Option.getOrThrow);
369
+ const op = condition.input === undefined
370
+ ? readModel.create(value)
371
+ : entityCondition(readModel.put(value), condition.input);
372
+ const run = condition.input === undefined
373
+ ? op.asEffect()
374
+ : op._builder("model", {
375
+ condition: op._condition,
376
+ withVectors: op._withVectors,
377
+ putKind: op._putKind,
378
+ createdAt: DateTime.isDateTime(createdAt) ? DateTime.toUtc(createdAt) : undefined,
379
+ });
380
+ const written = yield* run.pipe(Effect.catchTag("ConditionalCheckFailed", () => Effect.fail(new LostRace(key))));
381
+ return Option.some(written);
382
+ }
383
+ const rowPut = {
384
+ Put: {
385
+ TableName,
386
+ Item: rowItem,
387
+ ConditionExpression: condition.raw.expression,
388
+ ExpressionAttributeNames: condition.raw.names,
389
+ ...(Object.keys(condition.raw.values).length > 0
390
+ ? { ExpressionAttributeValues: condition.raw.values }
391
+ : {}),
392
+ },
393
+ };
394
+ if (enqueued.length === 0) {
395
+ yield* client.putItem(rowPut.Put).pipe(Effect.catchIf((error) => lostRaceOf(error, false), () => Effect.fail(new LostRace(key))));
396
+ }
397
+ else {
398
+ const OutboxTable = yield* outboxTable;
399
+ const items = yield* outboxItems(rowKey, revision ?? 0, enqueued);
400
+ yield* client
401
+ .transactWriteItems({
402
+ TransactItems: [
403
+ rowPut,
404
+ ...items.map((Item) => ({ Put: { TableName: OutboxTable, Item } })),
405
+ ],
406
+ })
407
+ .pipe(Effect.catchIf((error) => lostRaceOf(error, true), () => Effect.fail(new LostRace(key))));
408
+ }
409
+ return target;
410
+ });
411
+ // -------------------------------------------------------------------------
412
+ // Apply
413
+ // -------------------------------------------------------------------------
414
+ /** Every version from `from` to the last of `events` was delivered. */
415
+ const coveredFrom = (events, from, delivered) => {
416
+ const own = new Set(events.map((e) => e.version));
417
+ const last = events[events.length - 1].version;
418
+ for (let v = from; v <= last; v++)
419
+ if (!own.has(v) && !delivered.has(v))
420
+ return false;
421
+ return true;
422
+ };
423
+ const gapError = (streamId, from, to) => Effect.fail(new ValidationError({
424
+ entityType,
425
+ operation: config.label,
426
+ cause: `Stream ${JSON.stringify(streamId)} is missing events between versions ${from} and ` +
427
+ `${to}: a strongly consistent read did not return them. Was the stream written ` +
428
+ "outside `append`?",
429
+ }));
430
+ const withRetry = (attempt) => Effect.retry(attempt, {
431
+ schedule: config.retry,
432
+ while: (error) => error instanceof LostRace,
433
+ }).pipe(Effect.mapError((error) => error instanceof LostRace
434
+ ? new ConcurrentModification({
435
+ entityType,
436
+ key: error.key,
437
+ attributes: [
438
+ config.revision?.field ?? config.checkpoints.field,
439
+ ],
440
+ current: Option.none(),
441
+ })
442
+ : error));
443
+ /** The row's events of one instance: its checkpoint, what to fold, what was already in. */
444
+ const runOf = (rowId, slotAt, work) => Effect.gen(function* () {
445
+ const alreadyApplied = work.events?.filter((e) => e.version <= slotAt) ?? [];
446
+ const pending = work.events?.filter((e) => e.version > slotAt);
447
+ if (pending !== undefined ? pending.length === 0 : slotAt >= work.top) {
448
+ return { run: [], alreadyApplied };
449
+ }
450
+ // `latest` reads nothing: its candidates are the batch's events.
451
+ if (work.source.mode === "latest" && pending !== undefined)
452
+ return { run: pending, alreadyApplied };
453
+ if (pending !== undefined && coveredFrom(pending, slotAt + 1, work.delivered)) {
454
+ return { run: pending, alreadyApplied };
455
+ }
456
+ const upTo = pending === undefined ? work.top : pending[pending.length - 1].version;
457
+ const read = (yield* work.source.stream.readFrom(work.streamId, slotAt, {
458
+ consistentRead: true,
459
+ toVersion: upTo,
460
+ }));
461
+ let last = slotAt;
462
+ for (const e of read) {
463
+ if (e.version !== last + 1)
464
+ return yield* gapError(work.streamIdStr, last + 1, upTo);
465
+ last = e.version;
466
+ }
467
+ if (last < upTo)
468
+ return yield* gapError(work.streamIdStr, last + 1, upTo);
469
+ const mine = [];
470
+ for (const e of read) {
471
+ const key = addressed(yield* call("key", () => work.source.key(work.streamId, e)));
472
+ if (Option.isSome(key) && keyId(key.value) === rowId)
473
+ mine.push(e);
474
+ }
475
+ return { run: mine, alreadyApplied };
476
+ });
477
+ /**
478
+ * Apply a row's work: read, gap-fill per instance, fold in a deterministic
479
+ * order (sources as declared, then stream id, then version), write once.
480
+ * `fromScratch` folds from `initial` ignoring the row's state (rebuild).
481
+ */
482
+ const applyRow = (key, work, options) => {
483
+ const rowId = keyId(key);
484
+ const ordered = [...work].sort((a, b) => {
485
+ const sa = config.sources.indexOf(a.source);
486
+ const sb = config.sources.indexOf(b.source);
487
+ return sa !== sb
488
+ ? sa - sb
489
+ : a.streamIdStr < b.streamIdStr
490
+ ? -1
491
+ : a.streamIdStr > b.streamIdStr
492
+ ? 1
493
+ : 0;
494
+ });
495
+ const attempt = Effect.gen(function* () {
496
+ const row = yield* readRow(key);
497
+ /**
498
+ * Instances that failed (not by `terminal`): left out of this write —
499
+ * their checkpoints don't move — and reported, so the row's other
500
+ * sources still apply.
501
+ */
502
+ const failed = [];
503
+ const plans = [];
504
+ const claimed = new Map();
505
+ const slotRow = options?.fromScratch === true ? Option.none() : row;
506
+ for (const w of ordered) {
507
+ const slot = slotOf(slotRow, w.source.name, w.streamIdStr, claimed);
508
+ if (slot instanceof ProjectionSourceConflict) {
509
+ failed.push({ source: w.source.name, streamId: w.streamIdStr, error: slot });
510
+ continue;
511
+ }
512
+ const at = options?.fromScratch === true ? 0 : slot.at;
513
+ const r = yield* Effect.result(runOf(rowId, at, w));
514
+ if (Result.isFailure(r)) {
515
+ failed.push({ source: w.source.name, streamId: w.streamIdStr, error: r.failure });
516
+ continue;
517
+ }
518
+ plans.push({ work: w, slot, ...r.success });
519
+ }
520
+ const initialState = options?.fromScratch === true || Option.isNone(row) || row.value.tombstone
521
+ ? Option.none()
522
+ : Option.some(row.value.values);
523
+ const initiallyRemoved = Option.isSome(row) && row.value.tombstone && options?.fromScratch !== true;
524
+ /** Fold `active`; the index of the first instance that fails, if one does. */
525
+ const foldAll = (active) => Effect.gen(function* () {
526
+ let state = initialState;
527
+ let removed = initiallyRemoved;
528
+ const enqueued = [];
529
+ const results = [];
530
+ for (const [index, plan] of active.entries()) {
531
+ const applied = [];
532
+ const rejected = [];
533
+ const superseded = [];
534
+ const context = {
535
+ source: plan.work.source.name,
536
+ streamId: plan.work.streamIdStr,
537
+ };
538
+ /** Evolve one event onto the current state: its next state, or its failure. */
539
+ const evolveOne = (event) => Effect.result(Effect.gen(function* () {
540
+ const base = Option.isSome(state)
541
+ ? state.value
542
+ : yield* call("initial", () => config.initial(key, {
543
+ source: context.source,
544
+ streamId: plan.work.streamId,
545
+ event,
546
+ }));
547
+ const next = yield* plan.work.source.evolve(base, event, context);
548
+ if (isRemoveRow(next) && config.removal === undefined) {
549
+ return yield* new ValidationError({
550
+ entityType,
551
+ operation: config.label,
552
+ cause: "`evolve` returned `removeRow`, but the projection declares no `removal`.",
553
+ });
554
+ }
555
+ const effects = config.outbox === undefined
556
+ ? []
557
+ : yield* call("outbox", () => config.outbox.effects(event, isRemoveRow(next) ? undefined : next, context));
558
+ return { next, effects };
559
+ }));
560
+ const accept = (event, outcome) => {
561
+ if (isRemoveRow(outcome.next)) {
562
+ state = Option.none();
563
+ removed = true;
564
+ }
565
+ else {
566
+ state = Option.some(outcome.next);
567
+ removed = false;
568
+ }
569
+ applied.push(event);
570
+ for (const effect of outcome.effects)
571
+ enqueued.push({ effect, event, source: context.source });
572
+ };
573
+ if (plan.work.source.mode === "latest") {
574
+ // Newest first, until one is accepted: rejections fall back to an
575
+ // older event, and only those older than the accepted one are passed over.
576
+ for (let i = plan.run.length - 1; i >= 0; i--) {
577
+ const event = plan.run[i];
578
+ const result = yield* evolveOne(event);
579
+ if (Result.isSuccess(result)) {
580
+ accept(event, result.success);
581
+ superseded.push(...plan.run.slice(0, i));
582
+ break;
583
+ }
584
+ if (plan.work.source.terminal?.(result.failure) === true) {
585
+ rejected.unshift({ event, error: result.failure });
586
+ }
587
+ else
588
+ return { failedAt: index, error: result.failure };
589
+ }
590
+ }
591
+ else {
592
+ for (const event of plan.run) {
593
+ const result = yield* evolveOne(event);
594
+ if (Result.isSuccess(result))
595
+ accept(event, result.success);
596
+ else if (plan.work.source.terminal?.(result.failure) === true) {
597
+ rejected.push({ event, error: result.failure });
598
+ }
599
+ else
600
+ return { failedAt: index, error: result.failure };
601
+ }
602
+ }
603
+ results.push({ applied, rejected, superseded });
604
+ }
605
+ return { folded: { state, removed, enqueued, results } };
606
+ });
607
+ // Fold; an instance that fails is set aside and the rest folded again.
608
+ let active = plans;
609
+ let folded;
610
+ while (true) {
611
+ const attempt = yield* foldAll(active);
612
+ if ("folded" in attempt) {
613
+ folded = attempt.folded;
614
+ break;
615
+ }
616
+ const bad = active[attempt.failedAt];
617
+ failed.push({
618
+ source: bad.work.source.name,
619
+ streamId: bad.work.streamIdStr,
620
+ error: attempt.error,
621
+ });
622
+ active = active.filter((p) => p !== bad);
623
+ }
624
+ // Every instance failed: the row fails, with the first failure.
625
+ if (active.length === 0 && failed.length > 0)
626
+ return yield* Effect.fail(failed[0].error);
627
+ const { state, removed, enqueued, results } = folded;
628
+ const instances = (version) => active.map((plan, i) => ({
629
+ source: plan.work.source.name,
630
+ streamId: plan.work.streamIdStr,
631
+ applied: results[i].applied,
632
+ alreadyApplied: plan.alreadyApplied,
633
+ rejected: results[i].rejected,
634
+ superseded: results[i].superseded,
635
+ version: version(i),
636
+ }));
637
+ const unchanged = () => ({
638
+ key,
639
+ instances: instances((i) => active[i].slot.at),
640
+ state: Option.isSome(row) && !row.value.tombstone
641
+ ? Option.some(row.value.values)
642
+ : Option.none(),
643
+ removed: Option.isSome(row) && row.value.tombstone,
644
+ revision: revisionOf(row),
645
+ enqueued: 0,
646
+ failed,
647
+ });
648
+ const consumed = active.some((p) => p.run.length > 0);
649
+ if (!consumed && options?.fromScratch !== true)
650
+ return unchanged();
651
+ // No row came of it (only rejections, and none existed): write nothing.
652
+ if (Option.isNone(state) && !removed)
653
+ return unchanged();
654
+ // The new checkpoints: every event considered, applied or not.
655
+ const advanced = active
656
+ .filter((p) => p.run.length > 0 || options?.fromScratch === true)
657
+ .map((p) => ({
658
+ source: p.work.source.name,
659
+ streamIdStr: p.work.streamIdStr,
660
+ slot: p.slot,
661
+ version: p.run.length > 0 ? p.run[p.run.length - 1].version : p.slot.at,
662
+ }));
663
+ const target = Option.isSome(state)
664
+ ? { ...state.value }
665
+ : {};
666
+ stamp(target, options?.fromScratch === true ? Option.none() : row, advanced);
667
+ if (options?.fromScratch === true && config.revision !== undefined) {
668
+ target[config.revision.field] = nextRevisionOf(row);
669
+ }
670
+ const nextRevision = config.revision === undefined ? undefined : target[config.revision.field];
671
+ let written;
672
+ if (Option.isNone(state)) {
673
+ // A tombstone keeps exactly the layout's fields.
674
+ written = yield* writeRow(key, Option.none(), pick(target), row, enqueued, nextRevision);
675
+ }
676
+ else {
677
+ const mapped = config.toItem === undefined
678
+ ? target
679
+ : {
680
+ ...(yield* call("toItem", () => config.toItem(target, config.toItemContext(key, work)))),
681
+ ...pick(target),
682
+ };
683
+ written = yield* writeRow(key, Option.some(mapped), {}, row, enqueued, nextRevision);
684
+ }
685
+ const versions = new Map(advanced.map((a) => [`${a.source}\u0000${a.streamIdStr}`, a.version]));
686
+ return {
687
+ key,
688
+ instances: instances((i) => versions.get(`${active[i].work.source.name}\u0000${active[i].work.streamIdStr}`) ??
689
+ active[i].slot.at),
690
+ state: written,
691
+ removed: Option.isNone(state),
692
+ revision: nextRevision,
693
+ enqueued: enqueued.length,
694
+ failed,
695
+ };
696
+ });
697
+ return withRetry(attempt);
698
+ };
699
+ /** The fields the library owns, re-applied after `toItem`. */
700
+ const pick = (target) => {
701
+ const out = {};
702
+ for (const f of keptFields)
703
+ if (target[f] !== undefined)
704
+ out[f] = target[f];
705
+ return out;
706
+ };
707
+ // -------------------------------------------------------------------------
708
+ // Outbox drain (#155)
709
+ // -------------------------------------------------------------------------
710
+ /**
711
+ * Deliver a row's pending effects in order: claim the head item, deliver it,
712
+ * delete it, repeat. A claim another drainer holds stops the drain; a crash
713
+ * leaves a claim that expires, so the effect is delivered again.
714
+ */
715
+ const drainRaw = (rowPk, rowSk, deliver) => Effect.gen(function* () {
716
+ const outbox = config.outbox;
717
+ const client = yield* DynamoClient;
718
+ const TableName = yield* outboxTable;
719
+ const keys = outboxKeys();
720
+ let delivered = 0;
721
+ while (true) {
722
+ const page = yield* client.query({
723
+ TableName,
724
+ KeyConditionExpression: "#pk = :pk AND begins_with(#sk, :sk)",
725
+ ExpressionAttributeNames: { "#pk": keys.pk, "#sk": keys.sk },
726
+ ExpressionAttributeValues: toAttributeMap({ ":pk": rowPk, ":sk": `${rowSk}#outbox#` }),
727
+ ConsistentRead: true,
728
+ Limit: 1,
729
+ });
730
+ const head = page.Items?.[0];
731
+ if (head === undefined)
732
+ return { delivered, undelivered: Option.none() };
733
+ const raw = fromAttributeMap(head);
734
+ const now = DateTime.toEpochMillis(yield* DateTime.now);
735
+ const token = `${now}-${Math.floor((yield* Random.next) * 1e9)}`;
736
+ const claimed = yield* client
737
+ .updateItem({
738
+ TableName,
739
+ Key: toAttributeMap({ [keys.pk]: raw[keys.pk], [keys.sk]: raw[keys.sk] }),
740
+ UpdateExpression: "SET #c = :c, #u = :u",
741
+ ConditionExpression: "attribute_exists(#pk) AND (attribute_not_exists(#u) OR #u < :now)",
742
+ ExpressionAttributeNames: { "#c": CLAIM_TOKEN, "#u": CLAIMED_UNTIL, "#pk": keys.pk },
743
+ ExpressionAttributeValues: toAttributeMap({
744
+ ":c": token,
745
+ ":u": now + Dur.toMillis(outbox.claimFor),
746
+ ":now": now,
747
+ }),
748
+ })
749
+ .pipe(Effect.as(true), Effect.catchIf((error) => isAwsConditionalCheckFailed(error.cause), () => Effect.succeed(false)));
750
+ // Another drainer holds the head: it delivers in order; stop here.
751
+ if (!claimed)
752
+ return { delivered, undelivered: Option.none() };
753
+ const effect = yield* S.decodeUnknownEffect(outbox.schema)(raw.effect).pipe(Effect.mapError((cause) => new ValidationError({ entityType, operation: `${config.label}.drain`, cause })));
754
+ const meta = {
755
+ source: String(raw.source),
756
+ streamId: String(raw.streamId),
757
+ version: Number(raw.version),
758
+ eventType: String(raw.eventType),
759
+ };
760
+ const outcome = yield* Effect.result(deliver(effect, meta));
761
+ if (Result.isFailure(outcome)) {
762
+ // Release the claim so the next drain retries promptly.
763
+ yield* client
764
+ .updateItem({
765
+ TableName,
766
+ Key: toAttributeMap({ [keys.pk]: raw[keys.pk], [keys.sk]: raw[keys.sk] }),
767
+ UpdateExpression: "REMOVE #c, #u",
768
+ ConditionExpression: "#c = :c",
769
+ ExpressionAttributeNames: { "#c": CLAIM_TOKEN, "#u": CLAIMED_UNTIL },
770
+ ExpressionAttributeValues: toAttributeMap({ ":c": token }),
771
+ })
772
+ .pipe(Effect.ignore);
773
+ return { delivered, undelivered: Option.some({ meta, error: outcome.failure }) };
774
+ }
775
+ // Delivered: remove it, whoever holds the claim now. A drainer whose
776
+ // claim expired mid-delivery must still retire the effect, or two
777
+ // slow drainers re-claim it in turn forever.
778
+ yield* client
779
+ .deleteItem({
780
+ TableName,
781
+ Key: toAttributeMap({ [keys.pk]: raw[keys.pk], [keys.sk]: raw[keys.sk] }),
782
+ ConditionExpression: "attribute_exists(#pk)",
783
+ ExpressionAttributeNames: { "#pk": keys.pk },
784
+ })
785
+ .pipe(Effect.catchIf((error) => isAwsConditionalCheckFailed(error.cause), () => Effect.void));
786
+ delivered++;
787
+ }
788
+ });
789
+ const drain = (key, deliver) => {
790
+ const rowKey = composePrimaryKey(readModel, key);
791
+ return drainRaw(String(rowKey[primary.pk.field]), String(rowKey[primary.sk.field]), deliver);
792
+ };
793
+ /** Every row with pending effects, through the outbox's sparse index, drained. */
794
+ const drainPending = (deliver) => Effect.gen(function* () {
795
+ const outbox = config.outbox;
796
+ if (outbox.index === undefined) {
797
+ return yield* new ValidationError({
798
+ entityType,
799
+ operation: `${config.label}.drainPending`,
800
+ cause: "`drainPending` needs `outbox.index` (a sparse index over the outbox items).",
801
+ });
802
+ }
803
+ const client = yield* DynamoClient;
804
+ const TableName = yield* outboxTable;
805
+ const rows = new Map();
806
+ let start;
807
+ do {
808
+ const page = yield* client.query({
809
+ TableName,
810
+ IndexName: outbox.index.name,
811
+ KeyConditionExpression: "#p = :p",
812
+ ExpressionAttributeNames: { "#p": outbox.index.pk },
813
+ ExpressionAttributeValues: toAttributeMap({ ":p": outboxMarker(entityType) }),
814
+ ...(start === undefined ? {} : { ExclusiveStartKey: start }),
815
+ });
816
+ for (const item of page.Items ?? []) {
817
+ const raw = fromAttributeMap(item);
818
+ const pk = String(raw[ROW_PK]);
819
+ const sk = String(raw[ROW_SK]);
820
+ rows.set(`${pk}\u0000${sk}`, [pk, sk]);
821
+ }
822
+ start = page.LastEvaluatedKey;
823
+ } while (start !== undefined);
824
+ let delivered = 0;
825
+ const undelivered = [];
826
+ for (const [pk, sk] of rows.values()) {
827
+ const result = yield* drainRaw(pk, sk, deliver);
828
+ delivered += result.delivered;
829
+ if (Option.isSome(result.undelivered))
830
+ undelivered.push(result.undelivered.value);
831
+ }
832
+ return { delivered, undelivered };
833
+ });
834
+ return { readRow, applyRow, drain, drainPending, call };
835
+ };
836
+ /**
837
+ * The default lost-race policy: up to 12 retries after a jittered delay
838
+ * growing from 5 ms by 1.5× (about 1.3 s in all).
839
+ */
840
+ export const defaultRetry = Schedule.max([
841
+ Schedule.jittered(Schedule.exponential("5 millis", 1.5)),
842
+ Schedule.recurs(12),
843
+ ]);
844
+ /** The `retry` option as a schedule. */
845
+ export const retrySchedule = (retry) => (retry === undefined
846
+ ? defaultRetry
847
+ : typeof retry === "number"
848
+ ? Schedule.recurs(retry)
849
+ : retry);
850
+ //# sourceMappingURL=ProjectionEngine.js.map