effect-dynamodb 1.26.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,695 @@
1
+ /**
2
+ * EventStore projections (#151) — read models folded from a change feed that
3
+ * take their order from the event store, not from delivery.
4
+ *
5
+ * DynamoDB Streams orders records per item only, and a transaction's records
6
+ * carry no grouping or order, so a stream's events reach a consumer in any
7
+ * order. A projection groups a batch's events by row, sorts and
8
+ * deduplicates them, reads from the store only the versions the batch lacks
9
+ * (one strongly consistent `readFrom`), folds, and writes the read-model row
10
+ * once, conditioned on the checkpoint it read. See
11
+ * `docs/designs/eventstore-projections.md`.
12
+ *
13
+ * Re-exported from `EventStore`; this module only type-imports it.
14
+ *
15
+ * @internal
16
+ */
17
+ import { ConcurrentModification, StreamRecordIdentifierMissing, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
18
+ import { DateTime, Effect, Option, Pipeable, Result, Schedule, Stream } from "effect";
19
+ import * as Query from "../Query.js";
20
+ import { asItem, condition as entityCondition, consistentRead as entityConsistentRead, } from "./EntityCombinators.js";
21
+ import { imageEntityType } from "./Packed.js";
22
+ const identify = (record, index, options) => {
23
+ const [itemIdentifier, member] = "identifier" in options
24
+ ? [options.identifier(record), "identifier"]
25
+ : options.source === "lambda"
26
+ ? [record.dynamodb?.SequenceNumber, "dynamodb.SequenceNumber"]
27
+ : [record.eventID, "eventID"];
28
+ return typeof itemIdentifier === "string" && itemIdentifier.length > 0
29
+ ? Effect.succeed(itemIdentifier)
30
+ : Effect.fail(new StreamRecordIdentifierMissing({ index, member }));
31
+ };
32
+ /**
33
+ * The partial batch response (`ReportBatchItemFailures`) for `failedRecords`:
34
+ * each identified by `dynamodb.SequenceNumber` for a Lambda event source
35
+ * mapping, by `eventID` for an EventBridge Pipe, or by your own `identifier`
36
+ * accessor.
37
+ *
38
+ * Lambda checkpoints at the lowest reported sequence number and redelivers
39
+ * every record from there, so reporting the earliest failed record of each
40
+ * row is enough. A record without an identifier fails with
41
+ * `StreamRecordIdentifierMissing` — an empty one would fail the whole batch
42
+ * silently — so fail the invocation, and the source redelivers the batch.
43
+ */
44
+ export const batchResponse = (failedRecords, options) => Effect.forEach(failedRecords, (record, i) => identify(record, i, options)).pipe(Effect.map((ids) => ({ batchItemFailures: ids.map((itemIdentifier) => ({ itemIdentifier })) })));
45
+ const markerIndex = (operation, streams) => {
46
+ const index = new Map();
47
+ for (const entry of streams) {
48
+ const existing = index.get(entry[1].eventMarker);
49
+ if (existing !== undefined) {
50
+ throw new Error(`[EDD-9082] ${operation}: "${existing[0]}" and "${entry[0]}" both store events as ` +
51
+ `"${entry[1].eventMarker}", so their images can't be told apart. Give the streams ` +
52
+ "distinct names.");
53
+ }
54
+ index.set(entry[1].eventMarker, entry);
55
+ }
56
+ return index;
57
+ };
58
+ /**
59
+ * One decoder for several streams' change-feed images. Name the streams in a
60
+ * record; `decode(image)` dispatches on the image's `__edd_e__` and returns
61
+ * `{ stream: <name>, event }`, or `Option.none()` for anything that is not one
62
+ * of their events (snapshots, idempotency sentinels, entities, other streams).
63
+ * The image is decoded by that stream's `decodeStreamImage`, so it accepts the
64
+ * same shapes (DynamoDB JSON with `B` as base64 or bytes, or unmarshalled).
65
+ *
66
+ * Two streams storing events under the same marker throw `[EDD-9082]`.
67
+ *
68
+ * @example
69
+ * ```ts
70
+ * const decode = EventStore.decoder({ Order: OrderEvents, Payment: PaymentEvents })
71
+ * const decoded = yield* decode(record.dynamodb.NewImage)
72
+ * if (Option.isSome(decoded) && decoded.value.stream === "Order") { ... }
73
+ * ```
74
+ */
75
+ export const decoder = (streams) => {
76
+ const index = markerIndex("EventStore.decoder", Object.entries(streams));
77
+ return (image) => {
78
+ const entry = typeof image === "object" && image !== null && !Array.isArray(image)
79
+ ? index.get(imageEntityType(image) ?? "")
80
+ : undefined;
81
+ if (entry === undefined)
82
+ return Effect.succeed(Option.none());
83
+ const [name, stream] = entry;
84
+ return stream
85
+ .decodeStreamImage(image)
86
+ .pipe(Effect.map((event) => Option.map(event, (e) => ({ stream: name, event: e }))));
87
+ };
88
+ };
89
+ /**
90
+ * The filter pattern that selects the `INSERT`s of these streams' event items
91
+ * — `JSON.stringify` it into `FilterCriteria.Filters[].Pattern` of a Lambda
92
+ * event source mapping or an EventBridge Pipe. Snapshots, idempotency
93
+ * sentinels and other items are filtered out before the function runs.
94
+ */
95
+ export const eventFilterPattern = (streams) => {
96
+ const list = Array.isArray(streams)
97
+ ? streams
98
+ : Object.values(streams);
99
+ const markers = [...new Set(list.map((s) => s.eventMarker))];
100
+ return { eventName: ["INSERT"], dynamodb: { NewImage: { __edd_e__: { S: markers } } } };
101
+ };
102
+ // ---------------------------------------------------------------------------
103
+ // EventProjection
104
+ // ---------------------------------------------------------------------------
105
+ const EventProjectionTypeId = Symbol.for("effect-dynamodb/EventProjection");
106
+ /** @internal See {@link EventProjection}'s decode member. */
107
+ export const ProjectionDecode = Symbol.for("effect-dynamodb/EventProjection/Decode");
108
+ /**
109
+ * The projection's own lost-race signal: another writer advanced the row
110
+ * between this call's read and its conditional write. Kept apart from a
111
+ * `ConcurrentModification` that `evolve` or the read model's own write path
112
+ * raises, which is not retried; mapped to `ConcurrentModification` when the
113
+ * retries run out.
114
+ */
115
+ class LostRace {
116
+ key;
117
+ constructor(key) {
118
+ this.key = key;
119
+ }
120
+ }
121
+ /**
122
+ * The default lost-race policy: up to 12 retries, jittered and growing from
123
+ * 5 ms by 1.5× (about 1.3 s in all). Each round of a race has one winner, so
124
+ * `n` concurrent writers of one row can take `n` rounds.
125
+ */
126
+ const defaultRetry = Schedule.max([
127
+ Schedule.jittered(Schedule.exponential("5 millis", 1.5)),
128
+ Schedule.recurs(12),
129
+ ]);
130
+ /** The `retry` option as a schedule. */
131
+ const retrySchedule = (retry) => (retry === undefined
132
+ ? defaultRetry
133
+ : typeof retry === "number"
134
+ ? Schedule.recurs(retry)
135
+ : retry);
136
+ /** The model's declared fields, when its schema exposes them. */
137
+ const modelFields = (model) => {
138
+ const fields = model.fields;
139
+ return typeof fields === "object" && fields !== null ? Object.keys(fields) : undefined;
140
+ };
141
+ /**
142
+ * Define a change-feed projection of `stream` into `readModel` — rows keyed by
143
+ * `key(streamId, event)` (one per stream instance when the key ignores the
144
+ * event), each folded from `initial(streamId, event)` through `evolve`, its
145
+ * checkpoint kept in `checkpoint`. See {@link EventProjection} for the
146
+ * operations.
147
+ *
148
+ * The read model must be a plain entity: `versioned` (the checkpoint is the
149
+ * version), `softDelete` (rows are never deleted) and `timeSeries` read
150
+ * models throw `[EDD-9081]`, as do a checkpoint the model has no field for and
151
+ * a non-integer offset.
152
+ *
153
+ * @example
154
+ * ```ts
155
+ * const OrderSummaryProjection = EventStore.projection({
156
+ * stream: OrderEvents,
157
+ * readModel: OrderSummaries,
158
+ * key: (id) => ({ orderId: id.orderId }),
159
+ * initial: (id) => ({ orderId: id.orderId, status: "open", total: 0, version: 0 }),
160
+ * evolve: (state, event) => Effect.succeed(next(state, event.data)),
161
+ * checkpoint: "version",
162
+ * })
163
+ * ```
164
+ */
165
+ export const projection = (config) => {
166
+ const { stream, readModel } = config;
167
+ const label = `EventStore.projection("${stream.streamName}" → "${readModel.entityType}")`;
168
+ const refuse = (problem) => {
169
+ throw new Error(`[EDD-9081] ${label}: ${problem}`);
170
+ };
171
+ if (readModel.versioned) {
172
+ refuse("the read model is `versioned`. The projection's checkpoint is the row's version, and a " +
173
+ "versioned put adds its own lock — use a plain entity with a numeric checkpoint field.");
174
+ }
175
+ if (readModel.softDelete) {
176
+ refuse("the read model has `softDelete`; a projection never deletes its rows.");
177
+ }
178
+ if (readModel.timeSeries) {
179
+ refuse("the read model is a `timeSeries` entity, written by `append`, not `put`.");
180
+ }
181
+ const field = (typeof config.checkpoint === "string" ? config.checkpoint : config.checkpoint.field);
182
+ const offset = typeof config.checkpoint === "string" ? 0 : (config.checkpoint.offset ?? 0);
183
+ const fields = modelFields(readModel.model);
184
+ if (fields !== undefined && !fields.includes(field)) {
185
+ refuse(`the checkpoint field "${field}" is not a field of the read model.`);
186
+ }
187
+ if (!Number.isInteger(offset)) {
188
+ refuse(`the checkpoint offset ${offset} is not an integer.`);
189
+ }
190
+ const schedule = retrySchedule(config.retry);
191
+ const createdAtField = readModel.systemFields.createdAt;
192
+ /** A synchronous user callback, its throw this row's `ValidationError`. */
193
+ const call = (name, f) => Effect.try({
194
+ try: f,
195
+ catch: (cause) => new ValidationError({
196
+ entityType: readModel.entityType,
197
+ operation: `EventStore.projection.${name}`,
198
+ cause,
199
+ }),
200
+ });
201
+ /**
202
+ * The row, read strongly consistent — with its `Hidden` fields (the default
203
+ * decode drops them, and the whole-row `put` needs them back) and its
204
+ * `createdAt`; none when there is none.
205
+ */
206
+ const readRow = (key) => asItem(entityConsistentRead(readModel.get(key))).pipe(Effect.map((item) => {
207
+ let state = item;
208
+ if (fields !== undefined) {
209
+ state = {};
210
+ for (const f of fields)
211
+ if (item[f] !== undefined)
212
+ state[f] = item[f];
213
+ }
214
+ return Option.some({
215
+ state: (readModel._attachPrototype?.(state) ?? state),
216
+ createdAt: createdAtField === null ? undefined : item[createdAtField],
217
+ });
218
+ }), Effect.catchTag("ItemNotFound", () => Effect.succeed(Option.none())));
219
+ /** The stream version a row is at — its checkpoint, offset removed. */
220
+ const versionOf = (row) => Option.isSome(row) ? Number(row.value.state[field]) - offset : 0;
221
+ /**
222
+ * Write the folded state at `version`, conditioned on the row still being at
223
+ * the version read (`create` when there was no row), carrying its
224
+ * `createdAt` over. A failed condition is a lost race, which the caller's
225
+ * retry re-reads after.
226
+ */
227
+ const writeRow = (streamId, key, state, version, row) => Effect.gen(function* () {
228
+ const checkpointed = { ...state, [field]: version + offset };
229
+ const written = config.toItem === undefined
230
+ ? checkpointed
231
+ : yield* call("toItem", () => config.toItem(checkpointed, { streamId, key }));
232
+ const item = { ...written, [field]: version + offset };
233
+ if (Option.isNone(row)) {
234
+ return yield* readModel.create(item).asEffect().pipe(Effect.catchTag("ConditionalCheckFailed", () => Effect.fail(new LostRace(key))));
235
+ }
236
+ // A whole-row `put` stamps a new `createdAt` unless it is given the row's.
237
+ const op = entityCondition(readModel.put(item), {
238
+ eq: { [field]: versionOf(row) + offset },
239
+ });
240
+ const createdAt = row.value.createdAt;
241
+ return yield* op
242
+ ._builder("model", {
243
+ condition: op._condition,
244
+ withVectors: op._withVectors,
245
+ putKind: op._putKind,
246
+ createdAt: DateTime.isDateTime(createdAt) ? DateTime.toUtc(createdAt) : undefined,
247
+ })
248
+ .pipe(Effect.catchTag("ConditionalCheckFailed", () => Effect.fail(new LostRace(key))));
249
+ });
250
+ /**
251
+ * Every version from `from` to the last of `events` was delivered: `events`
252
+ * themselves, or `delivered` (the batch's other events of the stream, which
253
+ * are not this row's).
254
+ */
255
+ const coveredFrom = (events, from, delivered) => {
256
+ const own = new Set(events.map((event) => event.version));
257
+ const last = events[events.length - 1].version;
258
+ for (let version = from; version <= last; version++) {
259
+ if (!own.has(version) && !delivered.has(version))
260
+ return false;
261
+ }
262
+ return true;
263
+ };
264
+ /** `events` is exactly `from, from + 1, …`. */
265
+ const contiguousFrom = (events, from) => events.every((event, i) => event.version === from + i);
266
+ /** Retry lost races under the policy; once it is spent, `ConcurrentModification`. */
267
+ const withRetry = (attempt) => Effect.retry(attempt, {
268
+ schedule,
269
+ while: (error) => error instanceof LostRace,
270
+ }).pipe(Effect.mapError((error) => error instanceof LostRace
271
+ ? new ConcurrentModification({
272
+ entityType: readModel.entityType,
273
+ key: error.key,
274
+ attributes: [field],
275
+ current: Option.none(),
276
+ })
277
+ : error));
278
+ // Unreachable for a stream written by `append` (a contiguity-checked
279
+ // transaction): the store holds every version below a delivered one.
280
+ const gapError = (streamId, from, to) => Effect.fail(new ValidationError({
281
+ entityType: stream.eventMarker,
282
+ operation: "EventStore.projection",
283
+ cause: `Stream ${JSON.stringify(streamId)} is missing events between versions ${from} and ` +
284
+ `${to}: a strongly consistent read did not return them. Was the stream written ` +
285
+ "outside `append`?",
286
+ }));
287
+ /** A key's identity: its fields, sorted, as JSON. */
288
+ const keyId = (key) => JSON.stringify(Object.entries(key).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
289
+ /** The row an event addresses — none when `key` says it addresses none. */
290
+ const keyOf = (streamId, event) => call("key", () => {
291
+ const key = config.key(streamId, event);
292
+ return Option.isOption(key) ? key : Option.some(key);
293
+ });
294
+ /**
295
+ * Fold `events` onto `row` (or a new row), skipping the events `terminal`
296
+ * rejects. `state` stays none while no event has been applied to a new row.
297
+ */
298
+ const foldRow = (streamId, row, events) => Effect.gen(function* () {
299
+ let state = row;
300
+ const applied = [];
301
+ const rejected = [];
302
+ for (const event of events) {
303
+ const base = Option.isSome(state)
304
+ ? state.value
305
+ : yield* call("initial", () => config.initial(streamId, event));
306
+ const result = yield* Effect.result(config.evolve(base, event));
307
+ if (Result.isSuccess(result)) {
308
+ state = Option.some(result.success);
309
+ applied.push(event);
310
+ }
311
+ else if (config.terminal?.(result.failure) === true) {
312
+ rejected.push({ event, error: result.failure });
313
+ }
314
+ else {
315
+ return yield* Effect.fail(result.failure);
316
+ }
317
+ }
318
+ return { state, applied, rejected };
319
+ });
320
+ /**
321
+ * One row. `events` are the batch's events for it, sorted and deduplicated;
322
+ * `undefined` for a catch-up to `top`. `delivered` holds the versions of
323
+ * every event of the stream instance in the batch — this row's, other rows'
324
+ * and those addressing no row — so a run they cover needs no read.
325
+ */
326
+ const applyRow = (streamIdStr, streamId, key, events, top, delivered = new Set()) => {
327
+ const id = keyId(key);
328
+ const attempt = Effect.gen(function* () {
329
+ const row = yield* readRow(key);
330
+ const at = versionOf(row);
331
+ const alreadyApplied = events?.filter((e) => e.version <= at) ?? [];
332
+ const pending = events?.filter((e) => e.version > at);
333
+ const done = (rejected) => ({
334
+ streamId: streamIdStr,
335
+ key,
336
+ applied: [],
337
+ rejected,
338
+ alreadyApplied,
339
+ state: Option.map(row, (r) => r.state),
340
+ version: at,
341
+ });
342
+ if (pending !== undefined ? pending.length === 0 : at >= top)
343
+ return done([]);
344
+ let run;
345
+ if (pending !== undefined && coveredFrom(pending, at + 1, delivered)) {
346
+ run = pending;
347
+ }
348
+ else {
349
+ // Every version from the checkpoint to the highest one wanted — the
350
+ // other rows' events among them — must be there; only this row's are
351
+ // folded. The read stops at that version.
352
+ const upTo = pending === undefined ? top : pending[pending.length - 1].version;
353
+ const read = (yield* stream.readFrom(streamId, at, {
354
+ consistentRead: true,
355
+ toVersion: upTo,
356
+ }));
357
+ if (!contiguousFrom(read, at + 1) || read.length < upTo - at) {
358
+ return yield* gapError(streamIdStr, at + 1, upTo);
359
+ }
360
+ run = [];
361
+ for (const event of read) {
362
+ const key = yield* keyOf(streamId, event);
363
+ if (Option.isSome(key) && keyId(key.value) === id)
364
+ run.push(event);
365
+ }
366
+ }
367
+ if (run.length === 0)
368
+ return done([]);
369
+ const folded = yield* foldRow(streamId, Option.map(row, (r) => r.state), run);
370
+ // Nothing but rejections for a row that does not exist: write nothing.
371
+ if (Option.isNone(folded.state))
372
+ return done(folded.rejected);
373
+ const version = run[run.length - 1].version;
374
+ const state = yield* writeRow(streamId, key, folded.state.value, version, row);
375
+ return {
376
+ streamId: streamIdStr,
377
+ key,
378
+ applied: folded.applied,
379
+ rejected: folded.rejected,
380
+ alreadyApplied,
381
+ state: Option.some(state),
382
+ version,
383
+ };
384
+ });
385
+ return withRetry(attempt);
386
+ };
387
+ const applyBatch = (events, options) => Effect.gen(function* () {
388
+ // By stream instance, in order of first delivery.
389
+ const streams = new Map();
390
+ events.forEach((event, position) => {
391
+ const group = streams.get(event.streamId);
392
+ if (group === undefined)
393
+ streams.set(event.streamId, [[position, event]]);
394
+ else
395
+ group.push([position, event]);
396
+ });
397
+ /** Positions of the events that address no row. */
398
+ const ignored = [];
399
+ // Then by row; each row's task is ordered by its first delivered event.
400
+ const tasks = [];
401
+ for (const [streamIdStr, group] of streams) {
402
+ const positions = group.map(([position]) => position);
403
+ // A stream id that does not parse, or a `key` that throws, fails the
404
+ // whole stream instance: its events can't be placed in rows.
405
+ const rows = yield* Effect.result(Effect.gen(function* () {
406
+ const streamId = yield* stream.parseStreamId(streamIdStr);
407
+ const byRow = new Map();
408
+ for (const [position, event] of group) {
409
+ const addressed = yield* keyOf(streamId, event);
410
+ if (Option.isNone(addressed)) {
411
+ ignored.push(position);
412
+ continue;
413
+ }
414
+ const key = addressed.value;
415
+ const id = keyId(key);
416
+ const row = byRow.get(id);
417
+ if (row === undefined)
418
+ byRow.set(id, { key, positions: [position], events: [event] });
419
+ else {
420
+ row.positions.push(position);
421
+ row.events.push(event);
422
+ }
423
+ }
424
+ return { streamId, rows: [...byRow.values()] };
425
+ }));
426
+ if (Result.isFailure(rows)) {
427
+ const error = rows.failure;
428
+ tasks.push({
429
+ first: positions[0],
430
+ run: Effect.succeed({
431
+ _tag: "Failed",
432
+ streamId: streamIdStr,
433
+ key: undefined,
434
+ positions,
435
+ error,
436
+ }),
437
+ });
438
+ continue;
439
+ }
440
+ const { streamId } = rows.success;
441
+ const delivered = new Set(group.map(([, event]) => event.version));
442
+ for (const row of rows.success.rows) {
443
+ // Sorted by version, one event per version (a redelivery is a duplicate).
444
+ const ordered = [...row.events]
445
+ .sort((a, b) => a.version - b.version)
446
+ .filter((e, i, sorted) => i === 0 || sorted[i - 1].version !== e.version);
447
+ tasks.push({
448
+ first: row.positions[0],
449
+ run: Effect.result(applyRow(streamIdStr, streamId, row.key, ordered, 0, delivered)).pipe(Effect.map((result) => Result.isSuccess(result)
450
+ ? { _tag: "Applied", positions: row.positions, ...result.success }
451
+ : {
452
+ _tag: "Failed",
453
+ streamId: streamIdStr,
454
+ key: row.key,
455
+ positions: row.positions,
456
+ error: result.failure,
457
+ })),
458
+ });
459
+ }
460
+ }
461
+ tasks.sort((a, b) => a.first - b.first);
462
+ const outcomes = yield* Effect.forEach(tasks, (task) => task.run, {
463
+ concurrency: options?.concurrency ?? 10,
464
+ });
465
+ return {
466
+ outcomes,
467
+ failedPositions: outcomes
468
+ .filter((o) => o._tag === "Failed")
469
+ .map((o) => o.positions[0])
470
+ .sort((a, b) => a - b),
471
+ ignored: ignored.sort((a, b) => a - b),
472
+ };
473
+ });
474
+ /**
475
+ * `applyBatch([event])`, the row's failure in the error channel — wrapped in
476
+ * an `Option` when `key` returns one (none: the event addresses no row).
477
+ */
478
+ const apply = (event) => Effect.gen(function* () {
479
+ const streamId = yield* stream.parseStreamId(event.streamId);
480
+ const key = yield* call("key", () => config.key(streamId, event));
481
+ const optional = Option.isOption(key);
482
+ if (optional && Option.isNone(key))
483
+ return Option.none();
484
+ const { outcomes } = yield* applyBatch([event]);
485
+ const outcome = outcomes[0];
486
+ if (outcome._tag === "Failed")
487
+ return yield* Effect.fail(outcome.error);
488
+ const { _tag, positions, ...applied } = outcome;
489
+ return optional ? Option.some(applied) : applied;
490
+ });
491
+ const joinStreamId = (streamId) => stream.streamIdFields.map((f) => streamId[f]).join("#");
492
+ const catchUp = (streamId) => Effect.gen(function* () {
493
+ // The latest event that addresses a row — usually the head, one page.
494
+ const pages = yield* Query.paginate(stream.query
495
+ .events(streamId)
496
+ .pipe(Query.reverse, Query.pageSize(25), (q) => Query.consistentRead(q)));
497
+ const latest = yield* pages.pipe(Stream.flatMap((page) => Stream.fromIterable(page)), Stream.mapEffect((event) => keyOf(streamId, event).pipe(Effect.map((key) => Option.map(key, (k) => [event, k])))), Stream.filter((found) => Option.isSome(found)), Stream.map((found) => Option.getOrThrow(found)), Stream.runHead);
498
+ if (Option.isNone(latest)) {
499
+ return { key: Option.none(), applied: [], rejected: [], state: Option.none(), version: 0 };
500
+ }
501
+ const [head, key] = latest.value;
502
+ const result = yield* applyRow(joinStreamId(streamId), streamId, key, undefined, head.version);
503
+ return {
504
+ key: Option.some(key),
505
+ applied: result.applied,
506
+ rejected: result.rejected,
507
+ state: result.state,
508
+ version: result.version,
509
+ };
510
+ });
511
+ const rebuild = (streamId) => Effect.gen(function* () {
512
+ const events = (yield* stream.read(streamId, { consistentRead: true }));
513
+ const rows = new Map();
514
+ for (const event of events) {
515
+ const addressed = yield* keyOf(streamId, event);
516
+ if (Option.isNone(addressed))
517
+ continue;
518
+ const key = addressed.value;
519
+ const id = keyId(key);
520
+ const row = rows.get(id);
521
+ if (row === undefined)
522
+ rows.set(id, { key, events: [event] });
523
+ else
524
+ row.events.push(event);
525
+ }
526
+ const rebuilt = yield* Effect.forEach([...rows.values()], ({ key, events: rowEvents }) => withRetry(Effect.gen(function* () {
527
+ const row = yield* readRow(key);
528
+ // From `initial`: the row's current state is ignored.
529
+ const folded = yield* foldRow(streamId, Option.none(), rowEvents);
530
+ if (Option.isNone(folded.state)) {
531
+ return {
532
+ key,
533
+ state: Option.map(row, (r) => r.state),
534
+ version: versionOf(row),
535
+ rejected: folded.rejected,
536
+ };
537
+ }
538
+ const version = rowEvents[rowEvents.length - 1].version;
539
+ const state = yield* writeRow(streamId, key, folded.state.value, version, row);
540
+ return { key, state: Option.some(state), version, rejected: folded.rejected };
541
+ })), { concurrency: 10 });
542
+ return { rows: rebuilt };
543
+ });
544
+ const processBatch = (records, options) => runBatch(`${label}.processBatch`, records, options, [
545
+ {
546
+ name: "projection",
547
+ stream: stream,
548
+ decode: (image) => stream.decodeStreamImage(image),
549
+ applyBatch,
550
+ },
551
+ ]).pipe(Effect.map(({ outcomes, unrouted: _, ...rest }) => ({
552
+ ...rest,
553
+ outcomes: outcomes.projection,
554
+ })));
555
+ return {
556
+ [EventProjectionTypeId]: EventProjectionTypeId,
557
+ pipe() {
558
+ // eslint-disable-next-line prefer-rest-params
559
+ return Pipeable.pipeArguments(this, arguments);
560
+ },
561
+ stream,
562
+ entityType: readModel.entityType,
563
+ [ProjectionDecode]: (image) => stream.decodeStreamImage(image),
564
+ applyBatch,
565
+ apply,
566
+ catchUp,
567
+ rebuild,
568
+ processBatch,
569
+ };
570
+ };
571
+ /** The default `image` accessor: the `NewImage` of an `INSERT`. */
572
+ const insertImage = (record) => record.eventName !== undefined && record.eventName !== "INSERT"
573
+ ? undefined
574
+ : record.dynamodb?.NewImage;
575
+ /**
576
+ * @internal Decode a batch once, route each event to the projections of its
577
+ * stream, apply each projection's events, and build one partial batch
578
+ * response — shared by {@link processBatch} and `projection.processBatch`.
579
+ */
580
+ const runBatch = (operation, records, options, routes,
581
+ /** Streams decoded without a projection: their events are returned as `unrouted`. */
582
+ decodeOnly = []) => {
583
+ return Effect.gen(function* () {
584
+ // One decode per stream, however many projections read it.
585
+ const streams = new Map();
586
+ for (const route of routes) {
587
+ const readers = streams.get(route.stream);
588
+ if (readers === undefined)
589
+ streams.set(route.stream, [route]);
590
+ else
591
+ readers.push(route);
592
+ }
593
+ const unroutedStreams = decodeOnly.filter(([, stream]) => !streams.has(stream));
594
+ /** A marker's stream: the projections that read it, or its name when it has none. */
595
+ const byMarker = markerIndex(operation, [
596
+ ...[...streams.keys()].map((stream) => [stream.streamName, stream]),
597
+ ...unroutedStreams,
598
+ ]);
599
+ const unroutedNames = new Map(unroutedStreams.map(([name, stream]) => [stream, name]));
600
+ const unrouted = [];
601
+ const image = options.image ?? insertImage;
602
+ /** Each projection's events, and the record each came from. */
603
+ const routed = new Map(routes.map((route) => [route.name, { events: [], recordOf: [] }]));
604
+ const decodeFailures = [];
605
+ const failed = new Set();
606
+ for (const [i, record] of records.entries()) {
607
+ const recordImage = image(record);
608
+ if (recordImage === undefined)
609
+ continue;
610
+ const entry = typeof recordImage === "object" && recordImage !== null && !Array.isArray(recordImage)
611
+ ? byMarker.get(imageEntityType(recordImage) ?? "")
612
+ : undefined;
613
+ // Not an event of any stream named: nothing to apply.
614
+ if (entry === undefined)
615
+ continue;
616
+ const readers = streams.get(entry[1]);
617
+ const decoded = yield* Effect.result(readers === undefined
618
+ ? entry[1].decodeStreamImage(recordImage)
619
+ : readers[0].decode(recordImage));
620
+ if (Result.isFailure(decoded)) {
621
+ decodeFailures.push({ record, error: decoded.failure });
622
+ if (options.onDecodeFailure !== "skip")
623
+ failed.add(i);
624
+ }
625
+ else if (Option.isSome(decoded.success) && readers === undefined) {
626
+ unrouted.push({
627
+ position: i,
628
+ stream: unroutedNames.get(entry[1]),
629
+ event: decoded.success.value,
630
+ });
631
+ }
632
+ else if (Option.isSome(decoded.success)) {
633
+ for (const route of readers) {
634
+ const target = routed.get(route.name);
635
+ target.events.push(decoded.success.value);
636
+ target.recordOf.push(i);
637
+ }
638
+ }
639
+ }
640
+ const outcomes = {};
641
+ for (const route of routes) {
642
+ const { events, recordOf } = routed.get(route.name);
643
+ if (events.length === 0) {
644
+ outcomes[route.name] = [];
645
+ continue;
646
+ }
647
+ const batch = yield* route.applyBatch(events, options.concurrency === undefined ? undefined : { concurrency: options.concurrency });
648
+ for (const position of batch.failedPositions)
649
+ failed.add(recordOf[position]);
650
+ // Positions index the records, not the decoded events.
651
+ outcomes[route.name] = batch.outcomes.map((outcome) => ({
652
+ ...outcome,
653
+ positions: outcome.positions.map((position) => recordOf[position]),
654
+ }));
655
+ }
656
+ const failedRecords = [...failed].sort((a, b) => a - b).map((i) => records[i]);
657
+ const response = yield* batchResponse(failedRecords, ("identifier" in options && options.identifier !== undefined
658
+ ? { identifier: options.identifier }
659
+ : { source: options.source }));
660
+ return { unrouted, outcomes, decodeFailures, failedRecords, response };
661
+ });
662
+ };
663
+ /**
664
+ * Process one change-feed batch for several projections — the usual shape
665
+ * when one table's feed carries several streams. Each record is decoded once
666
+ * and routed to every projection of its stream; records of other streams and
667
+ * items are skipped, as acknowledged. Each projection applies its events
668
+ * (see {@link EventProjection.applyBatch}), and one partial batch response
669
+ * names the earliest record of every failed row across all of them, plus the
670
+ * records that failed to decode (unless `onDecodeFailure: "skip"`). Outcome
671
+ * `positions` index `records`.
672
+ *
673
+ * Records are read as `StreamRecord`s, identified per `source`, or through
674
+ * `image` / `identifier` accessors for a shape of your own (a Pipe input
675
+ * template's). A failed record without an identifier fails the call with
676
+ * `StreamRecordIdentifierMissing`. `streams` names streams to decode without a
677
+ * projection: their events come back as `unrouted`. Two streams storing
678
+ * events under one marker die with `[EDD-9082]`.
679
+ *
680
+ * @example
681
+ * ```ts
682
+ * const result = yield* EventStore.processBatch(event.Records, {
683
+ * projections: { summaries: OrderSummaryProjection, balances: BalanceProjection },
684
+ * source: "lambda",
685
+ * })
686
+ * return result.response
687
+ * ```
688
+ */
689
+ export const processBatch = (records, options) => runBatch("EventStore.processBatch", records, options, Object.entries(options.projections).map(([name, projection]) => ({
690
+ name,
691
+ stream: projection.stream,
692
+ decode: (image) => projection[ProjectionDecode](image),
693
+ applyBatch: (events, applyOptions) => projection.applyBatch(events, applyOptions),
694
+ })), Object.entries((options.streams ?? {})));
695
+ //# sourceMappingURL=EventProjection.js.map