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