effect-dynamodb 1.27.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.
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +4 -2
- package/dist/Entity.js.map +1 -1
- package/dist/EventStore.d.ts +60 -5
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +77 -2
- package/dist/EventStore.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/EntityOps.d.ts +6 -1
- package/dist/internal/EntityOps.d.ts.map +1 -1
- package/dist/internal/EntityOps.js.map +1 -1
- package/dist/internal/EventProjection.d.ts +597 -0
- package/dist/internal/EventProjection.d.ts.map +1 -0
- package/dist/internal/EventProjection.js +695 -0
- package/dist/internal/EventProjection.js.map +1 -0
- package/package.json +2 -2
|
@@ -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
|