effect-dynamodb 1.28.0 → 1.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Batch.d.ts.map +1 -1
- package/dist/Batch.js +3 -2
- package/dist/Batch.js.map +1 -1
- package/dist/Entity.d.ts +24 -1
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +29 -15
- package/dist/Entity.js.map +1 -1
- package/dist/EventStore.d.ts +8 -2
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +35 -12
- package/dist/EventStore.js.map +1 -1
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +5 -2
- package/dist/Transaction.js.map +1 -1
- package/dist/change-feed/ChangeFeed.d.ts +203 -0
- package/dist/change-feed/ChangeFeed.d.ts.map +1 -0
- package/dist/change-feed/ChangeFeed.js +498 -0
- package/dist/change-feed/ChangeFeed.js.map +1 -0
- package/dist/change-feed/CheckpointStore.d.ts +113 -0
- package/dist/change-feed/CheckpointStore.d.ts.map +1 -0
- package/dist/change-feed/CheckpointStore.js +247 -0
- package/dist/change-feed/CheckpointStore.js.map +1 -0
- package/dist/change-feed/DynamoStreamsClient.d.ts +71 -0
- package/dist/change-feed/DynamoStreamsClient.d.ts.map +1 -0
- package/dist/change-feed/DynamoStreamsClient.js +92 -0
- package/dist/change-feed/DynamoStreamsClient.js.map +1 -0
- package/dist/change-feed/Errors.d.ts +84 -0
- package/dist/change-feed/Errors.d.ts.map +1 -0
- package/dist/change-feed/Errors.js +40 -0
- package/dist/change-feed/Errors.js.map +1 -0
- package/dist/change-feed/index.d.ts +36 -0
- package/dist/change-feed/index.d.ts.map +1 -0
- package/dist/change-feed/index.js +36 -0
- package/dist/change-feed/index.js.map +1 -0
- 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 +1 -1
- package/dist/internal/EntityOps.d.ts.map +1 -1
- package/dist/internal/EntityOps.js.map +1 -1
- package/dist/internal/EventProjection.d.ts +220 -29
- package/dist/internal/EventProjection.d.ts.map +1 -1
- package/dist/internal/EventProjection.js +221 -308
- package/dist/internal/EventProjection.js.map +1 -1
- package/dist/internal/MultiProjection.d.ts +255 -0
- package/dist/internal/MultiProjection.d.ts.map +1 -0
- package/dist/internal/MultiProjection.js +377 -0
- package/dist/internal/MultiProjection.js.map +1 -0
- package/dist/internal/ProjectionEngine.d.ts +253 -0
- package/dist/internal/ProjectionEngine.d.ts.map +1 -0
- package/dist/internal/ProjectionEngine.js +850 -0
- package/dist/internal/ProjectionEngine.js.map +1 -0
- package/dist/internal/TransactWriteOps.d.ts.map +1 -1
- package/dist/internal/TransactWriteOps.js +12 -3
- package/dist/internal/TransactWriteOps.js.map +1 -1
- package/dist/internal/TransactableOps.d.ts +44 -1
- package/dist/internal/TransactableOps.d.ts.map +1 -1
- package/dist/internal/TransactableOps.js +52 -2
- package/dist/internal/TransactableOps.js.map +1 -1
- package/package.json +14 -3
|
@@ -14,11 +14,17 @@
|
|
|
14
14
|
*
|
|
15
15
|
* @internal
|
|
16
16
|
*/
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
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
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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` (
|
|
150
|
-
* models throw `[EDD-9081]`, as do a
|
|
151
|
-
*
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
200
|
+
throw new Error(`[EDD-9081] ${label}: the checkpoint offset ${offset} is not an integer.`);
|
|
189
201
|
}
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
410
|
-
if (Option.isNone(
|
|
275
|
+
const key = yield* keyOf(streamId, event);
|
|
276
|
+
if (Option.isNone(key)) {
|
|
411
277
|
ignored.push(position);
|
|
412
278
|
continue;
|
|
413
279
|
}
|
|
414
|
-
const
|
|
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(
|
|
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 {
|
|
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(
|
|
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:
|
|
506
|
-
rejected:
|
|
507
|
-
state:
|
|
508
|
-
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
|
|
516
|
-
if (Option.isNone(
|
|
408
|
+
const key = yield* keyOf(streamId, event);
|
|
409
|
+
if (Option.isNone(key))
|
|
517
410
|
continue;
|
|
518
|
-
const
|
|
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 }) =>
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
...
|
|
510
|
+
...decodeOnlyStreams,
|
|
598
511
|
]);
|
|
599
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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).
|
|
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
|