effect-dynamodb 1.28.0 → 1.29.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 +212 -27
  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 +837 -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
@@ -14,11 +14,17 @@
14
14
  *
15
15
  * @internal
16
16
  */
17
- import { ConcurrentModification, StreamRecordIdentifierMissing, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
18
- import { DateTime, Effect, Option, Pipeable, Result, Schedule, Stream } from "effect";
17
+ import { StreamRecordIdentifierMissing, } from "@effect-dynamodb/schema/Errors.js";
18
+ import { Duration, Effect, Option, Pipeable, Result, Stream, } from "effect";
19
19
  import * as Query from "../Query.js";
20
- import { asItem, condition as entityCondition, consistentRead as entityConsistentRead, } from "./EntityCombinators.js";
21
20
  import { imageEntityType } from "./Packed.js";
21
+ import { addressed, keyId, makeEngine, modelFields, retrySchedule, } from "./ProjectionEngine.js";
22
+ /** @internal `revision`, resolved. */
23
+ export const resolveRevision = (revision) => revision === undefined
24
+ ? undefined
25
+ : typeof revision === "string"
26
+ ? { field: revision, initial: 1 }
27
+ : { field: revision.field, initial: revision.initial ?? 1 };
22
28
  const identify = (record, index, options) => {
23
29
  const [itemIdentifier, member] = "identifier" in options
24
30
  ? [options.identifier(record), "identifier"]
@@ -103,41 +109,56 @@ export const eventFilterPattern = (streams) => {
103
109
  // EventProjection
104
110
  // ---------------------------------------------------------------------------
105
111
  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
112
  /**
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.
113
+ * @internal How a projection takes part in a multi-projection batch: one
114
+ * route per source stream — its decode (under the bound context for a bound
115
+ * projection) and its `applyBatch`.
114
116
  */
115
- class LostRace {
116
- key;
117
- constructor(key) {
118
- this.key = key;
117
+ export const ProjectionRoutes = Symbol.for("effect-dynamodb/EventProjection/Routes");
118
+ /** @internal Refusals shared by `projection` and `multiProjection` (`[EDD-9081]`). */
119
+ export const refuseReadModel = (label, readModel, options) => {
120
+ const refuse = (problem) => {
121
+ throw new Error(`[EDD-9081] ${label}: ${problem}`);
122
+ };
123
+ if (readModel.versioned) {
124
+ refuse("the read model is `versioned`. The projection's checkpoint is the row's version, and a " +
125
+ "versioned put adds its own lock — use a plain entity with a numeric checkpoint field " +
126
+ "(and `revision` for a write counter).");
127
+ }
128
+ if (readModel.softDelete) {
129
+ refuse("the read model has `softDelete`; projections remove rows with `removal` tombstones.");
130
+ }
131
+ if (readModel.timeSeries) {
132
+ refuse("the read model is a `timeSeries` entity, written by `append`, not `put`.");
133
+ }
134
+ const declared = modelFields(readModel.model);
135
+ for (const f of options.fields) {
136
+ if (declared !== undefined && !declared.includes(f)) {
137
+ refuse(`"${f}" is not a field of the read model.`);
138
+ }
139
+ }
140
+ const multiItem = readModel
141
+ ._multiItemWriteFeatures;
142
+ if ((options.removal || options.outbox) && multiItem !== undefined && multiItem.length > 0) {
143
+ refuse(`\`${options.removal ? "removal" : "outbox"}\` writes the row's item itself, which a read ` +
144
+ `model with ${multiItem.join(", ")} can't take (its extra items would not be maintained).`);
145
+ }
146
+ if (options.outbox && options.revision === undefined) {
147
+ refuse("`outbox` orders a row's effects by its `revision`: declare one.");
119
148
  }
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
149
  };
150
+ /** @internal An outbox config, resolved. */
151
+ export const resolveOutbox = (outbox) => outbox === undefined
152
+ ? undefined
153
+ : {
154
+ schema: outbox.schema,
155
+ effects: outbox.effects,
156
+ table: outbox.table,
157
+ pk: outbox.keys?.pk ?? "pk",
158
+ sk: outbox.keys?.sk ?? "sk",
159
+ index: outbox.index,
160
+ claimFor: Duration.fromInputUnsafe(outbox.claimFor ?? "30 seconds"),
161
+ };
141
162
  /**
142
163
  * Define a change-feed projection of `stream` into `readModel` — rows keyed by
143
164
  * `key(streamId, event)` (one per stream instance when the key ignores the
@@ -146,9 +167,10 @@ const modelFields = (model) => {
146
167
  * operations.
147
168
  *
148
169
  * 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.
170
+ * version), `softDelete` (removal is `removal` tombstones) and `timeSeries`
171
+ * read models throw `[EDD-9081]`, as do a field the model lacks, a
172
+ * non-integer offset, `removal` / `outbox` on a read model with `unique`
173
+ * constraints, and `outbox` without `revision`.
152
174
  *
153
175
  * @example
154
176
  * ```ts
@@ -165,225 +187,69 @@ const modelFields = (model) => {
165
187
  export const projection = (config) => {
166
188
  const { stream, readModel } = config;
167
189
  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
190
  const field = (typeof config.checkpoint === "string" ? config.checkpoint : config.checkpoint.field);
182
191
  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
- }
192
+ const revision = resolveRevision(config.revision);
193
+ refuseReadModel(label, readModel, {
194
+ fields: [field, ...(revision === undefined ? [] : [revision.field])],
195
+ removal: config.removal !== undefined,
196
+ outbox: config.outbox !== undefined,
197
+ revision: revision?.field,
198
+ });
187
199
  if (!Number.isInteger(offset)) {
188
- refuse(`the checkpoint offset ${offset} is not an integer.`);
200
+ throw new Error(`[EDD-9081] ${label}: the checkpoint offset ${offset} is not an integer.`);
189
201
  }
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;
202
+ const source = {
203
+ name: "projection",
204
+ stream: stream,
205
+ key: config.key,
206
+ evolve: (state, event) => config.evolve(state, event),
207
+ mode: "fold",
208
+ terminal: config.terminal,
263
209
  };
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);
210
+ const engine = makeEngine({
211
+ label,
212
+ readModel: readModel,
213
+ sources: [source],
214
+ initial: (_key, context) => config.initial(context.streamId, context.event),
215
+ checkpoints: { kind: "scalar", field, offset },
216
+ revision,
217
+ toItem: config.toItem,
218
+ toItemContext: (key, work) => ({ streamId: work[0]?.streamId, key }),
219
+ retry: retrySchedule(config.retry),
220
+ removal: config.removal === undefined
221
+ ? undefined
222
+ : {
223
+ ttl: config.removal.ttl === undefined
224
+ ? undefined
225
+ : Duration.fromInputUnsafe(config.removal.ttl),
226
+ },
227
+ outbox: resolveOutbox(config.outbox),
293
228
  });
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);
229
+ const keyOf = (streamId, event) => engine.call("key", () => addressed(config.key(streamId, event)));
230
+ /** A row's engine result, as this projection reports it. */
231
+ const appliedOf = (streamIdStr, result) => {
232
+ const instance = result.instances[0];
233
+ return {
234
+ streamId: streamIdStr,
235
+ key: result.key,
236
+ applied: (instance?.applied ?? []),
237
+ rejected: (instance?.rejected ?? []),
238
+ alreadyApplied: (instance?.alreadyApplied ?? []),
239
+ state: result.state,
240
+ version: instance?.version ?? 0,
241
+ removed: result.removed,
242
+ enqueued: result.enqueued,
243
+ undelivered: Option.none(),
244
+ };
386
245
  };
246
+ /** After a write with effects, drain the row when the caller passed `deliver`. */
247
+ const deliverAfter = (applied, deliver) => deliver === undefined || config.outbox === undefined || applied.enqueued === 0
248
+ ? Effect.succeed(applied)
249
+ : engine.drain(applied.key, deliver).pipe(Effect.map((drained) => ({
250
+ ...applied,
251
+ undelivered: drained.undelivered,
252
+ })));
387
253
  const applyBatch = (events, options) => Effect.gen(function* () {
388
254
  // By stream instance, in order of first delivery.
389
255
  const streams = new Map();
@@ -403,19 +269,19 @@ export const projection = (config) => {
403
269
  // A stream id that does not parse, or a `key` that throws, fails the
404
270
  // whole stream instance: its events can't be placed in rows.
405
271
  const rows = yield* Effect.result(Effect.gen(function* () {
406
- const streamId = yield* stream.parseStreamId(streamIdStr);
272
+ const streamId = (yield* stream.parseStreamId(streamIdStr));
407
273
  const byRow = new Map();
408
274
  for (const [position, event] of group) {
409
- const addressed = yield* keyOf(streamId, event);
410
- if (Option.isNone(addressed)) {
275
+ const key = yield* keyOf(streamId, event);
276
+ if (Option.isNone(key)) {
411
277
  ignored.push(position);
412
278
  continue;
413
279
  }
414
- const key = addressed.value;
415
- const id = keyId(key);
280
+ const id = keyId(key.value);
416
281
  const row = byRow.get(id);
417
- if (row === undefined)
418
- byRow.set(id, { key, positions: [position], events: [event] });
282
+ if (row === undefined) {
283
+ byRow.set(id, { key: key.value, positions: [position], events: [event] });
284
+ }
419
285
  else {
420
286
  row.positions.push(position);
421
287
  row.events.push(event);
@@ -444,9 +310,17 @@ export const projection = (config) => {
444
310
  const ordered = [...row.events]
445
311
  .sort((a, b) => a.version - b.version)
446
312
  .filter((e, i, sorted) => i === 0 || sorted[i - 1].version !== e.version);
313
+ const work = {
314
+ source,
315
+ streamIdStr,
316
+ streamId,
317
+ events: ordered,
318
+ top: 0,
319
+ delivered,
320
+ };
447
321
  tasks.push({
448
322
  first: row.positions[0],
449
- run: Effect.result(applyRow(streamIdStr, streamId, row.key, ordered, 0, delivered)).pipe(Effect.map((result) => Result.isSuccess(result)
323
+ run: Effect.result(engine.applyRow(row.key, [work]).pipe(Effect.flatMap((result) => deliverAfter(appliedOf(streamIdStr, result), options?.deliver)))).pipe(Effect.map((result) => Result.isSuccess(result)
450
324
  ? { _tag: "Applied", positions: row.positions, ...result.success }
451
325
  : {
452
326
  _tag: "Failed",
@@ -476,8 +350,8 @@ export const projection = (config) => {
476
350
  * an `Option` when `key` returns one (none: the event addresses no row).
477
351
  */
478
352
  const apply = (event) => Effect.gen(function* () {
479
- const streamId = yield* stream.parseStreamId(event.streamId);
480
- const key = yield* call("key", () => config.key(streamId, event));
353
+ const streamId = (yield* stream.parseStreamId(event.streamId));
354
+ const key = yield* engine.call("key", () => config.key(streamId, event));
481
355
  const optional = Option.isOption(key);
482
356
  if (optional && Option.isNone(key))
483
357
  return Option.none();
@@ -496,48 +370,69 @@ export const projection = (config) => {
496
370
  .pipe(Query.reverse, Query.pageSize(25), (q) => Query.consistentRead(q)));
497
371
  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
372
  if (Option.isNone(latest)) {
499
- return { key: Option.none(), applied: [], rejected: [], state: Option.none(), version: 0 };
373
+ return {
374
+ key: Option.none(),
375
+ applied: [],
376
+ rejected: [],
377
+ state: Option.none(),
378
+ version: 0,
379
+ removed: false,
380
+ };
500
381
  }
501
382
  const [head, key] = latest.value;
502
- const result = yield* applyRow(joinStreamId(streamId), streamId, key, undefined, head.version);
383
+ const result = (yield* engine.applyRow(key, [
384
+ {
385
+ source,
386
+ streamIdStr: joinStreamId(streamId),
387
+ streamId,
388
+ events: undefined,
389
+ top: head.version,
390
+ delivered: new Set(),
391
+ },
392
+ ]));
393
+ const applied = appliedOf(joinStreamId(streamId), result);
503
394
  return {
504
395
  key: Option.some(key),
505
- applied: result.applied,
506
- rejected: result.rejected,
507
- state: result.state,
508
- version: result.version,
396
+ applied: applied.applied,
397
+ rejected: applied.rejected,
398
+ state: applied.state,
399
+ version: applied.version,
400
+ removed: applied.removed,
509
401
  };
510
402
  });
511
403
  const rebuild = (streamId) => Effect.gen(function* () {
512
404
  const events = (yield* stream.read(streamId, { consistentRead: true }));
405
+ const delivered = new Set(events.map((e) => e.version));
513
406
  const rows = new Map();
514
407
  for (const event of events) {
515
- const addressed = yield* keyOf(streamId, event);
516
- if (Option.isNone(addressed))
408
+ const key = yield* keyOf(streamId, event);
409
+ if (Option.isNone(key))
517
410
  continue;
518
- const key = addressed.value;
519
- const id = keyId(key);
411
+ const id = keyId(key.value);
520
412
  const row = rows.get(id);
521
413
  if (row === undefined)
522
- rows.set(id, { key, events: [event] });
414
+ rows.set(id, { key: key.value, events: [event] });
523
415
  else
524
416
  row.events.push(event);
525
417
  }
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 };
418
+ const rebuilt = yield* Effect.forEach([...rows.values()], ({ key, events: rowEvents }) => engine.applyRow(key, [
419
+ {
420
+ source,
421
+ streamIdStr: joinStreamId(streamId),
422
+ streamId,
423
+ events: rowEvents,
424
+ top: rowEvents[rowEvents.length - 1].version,
425
+ delivered,
426
+ },
427
+ ], { fromScratch: true }).pipe(Effect.map((result) => {
428
+ const applied = appliedOf(joinStreamId(streamId), result);
429
+ return {
430
+ key,
431
+ state: applied.state,
432
+ version: applied.version,
433
+ rejected: applied.rejected,
434
+ removed: applied.removed,
435
+ };
541
436
  })), { concurrency: 10 });
542
437
  return { rows: rebuilt };
543
438
  });
@@ -546,12 +441,13 @@ export const projection = (config) => {
546
441
  name: "projection",
547
442
  stream: stream,
548
443
  decode: (image) => stream.decodeStreamImage(image),
549
- applyBatch,
444
+ applyBatch: (events, applyOptions) => applyBatch(events, applyOptions),
550
445
  },
551
446
  ]).pipe(Effect.map(({ outcomes, unrouted: _, ...rest }) => ({
552
447
  ...rest,
553
448
  outcomes: outcomes.projection,
554
449
  })));
450
+ const outboxRequired = (operation) => Effect.die(new Error(`[EDD-9081] ${label}.${operation}: the projection declares no \`outbox\`.`));
555
451
  return {
556
452
  [EventProjectionTypeId]: EventProjectionTypeId,
557
453
  pipe() {
@@ -560,12 +456,21 @@ export const projection = (config) => {
560
456
  },
561
457
  stream,
562
458
  entityType: readModel.entityType,
563
- [ProjectionDecode]: (image) => stream.decodeStreamImage(image),
459
+ [ProjectionRoutes]: (name) => [
460
+ {
461
+ name,
462
+ stream: stream,
463
+ decode: (image) => stream.decodeStreamImage(image),
464
+ applyBatch: (events, applyOptions) => applyBatch(events, applyOptions),
465
+ },
466
+ ],
564
467
  applyBatch,
565
468
  apply,
566
469
  catchUp,
567
470
  rebuild,
568
471
  processBatch,
472
+ drain: (key, deliver) => config.outbox === undefined ? outboxRequired("drain") : engine.drain(key, deliver),
473
+ drainPending: (deliver) => config.outbox === undefined ? outboxRequired("drainPending") : engine.drainPending(deliver),
569
474
  };
570
475
  };
571
476
  /** The default `image` accessor: the `NewImage` of an `INSERT`. */
@@ -577,7 +482,7 @@ const insertImage = (record) => record.eventName !== undefined && record.eventNa
577
482
  * stream, apply each projection's events, and build one partial batch
578
483
  * response — shared by {@link processBatch} and `projection.processBatch`.
579
484
  */
580
- const runBatch = (operation, records, options, routes,
485
+ export const runBatch = (operation, records, options, routes,
581
486
  /** Streams decoded without a projection: their events are returned as `unrouted`. */
582
487
  decodeOnly = []) => {
583
488
  return Effect.gen(function* () {
@@ -590,13 +495,22 @@ decodeOnly = []) => {
590
495
  else
591
496
  readers.push(route);
592
497
  }
593
- const unroutedStreams = decodeOnly.filter(([, stream]) => !streams.has(stream));
498
+ // `streams` may name a stream a projection reads too: it is decoded once,
499
+ // applied, and returned as `unrouted` as well.
500
+ // The same stream bound or unbound shares its event schema; a different
501
+ // stream with the same marker stays a conflict (`[EDD-9082]`).
502
+ const schemaOf = (stream) => stream.eventSchema;
503
+ const routedSchemas = new Map([...streams.keys()].map((stream) => [stream.eventMarker, schemaOf(stream)]));
504
+ const decodeOnlyStreams = decodeOnly.filter(([, stream]) => !streams.has(stream) &&
505
+ !(routedSchemas.has(stream.eventMarker) &&
506
+ routedSchemas.get(stream.eventMarker) === schemaOf(stream)));
594
507
  /** A marker's stream: the projections that read it, or its name when it has none. */
595
508
  const byMarker = markerIndex(operation, [
596
509
  ...[...streams.keys()].map((stream) => [stream.streamName, stream]),
597
- ...unroutedStreams,
510
+ ...decodeOnlyStreams,
598
511
  ]);
599
- const unroutedNames = new Map(unroutedStreams.map(([name, stream]) => [stream, name]));
512
+ /** The names `streams` gives, by event marker. */
513
+ const unroutedNames = new Map(decodeOnly.map(([name, stream]) => [stream.eventMarker, name]));
600
514
  const unrouted = [];
601
515
  const image = options.image ?? insertImage;
602
516
  /** Each projection's events, and the record each came from. */
@@ -622,29 +536,33 @@ decodeOnly = []) => {
622
536
  if (options.onDecodeFailure !== "skip")
623
537
  failed.add(i);
624
538
  }
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
539
  else if (Option.isSome(decoded.success)) {
633
- for (const route of readers) {
540
+ const name = unroutedNames.get(entry[1].eventMarker);
541
+ if (name !== undefined) {
542
+ unrouted.push({ position: i, stream: name, event: decoded.success.value });
543
+ }
544
+ for (const route of readers ?? []) {
634
545
  const target = routed.get(route.name);
635
- target.events.push(decoded.success.value);
546
+ target.events.push(route.tag === undefined
547
+ ? decoded.success.value
548
+ : { stream: route.tag, event: decoded.success.value });
636
549
  target.recordOf.push(i);
637
550
  }
638
551
  }
639
552
  }
640
553
  const outcomes = {};
641
- for (const route of routes) {
554
+ // One `applyBatch` per projection, however many of its sources routed here.
555
+ const projections = [...new Map(routes.map((route) => [route.name, route])).values()];
556
+ for (const route of projections) {
642
557
  const { events, recordOf } = routed.get(route.name);
643
558
  if (events.length === 0) {
644
559
  outcomes[route.name] = [];
645
560
  continue;
646
561
  }
647
- const batch = yield* route.applyBatch(events, options.concurrency === undefined ? undefined : { concurrency: options.concurrency });
562
+ const batch = yield* route.applyBatch(events, {
563
+ ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }),
564
+ ...(options.deliver === undefined ? {} : { deliver: options.deliver }),
565
+ });
648
566
  for (const position of batch.failedPositions)
649
567
  failed.add(recordOf[position]);
650
568
  // Positions index the records, not the decoded events.
@@ -686,10 +604,5 @@ decodeOnly = []) => {
686
604
  * return result.response
687
605
  * ```
688
606
  */
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 ?? {})));
607
+ export const processBatch = (records, options) => runBatch("EventStore.processBatch", records, options, Object.entries(options.projections).flatMap(([name, projection]) => projection[ProjectionRoutes](name)), Object.entries((options.streams ?? {})));
695
608
  //# sourceMappingURL=EventProjection.js.map