effect-dynamodb 1.25.0 → 1.27.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/Aggregate.d.ts +7 -0
- package/dist/Aggregate.d.ts.map +1 -1
- package/dist/Aggregate.js +150 -4
- package/dist/Aggregate.js.map +1 -1
- package/dist/Collection.d.ts +9 -1
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +3 -0
- package/dist/Collection.js.map +1 -1
- package/dist/DynamoClient.d.ts +80 -28
- package/dist/DynamoClient.d.ts.map +1 -1
- package/dist/DynamoClient.js +83 -11
- package/dist/DynamoClient.js.map +1 -1
- package/dist/Entity.d.ts +80 -27
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +406 -76
- package/dist/Entity.js.map +1 -1
- package/dist/EventStore.d.ts +454 -55
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +681 -129
- package/dist/EventStore.js.map +1 -1
- package/dist/Query.d.ts +53 -0
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +88 -6
- package/dist/Query.js.map +1 -1
- package/dist/StorageEncoding.d.ts +160 -0
- package/dist/StorageEncoding.d.ts.map +1 -0
- package/dist/StorageEncoding.js +258 -0
- package/dist/StorageEncoding.js.map +1 -0
- package/dist/Table.d.ts.map +1 -1
- package/dist/Table.js +8 -6
- package/dist/Table.js.map +1 -1
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +5 -0
- package/dist/Transaction.js.map +1 -1
- package/dist/compression/zlib.d.ts +75 -0
- package/dist/compression/zlib.d.ts.map +1 -0
- package/dist/compression/zlib.js +109 -0
- package/dist/compression/zlib.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/BoundCrud.d.ts +39 -35
- package/dist/internal/BoundCrud.d.ts.map +1 -1
- package/dist/internal/BoundCrud.js.map +1 -1
- package/dist/internal/BoundQuery.d.ts +3 -3
- package/dist/internal/BoundQuery.d.ts.map +1 -1
- package/dist/internal/BoundQuery.js.map +1 -1
- package/dist/internal/BoundVectorQuery.d.ts +12 -0
- package/dist/internal/BoundVectorQuery.d.ts.map +1 -1
- package/dist/internal/BoundVectorQuery.js +14 -1
- package/dist/internal/BoundVectorQuery.js.map +1 -1
- package/dist/internal/EntityCombinators.d.ts.map +1 -1
- package/dist/internal/EntityCombinators.js +16 -1
- package/dist/internal/EntityCombinators.js.map +1 -1
- package/dist/internal/Expr.d.ts +5 -3
- package/dist/internal/Expr.d.ts.map +1 -1
- package/dist/internal/Expr.js +1 -1
- package/dist/internal/Expr.js.map +1 -1
- package/dist/internal/Packed.d.ts +289 -0
- package/dist/internal/Packed.d.ts.map +1 -0
- package/dist/internal/Packed.js +654 -0
- package/dist/internal/Packed.js.map +1 -0
- package/dist/internal/PathBuilder.d.ts +45 -2
- package/dist/internal/PathBuilder.d.ts.map +1 -1
- package/dist/internal/PathBuilder.js +4 -0
- package/dist/internal/PathBuilder.js.map +1 -1
- package/dist/internal/TransactableOps.d.ts +1 -1
- package/dist/internal/TransactableOps.d.ts.map +1 -1
- package/dist/internal/TransactableOps.js +2 -0
- package/dist/internal/TransactableOps.js.map +1 -1
- package/package.json +6 -2
package/dist/EventStore.js
CHANGED
|
@@ -15,18 +15,32 @@
|
|
|
15
15
|
* - Stream indexes (`makeStream({ indexes })`): sub-streams ordered by a key
|
|
16
16
|
* derived from each event, on an LSI or GSI, each projecting all of the
|
|
17
17
|
* event, all but its metadata, or only its keys — `readIndex`,
|
|
18
|
-
* `query.index`, and `indexDefinitions` for the `CreateTable` fragments
|
|
18
|
+
* `query.index`, and `indexDefinitions` for the `CreateTable` fragments.
|
|
19
|
+
* A GSI may derive its partition too, so several streams share one
|
|
20
|
+
* partition of it, snapshots included (#149)
|
|
19
21
|
* - `fold` / `foldFrom` helpers for state reconstruction
|
|
22
|
+
* - Packed storage (`makeStream({ storedAs: { data } })`, `snapshot.storedAs`):
|
|
23
|
+
* event data and snapshot state as one JSON string, optionally compressed —
|
|
24
|
+
* read by the stored form, so a stream may mix native, JSON and compressed
|
|
25
|
+
* items; `measureAppend` / `measureSnapshot` for caller-side size planning,
|
|
26
|
+
* and `decodeStreamImage` for DynamoDB Streams records
|
|
20
27
|
*
|
|
21
28
|
* Built on the existing library primitives (DynamoSchema, KeyComposer, Query,
|
|
22
29
|
* DynamoClient, Marshaller).
|
|
23
30
|
*/
|
|
31
|
+
import { Compression } from "@effect-dynamodb/schema/Compression.js";
|
|
32
|
+
import { isPackedConfig } from "@effect-dynamodb/schema/DynamoModel.js";
|
|
24
33
|
import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
|
|
25
34
|
import { normalizeTtlSeconds } from "@effect-dynamodb/schema/Entity.js";
|
|
26
35
|
import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled, TransactionCancelled, ValidationError, VersionConflict, } from "@effect-dynamodb/schema/Errors.js";
|
|
36
|
+
import { isDispatchEligible, packedShape, packValue, unpackStoredSync, } from "@effect-dynamodb/schema/internal/PackedCodec.js";
|
|
27
37
|
import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
|
|
28
|
-
import { DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
|
|
38
|
+
import { Context, DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
|
|
29
39
|
import { DynamoClient } from "./DynamoClient.js";
|
|
40
|
+
import { eq } from "./internal/Expr.js";
|
|
41
|
+
import { itemBytes, transactItemBytes } from "./internal/ItemSize.js";
|
|
42
|
+
import { codecRequirements, currentRegistry, decodeImage, imageEntityType, runtimeCodecCheck, unregisteredCodecError, withRegistry, } from "./internal/Packed.js";
|
|
43
|
+
import { createPathBuilder } from "./internal/PathBuilder.js";
|
|
30
44
|
import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation, transactItemTarget, } from "./internal/TransactWriteOps.js";
|
|
31
45
|
import { diagnosedValidationError, diagnoseFailure, mergeKeyAttributes, withCause, } from "./internal/WriteDiagnosis.js";
|
|
32
46
|
import { fromAttributeMap, toAttributeMap } from "./Marshaller.js";
|
|
@@ -41,6 +55,13 @@ import { DEFAULT_TTL_ATTRIBUTE_NAME, resolveTtlAttributeName, } from "./Table.js
|
|
|
41
55
|
*
|
|
42
56
|
* @internal
|
|
43
57
|
*/
|
|
58
|
+
/** The codecs a stream's packed `data` / `state` name (`[EDD-9078]` when unregistered). */
|
|
59
|
+
const streamCodecRequirements = (streamName, storedAs) => codecRequirements(`stream "${streamName}"`, [
|
|
60
|
+
["data", storedAs.data],
|
|
61
|
+
["state", storedAs.state],
|
|
62
|
+
]);
|
|
63
|
+
/** The attributes a stream image reads by stored form: event `data` only. */
|
|
64
|
+
const DATA_ATTRIBUTES = ["data"];
|
|
44
65
|
const EventEnvelope = Schema.Struct({
|
|
45
66
|
streamId: Schema.String,
|
|
46
67
|
version: Schema.Number,
|
|
@@ -128,21 +149,31 @@ const STREAM_INDEX_CONFIG_KEYS = [
|
|
|
128
149
|
"pk",
|
|
129
150
|
"sk",
|
|
130
151
|
"key",
|
|
152
|
+
"partition",
|
|
153
|
+
"snapshot",
|
|
131
154
|
"projection",
|
|
132
155
|
];
|
|
156
|
+
/**
|
|
157
|
+
* @internal `pk = <streamPk>`: keeps one stream id's items in a partition of a
|
|
158
|
+
* stream index several streams (or stream ids) share (#149). The table's `pk`
|
|
159
|
+
* is projected into every index, whatever its projection.
|
|
160
|
+
*/
|
|
161
|
+
const streamPkFilter = (streamPk) => eq(createPathBuilder().pk, streamPk);
|
|
133
162
|
/**
|
|
134
163
|
* @internal Validate `makeStream({ indexes })` at definition time.
|
|
135
164
|
*
|
|
136
165
|
* @throws `[EDD-9063]` for a malformed entry (unknown `type`, a `gsi` without
|
|
137
|
-
* `pk`, an `lsi` with `pk`, an empty name, a
|
|
138
|
-
*
|
|
139
|
-
*
|
|
166
|
+
* `pk`, an `lsi` with `pk`, `partition` or `snapshot`, an empty name, a
|
|
167
|
+
* `key` / `partition` / `snapshot` that is not a function, a `snapshot`
|
|
168
|
+
* without `partition` or on a stream without a snapshot config, an unknown
|
|
169
|
+
* `projection`, a member the entry type does not declare — a misspelled
|
|
170
|
+
* `projection` would otherwise silently mean `"all"`).
|
|
140
171
|
* @throws `[EDD-9064]` for an index attribute that collides with a
|
|
141
172
|
* stream-owned attribute.
|
|
142
173
|
* @throws `[EDD-9065]` for two indexes sharing a physical index name or an
|
|
143
174
|
* attribute.
|
|
144
175
|
*/
|
|
145
|
-
const resolveStreamIndexes = (streamName, indexes) => {
|
|
176
|
+
const resolveStreamIndexes = (streamName, indexes, hasSnapshotConfig) => {
|
|
146
177
|
if (indexes === undefined)
|
|
147
178
|
return [];
|
|
148
179
|
const fail = (code, message) => {
|
|
@@ -179,7 +210,30 @@ const resolveStreamIndexes = (streamName, indexes) => {
|
|
|
179
210
|
'declare type: "gsi".');
|
|
180
211
|
}
|
|
181
212
|
if (typeof entry.key !== "function") {
|
|
182
|
-
fail("EDD-9063", `index "${name}": key must be a function (event, version) => string | undefined.`);
|
|
213
|
+
fail("EDD-9063", `index "${name}": key must be a function (event, version, streamId) => string | undefined.`);
|
|
214
|
+
}
|
|
215
|
+
if (entry.partition !== undefined) {
|
|
216
|
+
if (type !== "gsi") {
|
|
217
|
+
fail("EDD-9063", `index "${name}": an "lsi" index is partitioned by the stream; only a "gsi" index ` +
|
|
218
|
+
'derives its partition. Remove partition, or declare type: "gsi".');
|
|
219
|
+
}
|
|
220
|
+
if (typeof entry.partition !== "function") {
|
|
221
|
+
fail("EDD-9063", `index "${name}": partition must be a function (event, version, streamId) => string | undefined.`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (entry.snapshot !== undefined) {
|
|
225
|
+
if (typeof entry.snapshot !== "function") {
|
|
226
|
+
fail("EDD-9063", `index "${name}": snapshot must be a function (state, asOfVersion, streamId) => ` +
|
|
227
|
+
"{ partition, key } | undefined.");
|
|
228
|
+
}
|
|
229
|
+
if (entry.partition === undefined) {
|
|
230
|
+
fail("EDD-9063", `index "${name}": snapshot requires partition — only a "gsi" index with a derived ` +
|
|
231
|
+
"partition indexes the stream's snapshot.");
|
|
232
|
+
}
|
|
233
|
+
if (!hasSnapshotConfig) {
|
|
234
|
+
fail("EDD-9063", `index "${name}": snapshot requires the stream's snapshot config ` +
|
|
235
|
+
"(makeStream({ snapshot: { schema } })); the stream writes no snapshot to index.");
|
|
236
|
+
}
|
|
183
237
|
}
|
|
184
238
|
const projection = (entry.projection ?? "all");
|
|
185
239
|
if (projection !== "all" && projection !== "withoutMetadata" && projection !== "keys") {
|
|
@@ -216,11 +270,67 @@ const resolveStreamIndexes = (streamName, indexes) => {
|
|
|
216
270
|
pk,
|
|
217
271
|
sk,
|
|
218
272
|
projection,
|
|
273
|
+
partition: entry.partition === undefined ? "stream" : "derived",
|
|
274
|
+
snapshot: entry.snapshot !== undefined,
|
|
219
275
|
key: entry.key,
|
|
276
|
+
derivePartition: entry.partition,
|
|
277
|
+
snapshotEntry: entry.snapshot,
|
|
220
278
|
});
|
|
221
279
|
}
|
|
222
280
|
return resolved;
|
|
223
281
|
};
|
|
282
|
+
/**
|
|
283
|
+
* @internal Validate `makeStream({ storedAs })` and `snapshot.storedAs` at
|
|
284
|
+
* definition time (#147).
|
|
285
|
+
*
|
|
286
|
+
* @throws `[EDD-9079]` for a `storedAs` that is not an object, a key other
|
|
287
|
+
* than `data`, or a value that is not a `DynamoModel.Packed(...)` config.
|
|
288
|
+
* @throws `[EDD-9073]` for a packed snapshot whose state schema's encoded side
|
|
289
|
+
* is not object- or array-shaped.
|
|
290
|
+
*/
|
|
291
|
+
const resolveStreamStorage = (streamName, storedAs, snapshot) => {
|
|
292
|
+
const fail = (code, message) => {
|
|
293
|
+
throw new Error(`[${code}] EventStream "${streamName}": ${message}`);
|
|
294
|
+
};
|
|
295
|
+
const describe = (value) => value === null
|
|
296
|
+
? "null"
|
|
297
|
+
: typeof value === "object"
|
|
298
|
+
? "an object that is not a DynamoModel.Packed(...) config"
|
|
299
|
+
: `${typeof value} ${JSON.stringify(value)}`;
|
|
300
|
+
let data;
|
|
301
|
+
if (storedAs !== undefined) {
|
|
302
|
+
if (typeof storedAs !== "object" || storedAs === null || Array.isArray(storedAs)) {
|
|
303
|
+
fail("EDD-9079", `storedAs must be an object { data?: DynamoModel.Packed(...) }; received ${describe(storedAs)}.`);
|
|
304
|
+
}
|
|
305
|
+
for (const [key, value] of Object.entries(storedAs)) {
|
|
306
|
+
if (key !== "data") {
|
|
307
|
+
fail("EDD-9079", `storedAs declares "${key}", but only "data" can be stored packed` +
|
|
308
|
+
(key === "metadata" ? " — event metadata always stays a map" : "") +
|
|
309
|
+
(key === "state" ? " — pack snapshot state with snapshot.storedAs" : "") +
|
|
310
|
+
".");
|
|
311
|
+
}
|
|
312
|
+
if (value !== undefined && !isPackedConfig(value)) {
|
|
313
|
+
fail("EDD-9079", `storedAs.data must be a DynamoModel.Packed(...) config; received ${describe(value)}.`);
|
|
314
|
+
}
|
|
315
|
+
data = value;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
let state;
|
|
319
|
+
const snapshotStoredAs = snapshot?.storedAs;
|
|
320
|
+
if (snapshot !== undefined && snapshotStoredAs !== undefined) {
|
|
321
|
+
if (!isPackedConfig(snapshotStoredAs)) {
|
|
322
|
+
fail("EDD-9079", `snapshot.storedAs must be a DynamoModel.Packed(...) config; received ` +
|
|
323
|
+
`${describe(snapshotStoredAs)}.`);
|
|
324
|
+
}
|
|
325
|
+
const shape = packedShape(snapshot.schema);
|
|
326
|
+
if (shape._tag === "Refused") {
|
|
327
|
+
fail("EDD-9073", `snapshot.storedAs packs the snapshot state, but the state schema cannot be packed: ` +
|
|
328
|
+
`${shape.reason}. Packed storage holds object- or array-shaped JSON.`);
|
|
329
|
+
}
|
|
330
|
+
state = snapshotStoredAs;
|
|
331
|
+
}
|
|
332
|
+
return { data, state };
|
|
333
|
+
};
|
|
224
334
|
// ---------------------------------------------------------------------------
|
|
225
335
|
// EventStreamTypeId
|
|
226
336
|
// ---------------------------------------------------------------------------
|
|
@@ -361,11 +471,20 @@ const eventTimestampsProblem = (timestamps, count, label, events) => {
|
|
|
361
471
|
* `indexes` declares sub-streams ordered by a key derived from each event
|
|
362
472
|
* (#140), on a local (default) or global secondary index. `append` writes each
|
|
363
473
|
* event's derived key (and, for a GSI, the stream's partition key) on the
|
|
364
|
-
* event item
|
|
365
|
-
*
|
|
366
|
-
* `query.index`, and create the
|
|
367
|
-
* index's `projection` (default
|
|
368
|
-
* and how it is read — see
|
|
474
|
+
* event item. Idempotency sentinels never carry them; snapshots carry an
|
|
475
|
+
* entry only in a GSI that derives its partition and declares `snapshot`.
|
|
476
|
+
* Read with {@link EventStream.readIndex} or `query.index`, and create the
|
|
477
|
+
* indexes with {@link indexDefinitions}. Each index's `projection` (default
|
|
478
|
+
* `"all"`) sets what it copies from each event and how it is read — see
|
|
479
|
+
* {@link StreamIndexProjection}.
|
|
480
|
+
*
|
|
481
|
+
* A GSI's `partition` (#149) derives the partition from the stream id and the
|
|
482
|
+
* event instead, stored raw, so several streams declaring the same GSI write
|
|
483
|
+
* one partition of it — an order's line, payment and shipment streams under
|
|
484
|
+
* `ORDER#<orderId>`, read by one query — and its `snapshot` puts each stream's
|
|
485
|
+
* snapshot item in that partition too, in the same `Put` (so an inline
|
|
486
|
+
* snapshot enters the index in the append's own transaction). See
|
|
487
|
+
* {@link GlobalStreamIndexConfig}.
|
|
369
488
|
*
|
|
370
489
|
* Index attributes are written only at append time and events are never
|
|
371
490
|
* rewritten. Declaring an index on a stream that already holds events, or
|
|
@@ -377,6 +496,36 @@ const eventTimestampsProblem = (timestamps, count, label, events) => {
|
|
|
377
496
|
*
|
|
378
497
|
* @example
|
|
379
498
|
* ```typescript
|
|
499
|
+
* // Lines and payments of an order share `gsi1` under `ORDER#<orderId>`.
|
|
500
|
+
* const OrderLines = EventStore.makeStream({
|
|
501
|
+
* table: EventsTable,
|
|
502
|
+
* streamName: "OrderLine",
|
|
503
|
+
* events: [LineAdded, LineRemoved],
|
|
504
|
+
* streamId: { composite: ["orderId", "lineId"] },
|
|
505
|
+
* snapshot: { schema: LineState, mode: "inline" },
|
|
506
|
+
* indexes: {
|
|
507
|
+
* byOrder: {
|
|
508
|
+
* type: "gsi",
|
|
509
|
+
* index: "gsi1",
|
|
510
|
+
* pk: "gsi1pk",
|
|
511
|
+
* sk: "gsi1sk",
|
|
512
|
+
* partition: (_event, _version, streamId) => `ORDER#${streamId.orderId}`,
|
|
513
|
+
* key: (_event, version, streamId) => `LINE#${streamId.lineId}#EVT#${pad(version)}`,
|
|
514
|
+
* snapshot: (_state, _asOfVersion, streamId) => ({
|
|
515
|
+
* partition: `ORDER#${streamId.orderId}`,
|
|
516
|
+
* key: `LINE#${streamId.lineId}#STATE`,
|
|
517
|
+
* }),
|
|
518
|
+
* },
|
|
519
|
+
* },
|
|
520
|
+
* })
|
|
521
|
+
*
|
|
522
|
+
* const lineEvents = yield* OrderLines.readIndex("byOrder", { orderId, lineId }, {
|
|
523
|
+
* partition: `ORDER#${orderId}`,
|
|
524
|
+
* })
|
|
525
|
+
* ```
|
|
526
|
+
*
|
|
527
|
+
* @example
|
|
528
|
+
* ```typescript
|
|
380
529
|
* const Entries = EventStore.makeStream({
|
|
381
530
|
* table: EventsTable,
|
|
382
531
|
* streamName: "Ledger",
|
|
@@ -401,10 +550,12 @@ const eventTimestampsProblem = (timestamps, count, label, events) => {
|
|
|
401
550
|
* @throws `[EDD-9062]` when `snapshot.mode` is neither `"after-append"` nor
|
|
402
551
|
* `"inline"`.
|
|
403
552
|
* @throws `[EDD-9063]` for a malformed index: a `type` other than `"lsi"` /
|
|
404
|
-
* `"gsi"`, a `gsi` without `pk`, an `lsi` with `pk`,
|
|
405
|
-
*
|
|
406
|
-
* `
|
|
407
|
-
*
|
|
553
|
+
* `"gsi"`, a `gsi` without `pk`, an `lsi` with `pk`, `partition` or
|
|
554
|
+
* `snapshot`, an empty `index` / `sk` / `pk`, a `key` / `partition` /
|
|
555
|
+
* `snapshot` that is not a function, a `snapshot` without `partition` or on
|
|
556
|
+
* a stream without a `snapshot` config, a `projection` other than `"all"` /
|
|
557
|
+
* `"withoutMetadata"` / `"keys"`, or a member an index entry does not
|
|
558
|
+
* declare (such as a misspelled `projection`, also a type error).
|
|
408
559
|
* @throws `[EDD-9064]` when an index attribute is one the stream writes itself
|
|
409
560
|
* (`pk`, `sk`, `__edd_e__`, `streamId`, `version`, `eventType`, `data`,
|
|
410
561
|
* `metadata`, `timestamp`, `asOfVersion`, `state`, `commandId`, or the
|
|
@@ -413,6 +564,18 @@ const eventTimestampsProblem = (timestamps, count, label, events) => {
|
|
|
413
564
|
* with `ValidationError`.
|
|
414
565
|
* @throws `[EDD-9065]` when two indexes share a physical index name or an
|
|
415
566
|
* attribute (or a GSI's `pk` and `sk` are the same attribute).
|
|
567
|
+
* @throws `[EDD-9079]` when `storedAs` declares a key other than `data`
|
|
568
|
+
* (metadata always stays a map), or a `storedAs.data` / `snapshot.storedAs`
|
|
569
|
+
* value is not a `DynamoModel.Packed(...)` config.
|
|
570
|
+
* @throws `[EDD-9073]` when `snapshot.storedAs` is set and the state schema's
|
|
571
|
+
* encoded side is not object- or array-shaped.
|
|
572
|
+
*
|
|
573
|
+
* `storedAs: { data: DynamoModel.Packed(...) }` stores event `data` packed, and
|
|
574
|
+
* `snapshot.storedAs` the snapshot `state` — see {@link StreamStorageConfig}.
|
|
575
|
+
* Register the codecs a compressed config names through the `Compression`
|
|
576
|
+
* service (`Compression.layer([...])` merged into the application layer): a
|
|
577
|
+
* bound stream refuses to bind without them, and an unbound one to run its
|
|
578
|
+
* first operation (`[EDD-9078]`).
|
|
416
579
|
*/
|
|
417
580
|
export const makeStream = (config) => {
|
|
418
581
|
const schema = config.table.schema;
|
|
@@ -463,8 +626,41 @@ export const makeStream = (config) => {
|
|
|
463
626
|
const snapshotSettings = snapshot === undefined
|
|
464
627
|
? undefined
|
|
465
628
|
: { mode: snapshot.mode ?? "after-append", every: snapshot.every };
|
|
629
|
+
// Packed storage (EDD-9079, EDD-9073) — validated at definition time.
|
|
630
|
+
const storage = resolveStreamStorage(config.streamName, config.storedAs, snapshot);
|
|
631
|
+
/** Event `data` packing, or `undefined` for the native map. */
|
|
632
|
+
const dataStoredAs = storage.data;
|
|
633
|
+
/** Snapshot `state` packing, or `undefined` for the native map. */
|
|
634
|
+
const stateStoredAs = storage.state;
|
|
635
|
+
/**
|
|
636
|
+
* Whether a snapshot's stored `state` is read by its stored form: when it is
|
|
637
|
+
* configured packed, or when its schema's encoded side is object- or
|
|
638
|
+
* array-shaped (a native state is then never `S` / `B`), so a stream that
|
|
639
|
+
* turned packing off still reads its packed snapshot.
|
|
640
|
+
*/
|
|
641
|
+
const stateDispatch = snapshot !== undefined && (stateStoredAs !== undefined || isDispatchEligible(snapshot.schema));
|
|
642
|
+
/**
|
|
643
|
+
* The first operation run with a given compressor registry checks that it
|
|
644
|
+
* has every codec `storedAs` names (`[EDD-9078]`, a defect): the stream
|
|
645
|
+
* meets values compressed with them on every read and write. `bind` checks
|
|
646
|
+
* the registry it captures up front instead.
|
|
647
|
+
*/
|
|
648
|
+
const codecCheck = runtimeCodecCheck(`EventStore (first use of stream "${config.streamName}")`, streamCodecRequirements(config.streamName, storage));
|
|
649
|
+
const checked = (op) => codecCheck === undefined ? op : (...args) => Effect.andThen(codecCheck, op(...args));
|
|
650
|
+
/** The `[EDD-9076]` guard for queries of a stream whose `data` is packed. */
|
|
651
|
+
const dataGuards = dataStoredAs === undefined
|
|
652
|
+
? []
|
|
653
|
+
: [
|
|
654
|
+
{
|
|
655
|
+
attribute: "data",
|
|
656
|
+
owner: `EventStream "${config.streamName}"`,
|
|
657
|
+
storedAs: dataStoredAs,
|
|
658
|
+
alternative: (path) => `Use Query.filterBy((event) => /* test event.${path} */), which runs on the ` +
|
|
659
|
+
"decoded event inside the same paging loop.",
|
|
660
|
+
},
|
|
661
|
+
];
|
|
466
662
|
// Stream indexes (#140) — validated at definition time (EDD-9063..9065).
|
|
467
|
-
const streamIndexes = resolveStreamIndexes(config.streamName, config.indexes);
|
|
663
|
+
const streamIndexes = resolveStreamIndexes(config.streamName, config.indexes, snapshot !== undefined);
|
|
468
664
|
const indexSettings = {};
|
|
469
665
|
for (const idx of streamIndexes) {
|
|
470
666
|
indexSettings[idx.name] = {
|
|
@@ -473,8 +669,12 @@ export const makeStream = (config) => {
|
|
|
473
669
|
pk: idx.pk,
|
|
474
670
|
sk: idx.sk,
|
|
475
671
|
projection: idx.projection,
|
|
672
|
+
partition: idx.partition,
|
|
673
|
+
snapshot: idx.snapshot,
|
|
476
674
|
};
|
|
477
675
|
}
|
|
676
|
+
/** The indexes that hold the stream's snapshot (#149). */
|
|
677
|
+
const snapshotIndexes = streamIndexes.filter((idx) => idx.snapshotEntry !== undefined);
|
|
478
678
|
/** Every attribute the indexes write on an event item. */
|
|
479
679
|
const indexAttributes = streamIndexes.flatMap((idx) => idx.type === "gsi" ? [idx.pk, idx.sk] : [idx.sk]);
|
|
480
680
|
// Build union schema from event schemas for decoding
|
|
@@ -548,11 +748,41 @@ export const makeStream = (config) => {
|
|
|
548
748
|
// Decode a raw DynamoDB item → StreamEvent<TEvent>
|
|
549
749
|
// ---------------------------------------------------------------------------
|
|
550
750
|
const decodeEnvelope = Schema.decodeUnknownEffect(EventEnvelope);
|
|
751
|
+
/**
|
|
752
|
+
* An event's stored `data` → its wire form (plain JSON), by the stored
|
|
753
|
+
* form. A native value costs one `typeof` check.
|
|
754
|
+
*/
|
|
755
|
+
const unpackData = (stored, operation) => typeof stored === "string" || stored instanceof Uint8Array
|
|
756
|
+
? withRegistry((registry) => unpackStoredSync(stored, registry, { entityType, operation }, "data"))
|
|
757
|
+
: Effect.succeed(stored);
|
|
758
|
+
/**
|
|
759
|
+
* A raw event row (`Query.select`) with its `data` — when projected — as
|
|
760
|
+
* plain parsed JSON, whatever its stored form (R3: a stream that switched
|
|
761
|
+
* encoding holds a mix). Never schema-decoded. Only this stream's event
|
|
762
|
+
* rows are read: a row of another type (`Query.ignoreOwnership`) is handed
|
|
763
|
+
* back untouched, its `__edd_e__` borrowed for the check and stripped again.
|
|
764
|
+
* In place, so the strip reaches the row returned.
|
|
765
|
+
*/
|
|
766
|
+
const rawEventRow = Object.assign((raw) => {
|
|
767
|
+
const stored = raw.data;
|
|
768
|
+
if (raw.__edd_e__ !== entityType)
|
|
769
|
+
return Effect.succeed(raw);
|
|
770
|
+
if (typeof stored !== "string" && !(stored instanceof Uint8Array))
|
|
771
|
+
return Effect.succeed(raw);
|
|
772
|
+
return Effect.map(unpackData(stored, "EventStore.select"), (data) => {
|
|
773
|
+
raw.data = data;
|
|
774
|
+
return raw;
|
|
775
|
+
});
|
|
776
|
+
}, { reads: ["__edd_e__"], attributes: ["data"] });
|
|
551
777
|
const decodeStreamEvent = (raw) => Effect.gen(function* () {
|
|
552
778
|
const toValidationError = (operation) => (cause) => new ValidationError({ entityType, operation, cause });
|
|
553
779
|
const envelope = yield* decodeEnvelope(raw).pipe(Effect.mapError(toValidationError("EventStore.decode")));
|
|
780
|
+
// `data` is read by its stored form (#147): a native map as it is, a
|
|
781
|
+
// JSON string (`S`) parsed, compressed binary (`B`) decompressed and
|
|
782
|
+
// parsed — whatever the stream's current `storedAs` says.
|
|
783
|
+
const wireData = yield* unpackData(raw.data, "EventStore.decode");
|
|
554
784
|
const decoder = Schema.decodeUnknownEffect(eventUnion);
|
|
555
|
-
const data = yield* decoder(
|
|
785
|
+
const data = yield* decoder(wireData).pipe(Effect.mapError(toValidationError("EventStore.decode")));
|
|
556
786
|
// Metadata is decoded through its schema when the stream declares one —
|
|
557
787
|
// the mirror of the encode performed by `append`. Streams without a
|
|
558
788
|
// metadata schema surface the stored attribute map untouched.
|
|
@@ -575,34 +805,61 @@ export const makeStream = (config) => {
|
|
|
575
805
|
// append
|
|
576
806
|
// ---------------------------------------------------------------------------
|
|
577
807
|
/**
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
* rules on a key — not empty, at
|
|
582
|
-
*
|
|
583
|
-
*
|
|
808
|
+
* One value an index function derives for an item (#140, #149): an event's
|
|
809
|
+
* `key` or `partition`, a string or `undefined` to leave the item out.
|
|
810
|
+
* Another type, or a function that throws, is a `ValidationError`, raised
|
|
811
|
+
* before anything is written. DynamoDB's own rules on a key — not empty, at
|
|
812
|
+
* most 1024 bytes for a sort key and 2048 for a partition key — are not
|
|
813
|
+
* pre-checked: DynamoDB rejects the write and the rejection is diagnosed,
|
|
814
|
+
* naming the index and the item (see `sendAppendTransaction`).
|
|
584
815
|
*/
|
|
585
|
-
const
|
|
586
|
-
try:
|
|
816
|
+
const deriveIndexValue = (idx, what, subject, derive) => Effect.try({
|
|
817
|
+
try: derive,
|
|
587
818
|
catch: (cause) => new ValidationError({ entityType, operation: "EventStore.append.indexes", cause }),
|
|
588
819
|
}).pipe(Effect.flatMap((derived) => {
|
|
589
820
|
if (derived === undefined)
|
|
590
821
|
return Effect.succeed(undefined);
|
|
591
|
-
const refuse = (problem) => Effect.fail(new ValidationError({
|
|
592
|
-
entityType,
|
|
593
|
-
operation: "EventStore.append.indexes",
|
|
594
|
-
cause: `The key of index "${idx.name}" for the event at version ${version} ${problem}. ` +
|
|
595
|
-
"Nothing was written.",
|
|
596
|
-
}));
|
|
597
822
|
// A string, or the event is not indexed: what the stream's own key
|
|
598
823
|
// contract means, which DynamoDB cannot see (a number would be sent
|
|
599
824
|
// as `N`). An empty string is DynamoDB's to reject, and is diagnosed
|
|
600
825
|
// from its rejection, naming the index and the event (#144).
|
|
601
826
|
if (typeof derived !== "string") {
|
|
602
|
-
return
|
|
827
|
+
return Effect.fail(new ValidationError({
|
|
828
|
+
entityType,
|
|
829
|
+
operation: "EventStore.append.indexes",
|
|
830
|
+
cause: `The ${what} of index "${idx.name}" for ${subject} must be a string or ` +
|
|
831
|
+
`undefined; received ${String(derived)}. Nothing was written.`,
|
|
832
|
+
}));
|
|
603
833
|
}
|
|
604
834
|
return Effect.succeed(derived);
|
|
605
835
|
}));
|
|
836
|
+
/**
|
|
837
|
+
* The snapshot's entry in a stream index (#149): its partition and key, or
|
|
838
|
+
* `undefined` to leave it out. Anything but an object of two strings, or an
|
|
839
|
+
* entry function that throws, is a `ValidationError` before anything is
|
|
840
|
+
* written; DynamoDB's rules on the values are its own, as for events.
|
|
841
|
+
*/
|
|
842
|
+
const deriveSnapshotEntry = (idx, entry, state, streamId, asOfVersion, operation) => Effect.try({
|
|
843
|
+
try: () => entry(state, asOfVersion, streamId),
|
|
844
|
+
catch: (cause) => new ValidationError({ entityType: snapshotEntityType, operation, cause }),
|
|
845
|
+
}).pipe(Effect.flatMap((derived) => {
|
|
846
|
+
if (derived === undefined)
|
|
847
|
+
return Effect.succeed(undefined);
|
|
848
|
+
const { partition, key } = typeof derived === "object" && derived !== null
|
|
849
|
+
? derived
|
|
850
|
+
: { partition: undefined, key: undefined };
|
|
851
|
+
if (typeof partition !== "string" || typeof key !== "string") {
|
|
852
|
+
return Effect.fail(new ValidationError({
|
|
853
|
+
entityType: snapshotEntityType,
|
|
854
|
+
operation,
|
|
855
|
+
cause: `The snapshot entry of index "${idx.name}" at version ${asOfVersion} must be ` +
|
|
856
|
+
"{ partition: string, key: string } or undefined; received " +
|
|
857
|
+
`${derived !== null && typeof derived === "object" ? JSON.stringify(derived) : String(derived)}. ` +
|
|
858
|
+
"Nothing was written.",
|
|
859
|
+
}));
|
|
860
|
+
}
|
|
861
|
+
return Effect.succeed({ partition, key });
|
|
862
|
+
}));
|
|
606
863
|
/**
|
|
607
864
|
* Encode snapshot state to wire form through the state schema, with the
|
|
608
865
|
* same `decode → encode` fallback as events and metadata. A structurally
|
|
@@ -708,42 +965,127 @@ export const makeStream = (config) => {
|
|
|
708
965
|
};
|
|
709
966
|
/**
|
|
710
967
|
* A diagnosed rejection of an append (#144) other than its item count. An
|
|
711
|
-
* over-long (or empty) stream-index
|
|
712
|
-
* index
|
|
713
|
-
* the `indexes` option reports its other key problems
|
|
714
|
-
*
|
|
715
|
-
*
|
|
716
|
-
*
|
|
968
|
+
* over-long (or empty) stream-index key OF AN EVENT OR THE INLINE SNAPSHOT
|
|
969
|
+
* — a value an index function returned — is named by its index and item,
|
|
970
|
+
* as the `indexes` option reports its other key problems (see
|
|
971
|
+
* {@link indexKeyError}). Only the stream's own items carry stream-index
|
|
972
|
+
* keys: an additional item writing the same physical attribute
|
|
973
|
+
* (single-table designs share `gsi1sk` and the like) is its own entity's
|
|
974
|
+
* key, and is named as any other item.
|
|
717
975
|
*/
|
|
718
976
|
const appendLimitError = (diagnosis, original, tx) => {
|
|
719
|
-
if (
|
|
720
|
-
diagnosis._tag === "EmptyKey") {
|
|
977
|
+
if (diagnosis._tag === "KeyTooLong" || diagnosis._tag === "EmptyKey") {
|
|
721
978
|
const position = tx.targets().indexOf(diagnosis.target);
|
|
722
979
|
const isEvent = position >= tx.guardCount && position < tx.guardCount + tx.eventCount;
|
|
723
|
-
const
|
|
724
|
-
|
|
980
|
+
const isSnapshot = position >= 0 && position === tx.snapshotIndex;
|
|
981
|
+
const named = isEvent || isSnapshot
|
|
982
|
+
? indexKeyError(diagnosis, original, isSnapshot, "the append", "EventStore.append.indexes")
|
|
725
983
|
: undefined;
|
|
726
|
-
if (
|
|
727
|
-
return
|
|
728
|
-
entityType,
|
|
729
|
-
operation: "EventStore.append.indexes",
|
|
730
|
-
cause: withCause(`The key of index "${idx.name}" for ${diagnosis.target.source} ` +
|
|
731
|
-
(diagnosis._tag === "KeyTooLong"
|
|
732
|
-
? `is ${diagnosis.bytes} bytes, longer than DynamoDB's 1024-byte limit for a ` +
|
|
733
|
-
"sort key."
|
|
734
|
-
: "is an empty string, which DynamoDB does not accept as a key value; the " +
|
|
735
|
-
"key function must return a non-empty string, or undefined to leave the " +
|
|
736
|
-
"event out of the index.") +
|
|
737
|
-
" DynamoDB rejected the append. Nothing was written.", original),
|
|
738
|
-
});
|
|
739
|
-
}
|
|
984
|
+
if (named !== undefined)
|
|
985
|
+
return named;
|
|
740
986
|
}
|
|
741
987
|
return diagnosedValidationError(diagnosis, original, {
|
|
742
988
|
operation: "EventStore.append",
|
|
743
989
|
entityType,
|
|
744
990
|
});
|
|
745
991
|
};
|
|
746
|
-
|
|
992
|
+
/**
|
|
993
|
+
* An over-long or empty key value in a stream-index attribute of an event
|
|
994
|
+
* or snapshot item, named by its index and item, or `undefined` when the
|
|
995
|
+
* attribute is not one the item's indexes derive. An event carries each
|
|
996
|
+
* index's `key` (and a derived `partition`); a snapshot, each snapshot
|
|
997
|
+
* index's entry. A stream-scoped GSI's partition is the stream's own key,
|
|
998
|
+
* which no function returned: it is named as any key.
|
|
999
|
+
*/
|
|
1000
|
+
const indexKeyError = (diagnosis, original, isSnapshot, request, operation) => {
|
|
1001
|
+
const candidates = isSnapshot ? snapshotIndexes : streamIndexes;
|
|
1002
|
+
const kind = diagnosis._tag === "KeyTooLong"
|
|
1003
|
+
? diagnosis.kind
|
|
1004
|
+
: candidates.some((idx) => idx.sk === diagnosis.attribute)
|
|
1005
|
+
? "sort"
|
|
1006
|
+
: "partition";
|
|
1007
|
+
const idx = candidates.find((candidate) => kind === "sort"
|
|
1008
|
+
? candidate.sk === diagnosis.attribute
|
|
1009
|
+
: candidate.partition === "derived" && candidate.pk === diagnosis.attribute);
|
|
1010
|
+
if (idx === undefined)
|
|
1011
|
+
return undefined;
|
|
1012
|
+
const value = isSnapshot
|
|
1013
|
+
? `The snapshot entry's ${kind === "sort" ? "key" : "partition"}`
|
|
1014
|
+
: `The ${kind === "sort" ? "key" : "partition"}`;
|
|
1015
|
+
const leaveOut = isSnapshot
|
|
1016
|
+
? "the snapshot function must return non-empty strings, or undefined to leave the " +
|
|
1017
|
+
"snapshot out of the index."
|
|
1018
|
+
: `the ${kind === "sort" ? "key" : "partition"} function must return a non-empty ` +
|
|
1019
|
+
"string, or undefined to leave the event out of the index.";
|
|
1020
|
+
return new ValidationError({
|
|
1021
|
+
entityType: isSnapshot ? snapshotEntityType : entityType,
|
|
1022
|
+
operation,
|
|
1023
|
+
cause: withCause(`${value} of index "${idx.name}" for ${diagnosis.target.source} ` +
|
|
1024
|
+
(diagnosis._tag === "KeyTooLong"
|
|
1025
|
+
? `is ${diagnosis.bytes} bytes, longer than DynamoDB's ` +
|
|
1026
|
+
`${kind === "sort" ? "1024-byte limit for a sort" : "2048-byte limit for a partition"} key.`
|
|
1027
|
+
: `is an empty string, which DynamoDB does not accept as a key value; ${leaveOut}`) +
|
|
1028
|
+
` DynamoDB rejected ${request}. Nothing was written.`, original),
|
|
1029
|
+
});
|
|
1030
|
+
};
|
|
1031
|
+
/**
|
|
1032
|
+
* The snapshot item for `state` at `asOfVersion`: the state encoded through
|
|
1033
|
+
* the state schema, then packed per `snapshot.storedAs` (#147) — so every
|
|
1034
|
+
* size measured afterwards is stored bytes — with its entry in each index
|
|
1035
|
+
* that holds snapshots (#149). Shared by `writeSnapshot`, the inline
|
|
1036
|
+
* snapshot of `append`, and `measureSnapshot` / `measureAppend`.
|
|
1037
|
+
*
|
|
1038
|
+
* `eventPartitions` — the partition the append's events have in each
|
|
1039
|
+
* index, by index name — is what the inline snapshot's entry must match.
|
|
1040
|
+
*/
|
|
1041
|
+
const buildSnapshotItem = (streamId, state, asOfVersion, now, operation, eventPartitions) => Effect.gen(function* () {
|
|
1042
|
+
const encoded = yield* encodeSnapshotState(state, operation);
|
|
1043
|
+
const stored = stateStoredAs === undefined
|
|
1044
|
+
? encoded
|
|
1045
|
+
: yield* packValue(stateStoredAs, encoded, yield* currentRegistry, {
|
|
1046
|
+
entityType: snapshotEntityType,
|
|
1047
|
+
attribute: "state",
|
|
1048
|
+
operation: `${operation}.packed`,
|
|
1049
|
+
});
|
|
1050
|
+
const item = {
|
|
1051
|
+
pk: composeStreamPk(streamId),
|
|
1052
|
+
sk: snapshotSk,
|
|
1053
|
+
__edd_e__: snapshotEntityType,
|
|
1054
|
+
streamId: composeStreamIdString(streamId),
|
|
1055
|
+
asOfVersion,
|
|
1056
|
+
state: stored,
|
|
1057
|
+
timestamp: now,
|
|
1058
|
+
};
|
|
1059
|
+
// The snapshot's index entries (#149), stored raw on the item itself,
|
|
1060
|
+
// so they are written by the same `Put` and count towards its size.
|
|
1061
|
+
for (const idx of snapshotIndexes) {
|
|
1062
|
+
const entry = yield* deriveSnapshotEntry(idx, idx.snapshotEntry, state, streamId, asOfVersion, operation);
|
|
1063
|
+
if (entry === undefined)
|
|
1064
|
+
continue;
|
|
1065
|
+
const events = eventPartitions?.get(idx.name);
|
|
1066
|
+
if (events !== undefined && events.partition !== entry.partition) {
|
|
1067
|
+
return yield* new ValidationError({
|
|
1068
|
+
entityType: snapshotEntityType,
|
|
1069
|
+
operation,
|
|
1070
|
+
cause: `The inline snapshot's partition in index "${idx.name}" is ` +
|
|
1071
|
+
`${JSON.stringify(entry.partition)}, but ${events.subject} has ` +
|
|
1072
|
+
`${JSON.stringify(events.partition)}: an append's events and its inline snapshot ` +
|
|
1073
|
+
"must be in the same partition of an index. Nothing was written.",
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
item[idx.pk] = entry.partition;
|
|
1077
|
+
item[idx.sk] = entry.key;
|
|
1078
|
+
}
|
|
1079
|
+
return toAttributeMap(item);
|
|
1080
|
+
});
|
|
1081
|
+
/**
|
|
1082
|
+
* The stream's own items of an append — everything but the caller's
|
|
1083
|
+
* `additionalItems` — validated and built exactly as `append` sends them.
|
|
1084
|
+
* Shared by `append` and `measureAppend`, so a measurement is the size of
|
|
1085
|
+
* the request `append` would send. `undefined` for an append with nothing
|
|
1086
|
+
* to write (the historical no-op).
|
|
1087
|
+
*/
|
|
1088
|
+
const buildAppend = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
|
|
747
1089
|
const additionalOps = options?.additionalItems ?? [];
|
|
748
1090
|
const idempotency = options?.idempotency;
|
|
749
1091
|
const snapshotState = options?.snapshot;
|
|
@@ -794,7 +1136,7 @@ export const makeStream = (config) => {
|
|
|
794
1136
|
additionalOps.length === 0 &&
|
|
795
1137
|
idempotency === undefined &&
|
|
796
1138
|
snapshotState === undefined) {
|
|
797
|
-
return
|
|
1139
|
+
return undefined;
|
|
798
1140
|
}
|
|
799
1141
|
// Resolve stream ID string for storage (join composites)
|
|
800
1142
|
const streamIdStr = composeStreamIdString(streamId);
|
|
@@ -814,7 +1156,6 @@ export const makeStream = (config) => {
|
|
|
814
1156
|
// atomicity — so one over the limit is rejected whole by DynamoDB and
|
|
815
1157
|
// reported as `AppendTooLarge`, with the expanded item count.
|
|
816
1158
|
const needsContiguityCheck = expectedVersion > 0 && events.length > 0;
|
|
817
|
-
const client = yield* DynamoClient;
|
|
818
1159
|
const tableConfig = yield* config.table.Tag;
|
|
819
1160
|
const tableName = tableConfig.name;
|
|
820
1161
|
// A custom TTL attribute is only known at runtime. An index key attribute
|
|
@@ -834,6 +1175,8 @@ export const makeStream = (config) => {
|
|
|
834
1175
|
// Clock-backed timestamp (deterministic under TestClock; wall-clock in prod).
|
|
835
1176
|
const nowDateTime = yield* DateTime.now;
|
|
836
1177
|
const now = DateTime.formatIso(nowDateTime);
|
|
1178
|
+
// The compressor registry in scope, for packed `data` / `state` (#147).
|
|
1179
|
+
const registry = yield* currentRegistry;
|
|
837
1180
|
// Validate and encode metadata to wire form if schema provided. Shared
|
|
838
1181
|
// by every event; per-event `eventMetadata` is encoded in the loop below.
|
|
839
1182
|
let encodedMetadata;
|
|
@@ -844,6 +1187,10 @@ export const makeStream = (config) => {
|
|
|
844
1187
|
else if (options?.metadata !== undefined) {
|
|
845
1188
|
encodedMetadata = options.metadata;
|
|
846
1189
|
}
|
|
1190
|
+
// The partition the events have in each index that derives one (#149),
|
|
1191
|
+
// by index name, with the first event that has it: the events and the
|
|
1192
|
+
// inline snapshot of one append must agree.
|
|
1193
|
+
const eventPartitions = new Map();
|
|
847
1194
|
// Build the event puts — one Put per event, each with attribute_not_exists(pk).
|
|
848
1195
|
// Events are encoded to wire form through their schema (codec symmetry
|
|
849
1196
|
// with the read path, which decodes through the same schema), so this is
|
|
@@ -859,6 +1206,16 @@ export const makeStream = (config) => {
|
|
|
859
1206
|
// Inject _tag for plain Schema.Class events; Schema.TaggedClass
|
|
860
1207
|
// events already carry _tag in their encoded form, which wins.
|
|
861
1208
|
const eventData = { _tag: evtType, ...wire };
|
|
1209
|
+
// Packed `data` (#147): one JSON string, or compressed binary at
|
|
1210
|
+
// or over the threshold when that is smaller — packed here, before
|
|
1211
|
+
// marshalling, so every size measured afterwards is stored bytes.
|
|
1212
|
+
const storedData = dataStoredAs === undefined
|
|
1213
|
+
? eventData
|
|
1214
|
+
: yield* packValue(dataStoredAs, eventData, registry, {
|
|
1215
|
+
entityType,
|
|
1216
|
+
attribute: "data",
|
|
1217
|
+
operation: "EventStore.append.packed",
|
|
1218
|
+
});
|
|
862
1219
|
// This event's own metadata entry (#144), encoded as `metadata`
|
|
863
1220
|
// is; an `undefined` entry writes no metadata attribute.
|
|
864
1221
|
let itemMetadata = encodedMetadata;
|
|
@@ -884,7 +1241,7 @@ export const makeStream = (config) => {
|
|
|
884
1241
|
streamId: streamIdStr,
|
|
885
1242
|
version,
|
|
886
1243
|
eventType: evtType,
|
|
887
|
-
data:
|
|
1244
|
+
data: storedData,
|
|
888
1245
|
timestamp: eventTimestamp === undefined ? now : DateTime.formatIso(eventTimestamp),
|
|
889
1246
|
};
|
|
890
1247
|
const item = { ...envelope };
|
|
@@ -892,15 +1249,39 @@ export const makeStream = (config) => {
|
|
|
892
1249
|
item.metadata = itemMetadata;
|
|
893
1250
|
}
|
|
894
1251
|
// Stream indexes (#140): the derived key, stored raw, and for a
|
|
895
|
-
// GSI the stream's partition key
|
|
896
|
-
//
|
|
1252
|
+
// GSI the stream's partition key — or the derived partition, raw
|
|
1253
|
+
// too (#149). The attributes are part of the event item itself,
|
|
1254
|
+
// so they count towards the size checks. Both functions run for
|
|
1255
|
+
// every event, so each one's contract is checked on every event.
|
|
1256
|
+
const subject = `the event at version ${version}`;
|
|
897
1257
|
for (const idx of streamIndexes) {
|
|
898
|
-
const derived = yield*
|
|
899
|
-
|
|
1258
|
+
const derived = yield* deriveIndexValue(idx, "key", subject, () => idx.key(event, version, streamId));
|
|
1259
|
+
let partition = pk;
|
|
1260
|
+
if (idx.derivePartition !== undefined) {
|
|
1261
|
+
const derivePartition = idx.derivePartition;
|
|
1262
|
+
partition = yield* deriveIndexValue(idx, "partition", subject, () => derivePartition(event, version, streamId));
|
|
1263
|
+
if (partition !== undefined) {
|
|
1264
|
+
const first = eventPartitions.get(idx.name);
|
|
1265
|
+
if (first === undefined) {
|
|
1266
|
+
eventPartitions.set(idx.name, { partition, subject });
|
|
1267
|
+
}
|
|
1268
|
+
else if (first.partition !== partition) {
|
|
1269
|
+
return yield* new ValidationError({
|
|
1270
|
+
entityType,
|
|
1271
|
+
operation: "EventStore.append.indexes",
|
|
1272
|
+
cause: `The partition of index "${idx.name}" for ${subject} is ` +
|
|
1273
|
+
`${JSON.stringify(partition)}, but ${first.subject} has ` +
|
|
1274
|
+
`${JSON.stringify(first.partition)}: every event of one append must be ` +
|
|
1275
|
+
"in the same partition of an index. Nothing was written.",
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
if (derived === undefined || partition === undefined)
|
|
900
1281
|
continue;
|
|
901
1282
|
item[idx.sk] = derived;
|
|
902
1283
|
if (idx.type === "gsi")
|
|
903
|
-
item[idx.pk] =
|
|
1284
|
+
item[idx.pk] = partition;
|
|
904
1285
|
}
|
|
905
1286
|
return {
|
|
906
1287
|
Put: {
|
|
@@ -931,8 +1312,7 @@ export const makeStream = (config) => {
|
|
|
931
1312
|
cause,
|
|
932
1313
|
}),
|
|
933
1314
|
});
|
|
934
|
-
sentinel[
|
|
935
|
-
DateTime.toEpochSeconds(nowDateTime) + ttlSeconds;
|
|
1315
|
+
sentinel[ttlAttribute] = DateTime.toEpochSeconds(nowDateTime) + ttlSeconds;
|
|
936
1316
|
}
|
|
937
1317
|
sentinelPut = {
|
|
938
1318
|
Put: {
|
|
@@ -943,21 +1323,14 @@ export const makeStream = (config) => {
|
|
|
943
1323
|
};
|
|
944
1324
|
}
|
|
945
1325
|
// The inline snapshot (#138): unconditional — see AppendOptions.snapshot.
|
|
1326
|
+
// Packed per `snapshot.storedAs` (#147), so the transaction-size and
|
|
1327
|
+
// item-size diagnosis count its stored bytes.
|
|
946
1328
|
let snapshotPut;
|
|
947
1329
|
if (snapshotState !== undefined) {
|
|
948
|
-
const encoded = yield* encodeSnapshotState(snapshotState, "EventStore.append.snapshot");
|
|
949
1330
|
snapshotPut = {
|
|
950
1331
|
Put: {
|
|
951
1332
|
TableName: tableName,
|
|
952
|
-
Item:
|
|
953
|
-
pk,
|
|
954
|
-
sk: snapshotSk,
|
|
955
|
-
__edd_e__: snapshotEntityType,
|
|
956
|
-
streamId: streamIdStr,
|
|
957
|
-
asOfVersion: expectedVersion + events.length,
|
|
958
|
-
state: encoded,
|
|
959
|
-
timestamp: now,
|
|
960
|
-
}),
|
|
1333
|
+
Item: yield* buildSnapshotItem(streamId, snapshotState, expectedVersion + events.length, now, "EventStore.append.snapshot", eventPartitions),
|
|
961
1334
|
},
|
|
962
1335
|
};
|
|
963
1336
|
}
|
|
@@ -982,6 +1355,24 @@ export const makeStream = (config) => {
|
|
|
982
1355
|
},
|
|
983
1356
|
}
|
|
984
1357
|
: undefined;
|
|
1358
|
+
const parts = {
|
|
1359
|
+
tableName,
|
|
1360
|
+
streamIdStr,
|
|
1361
|
+
contiguityCheck,
|
|
1362
|
+
eventItems,
|
|
1363
|
+
sentinelPut,
|
|
1364
|
+
snapshotPut,
|
|
1365
|
+
};
|
|
1366
|
+
return parts;
|
|
1367
|
+
});
|
|
1368
|
+
const append = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
|
|
1369
|
+
const additionalOps = options?.additionalItems ?? [];
|
|
1370
|
+
const idempotency = options?.idempotency;
|
|
1371
|
+
const parts = yield* buildAppend(streamId, events, expectedVersion, options);
|
|
1372
|
+
if (parts === undefined)
|
|
1373
|
+
return { version: expectedVersion, events: [] };
|
|
1374
|
+
const { tableName, streamIdStr, contiguityCheck, eventItems, sentinelPut, snapshotPut } = parts;
|
|
1375
|
+
const client = yield* DynamoClient;
|
|
985
1376
|
/**
|
|
986
1377
|
* Lay out the append's one transaction — see {@link AppendTransaction}:
|
|
987
1378
|
* [0, C) version-contiguity ConditionCheck (C is 0 or 1)
|
|
@@ -1018,6 +1409,7 @@ export const makeStream = (config) => {
|
|
|
1018
1409
|
if (sentinelPut !== undefined) {
|
|
1019
1410
|
pushStreamItem(sentinelPut, entityType, "the idempotency sentinel");
|
|
1020
1411
|
}
|
|
1412
|
+
const snapshotIndex = snapshotPut === undefined ? -1 : items.length;
|
|
1021
1413
|
if (snapshotPut !== undefined) {
|
|
1022
1414
|
pushStreamItem(snapshotPut, snapshotEntityType, "the inline snapshot");
|
|
1023
1415
|
}
|
|
@@ -1030,6 +1422,7 @@ export const makeStream = (config) => {
|
|
|
1030
1422
|
eventCount: eventItems.length,
|
|
1031
1423
|
additional,
|
|
1032
1424
|
sentinelIndex,
|
|
1425
|
+
snapshotIndex,
|
|
1033
1426
|
expectedVersion,
|
|
1034
1427
|
};
|
|
1035
1428
|
};
|
|
@@ -1045,7 +1438,9 @@ export const makeStream = (config) => {
|
|
|
1045
1438
|
// repeating an event, the contiguity check, the idempotency sentinel
|
|
1046
1439
|
// or the inline snapshot, or another additional item), 100 items,
|
|
1047
1440
|
// 4 MB, 400 KB per item, 1024-byte sort keys — are not pre-checked:
|
|
1048
|
-
// `sendAppendTransaction` diagnoses DynamoDB's rejection (#144).
|
|
1441
|
+
// `sendAppendTransaction` diagnoses DynamoDB's rejection (#144). The
|
|
1442
|
+
// items carry packed `data` / `state` as stored (#147), so the
|
|
1443
|
+
// diagnosis attributes stored bytes.
|
|
1049
1444
|
const outcome = yield* sendAppendTransaction(client, tx, streamIdStr, idempotency?.commandId);
|
|
1050
1445
|
if (outcome === undefined) {
|
|
1051
1446
|
return { version: expectedVersion + events.length, events };
|
|
@@ -1055,6 +1450,73 @@ export const makeStream = (config) => {
|
|
|
1055
1450
|
return yield* Effect.fail(lost);
|
|
1056
1451
|
});
|
|
1057
1452
|
// ---------------------------------------------------------------------------
|
|
1453
|
+
// measureAppend / measureSnapshot — caller-side size planning (#147, R1)
|
|
1454
|
+
// ---------------------------------------------------------------------------
|
|
1455
|
+
const measureAppend = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
|
|
1456
|
+
if (options?.additionalItems !== undefined) {
|
|
1457
|
+
return yield* new ValidationError({
|
|
1458
|
+
entityType,
|
|
1459
|
+
operation: "EventStore.measureAppend",
|
|
1460
|
+
cause: "measureAppend does not measure additionalItems (building a guarded put reads the " +
|
|
1461
|
+
"table): measure the stream's own items without them, and add the size of your " +
|
|
1462
|
+
"own items.",
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
const parts = yield* buildAppend(streamId, events, expectedVersion, options);
|
|
1466
|
+
if (parts === undefined) {
|
|
1467
|
+
return { events: [], sentinel: undefined, snapshot: undefined, transactionBytes: 0 };
|
|
1468
|
+
}
|
|
1469
|
+
const putBytes = (item) => itemBytes(item.Put?.Item ?? {});
|
|
1470
|
+
const owned = [
|
|
1471
|
+
...(parts.contiguityCheck === undefined ? [] : [parts.contiguityCheck]),
|
|
1472
|
+
...parts.eventItems,
|
|
1473
|
+
...(parts.sentinelPut === undefined ? [] : [parts.sentinelPut]),
|
|
1474
|
+
...(parts.snapshotPut === undefined ? [] : [parts.snapshotPut]),
|
|
1475
|
+
];
|
|
1476
|
+
const measurement = {
|
|
1477
|
+
events: parts.eventItems.map(putBytes),
|
|
1478
|
+
sentinel: parts.sentinelPut === undefined ? undefined : putBytes(parts.sentinelPut),
|
|
1479
|
+
snapshot: parts.snapshotPut === undefined ? undefined : putBytes(parts.snapshotPut),
|
|
1480
|
+
transactionBytes: owned.reduce((sum, item) => sum + transactItemBytes(item), 0),
|
|
1481
|
+
};
|
|
1482
|
+
return measurement;
|
|
1483
|
+
});
|
|
1484
|
+
const measureSnapshot = (streamId, state, asOfVersion) => Effect.gen(function* () {
|
|
1485
|
+
if (snapshot === undefined)
|
|
1486
|
+
return yield* snapshotUnavailable("measureSnapshot");
|
|
1487
|
+
const now = DateTime.formatIso(yield* DateTime.now);
|
|
1488
|
+
const item = yield* buildSnapshotItem(streamId, state, asOfVersion, now, "EventStore.measureSnapshot");
|
|
1489
|
+
return itemBytes(item);
|
|
1490
|
+
});
|
|
1491
|
+
// ---------------------------------------------------------------------------
|
|
1492
|
+
// decodeStreamImage — DynamoDB Streams records (#147, R2)
|
|
1493
|
+
// ---------------------------------------------------------------------------
|
|
1494
|
+
const decodeStreamImage = (image, options) => Effect.gen(function* () {
|
|
1495
|
+
const ctx = { entityType, operation: "EventStore.decodeStreamImage" };
|
|
1496
|
+
// Not one of this stream's events — its snapshot, a sentinel, another
|
|
1497
|
+
// stream's or an entity's item: none, decided from the raw image before
|
|
1498
|
+
// anything is unmarshalled, so nothing in a foreign image can fail it.
|
|
1499
|
+
if (typeof image === "object" &&
|
|
1500
|
+
image !== null &&
|
|
1501
|
+
!Array.isArray(image) &&
|
|
1502
|
+
imageEntityType(image) !== entityType) {
|
|
1503
|
+
return Option.none();
|
|
1504
|
+
}
|
|
1505
|
+
// In DynamoDB JSON the S / B tag says what `data` is; unmarshalled, a
|
|
1506
|
+
// string that is base64 of a `B` value is told apart from JSON text.
|
|
1507
|
+
const raw = yield* decodeImage(image, DATA_ATTRIBUTES, ctx);
|
|
1508
|
+
const event = yield* decodeStreamEvent(raw);
|
|
1509
|
+
if (options?.raw !== true)
|
|
1510
|
+
return Option.some(event);
|
|
1511
|
+
// `raw.data` is the wire form the decode just read: the parsed JSON of a
|
|
1512
|
+
// packed value, the unmarshalled map of a native one.
|
|
1513
|
+
const withRaw = {
|
|
1514
|
+
...event,
|
|
1515
|
+
rawData: raw.data,
|
|
1516
|
+
};
|
|
1517
|
+
return Option.some(withRaw);
|
|
1518
|
+
});
|
|
1519
|
+
// ---------------------------------------------------------------------------
|
|
1058
1520
|
// read
|
|
1059
1521
|
// ---------------------------------------------------------------------------
|
|
1060
1522
|
/** Apply {@link ReadOptions} to an event query. */
|
|
@@ -1111,19 +1573,12 @@ export const makeStream = (config) => {
|
|
|
1111
1573
|
const client = yield* DynamoClient;
|
|
1112
1574
|
const { name: tableName } = yield* config.table.Tag;
|
|
1113
1575
|
const now = DateTime.formatIso(yield* DateTime.now);
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
sk: snapshotSk,
|
|
1118
|
-
__edd_e__: snapshotEntityType,
|
|
1119
|
-
streamId: composeStreamIdString(streamId),
|
|
1120
|
-
asOfVersion,
|
|
1121
|
-
state: encoded,
|
|
1122
|
-
timestamp: now,
|
|
1123
|
-
};
|
|
1576
|
+
// Packed per `snapshot.storedAs` (#147): the diagnosis below measures
|
|
1577
|
+
// the stored bytes.
|
|
1578
|
+
const item = yield* buildSnapshotItem(streamId, state, asOfVersion, now, "EventStore.writeSnapshot");
|
|
1124
1579
|
const put = {
|
|
1125
1580
|
TableName: tableName,
|
|
1126
|
-
Item:
|
|
1581
|
+
Item: item,
|
|
1127
1582
|
// Monotonic: never regress the cache. Losing this race is a no-op,
|
|
1128
1583
|
// not an error — the events it summarises are already durable.
|
|
1129
1584
|
ConditionExpression: "attribute_not_exists(#pk) OR #asOfVersion < :asOfVersion",
|
|
@@ -1138,18 +1593,31 @@ export const makeStream = (config) => {
|
|
|
1138
1593
|
item: { Put: put },
|
|
1139
1594
|
target: () => transactItemTarget({ Put: put }, tableName, ["pk", "sk"], snapshotEntityType, "the snapshot"),
|
|
1140
1595
|
}),
|
|
1141
|
-
keys: () => ({
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
entityType: snapshotEntityType,
|
|
1596
|
+
keys: () => ({
|
|
1597
|
+
partition: ["pk", ...snapshotIndexes.map((idx) => idx.pk)],
|
|
1598
|
+
sort: ["sk", ...snapshotIndexes.map((idx) => idx.sk)],
|
|
1145
1599
|
}),
|
|
1600
|
+
// A snapshot index entry's empty or over-long value (#149) is named
|
|
1601
|
+
// by its index, as an inline snapshot's is.
|
|
1602
|
+
rewrite: (diagnosis, original) => (diagnosis._tag === "KeyTooLong" || diagnosis._tag === "EmptyKey"
|
|
1603
|
+
? indexKeyError(diagnosis, original, true, "the snapshot", "EventStore.writeSnapshot")
|
|
1604
|
+
: undefined) ??
|
|
1605
|
+
diagnosedValidationError(diagnosis, original, {
|
|
1606
|
+
operation: "EventStore.writeSnapshot",
|
|
1607
|
+
entityType: snapshotEntityType,
|
|
1608
|
+
}),
|
|
1146
1609
|
}));
|
|
1147
1610
|
});
|
|
1148
1611
|
/**
|
|
1149
1612
|
* Decode a stored snapshot item. A snapshot that fails to decode through the
|
|
1150
1613
|
* state schema is a `ValidationError` — never silently discarded.
|
|
1151
1614
|
*/
|
|
1152
|
-
const decodeSnapshotItem = (raw, operation) =>
|
|
1615
|
+
const decodeSnapshotItem = (raw, operation) =>
|
|
1616
|
+
// `state` is read by its stored form (#147) when it may be packed — see
|
|
1617
|
+
// `stateDispatch` — then decoded through the state schema as before.
|
|
1618
|
+
(stateDispatch && (typeof raw.state === "string" || raw.state instanceof Uint8Array)
|
|
1619
|
+
? withRegistry((registry) => unpackStoredSync(raw.state, registry, { entityType: snapshotEntityType, operation }, "state"))
|
|
1620
|
+
: Effect.succeed(raw.state)).pipe(Effect.flatMap((state) => Schema.decodeUnknownEffect(snapshot.schema)(state).pipe(Effect.mapError((cause) => new ValidationError({ entityType: snapshotEntityType, operation, cause })))), Effect.map((state) => ({
|
|
1153
1621
|
state,
|
|
1154
1622
|
asOfVersion: raw.asOfVersion,
|
|
1155
1623
|
timestamp: raw.timestamp,
|
|
@@ -1312,6 +1780,10 @@ export const makeStream = (config) => {
|
|
|
1312
1780
|
skField: "sk",
|
|
1313
1781
|
entityTypes: [entityType],
|
|
1314
1782
|
decoder: (raw) => decodeStreamEvent(raw),
|
|
1783
|
+
// Raw rows (`Query.select`) carry `data` as parsed JSON, whatever its
|
|
1784
|
+
// stored form, and a filter inside a packed `data` is refused (#147).
|
|
1785
|
+
rawRow: rawEventRow,
|
|
1786
|
+
packed: dataGuards,
|
|
1315
1787
|
resolveTableName: config.table.Tag.useSync((tc) => tc.name),
|
|
1316
1788
|
keyFields: ["pk", "sk"],
|
|
1317
1789
|
// Bound to the event SK range so non-event items in the stream partition
|
|
@@ -1323,15 +1795,44 @@ export const makeStream = (config) => {
|
|
|
1323
1795
|
// Stream indexes — query.index / readIndex (#140)
|
|
1324
1796
|
// ---------------------------------------------------------------------------
|
|
1325
1797
|
/**
|
|
1326
|
-
*
|
|
1327
|
-
*
|
|
1328
|
-
*
|
|
1329
|
-
*
|
|
1798
|
+
* @internal Why `partition` cannot be passed for index `name`, or
|
|
1799
|
+
* `undefined` when it can: required (a string) for an index that derives
|
|
1800
|
+
* its partition (#149), refused for any other. Unreachable through the
|
|
1801
|
+
* types. An unknown name is left to {@link buildIndexQuery}.
|
|
1802
|
+
*/
|
|
1803
|
+
const invalidIndexPartition = (name, partition) => {
|
|
1804
|
+
const idx = streamIndexes.find((candidate) => candidate.name === name);
|
|
1805
|
+
if (idx === undefined)
|
|
1806
|
+
return undefined;
|
|
1807
|
+
if (idx.partition === "derived") {
|
|
1808
|
+
return typeof partition === "string"
|
|
1809
|
+
? undefined
|
|
1810
|
+
: `Index "${name}" derives its partition: pass the partition to read as ` +
|
|
1811
|
+
`options.partition (a string); received ${String(partition)}.`;
|
|
1812
|
+
}
|
|
1813
|
+
return partition === undefined
|
|
1814
|
+
? undefined
|
|
1815
|
+
: `Index "${name}" is scoped to the stream; it takes no partition.`;
|
|
1816
|
+
};
|
|
1817
|
+
/**
|
|
1818
|
+
* A query of one stream index. A stream-scoped index is queried in the
|
|
1819
|
+
* stream's partition: the LSI shares the table's `pk`; a GSI's `pk`
|
|
1820
|
+
* attribute carries the same value. No sort-key condition — the index holds
|
|
1821
|
+
* only this stream's indexed events under that key; `Query.where` narrows
|
|
1822
|
+
* the derived-key range.
|
|
1823
|
+
*
|
|
1824
|
+
* An index that derives its partition (#149) is queried in `partition`,
|
|
1825
|
+
* which other streams (and this one's other stream ids, and snapshots) may
|
|
1826
|
+
* share: the `__edd_e__` filter keeps this stream's events, and a filter on
|
|
1827
|
+
* the table's `pk` — projected by every index — keeps `streamId`'s.
|
|
1330
1828
|
*
|
|
1331
1829
|
* @throws `[EDD-9066]` for a name the stream does not declare (unreachable
|
|
1332
1830
|
* through the types).
|
|
1831
|
+
* @throws `[EDD-9080]` for a missing `partition` on an index that derives
|
|
1832
|
+
* it, or one passed to an index that does not (unreachable through the
|
|
1833
|
+
* types).
|
|
1333
1834
|
*/
|
|
1334
|
-
const buildIndexQuery = (name, streamId) => {
|
|
1835
|
+
const buildIndexQuery = (name, streamId, partition) => {
|
|
1335
1836
|
const idx = streamIndexes.find((candidate) => candidate.name === name);
|
|
1336
1837
|
if (idx === undefined) {
|
|
1337
1838
|
throw new Error(`[EDD-9066] EventStream "${config.streamName}": unknown index "${String(name)}". ` +
|
|
@@ -1339,14 +1840,23 @@ export const makeStream = (config) => {
|
|
|
1339
1840
|
? "The stream declares no indexes."
|
|
1340
1841
|
: `Declared: ${streamIndexes.map((candidate) => `"${candidate.name}"`).join(", ")}.`));
|
|
1341
1842
|
}
|
|
1342
|
-
|
|
1843
|
+
const partitionProblem = invalidIndexPartition(name, partition);
|
|
1844
|
+
if (partitionProblem !== undefined) {
|
|
1845
|
+
throw new Error(`[EDD-9080] EventStream "${config.streamName}": ${partitionProblem}`);
|
|
1846
|
+
}
|
|
1847
|
+
const streamPk = composeStreamPk(streamId);
|
|
1848
|
+
const query = Query.make({
|
|
1343
1849
|
tableName: "",
|
|
1344
1850
|
indexName: idx.index,
|
|
1345
1851
|
pkField: idx.pk,
|
|
1346
|
-
pkValue:
|
|
1852
|
+
pkValue: idx.partition === "derived" ? partition : streamPk,
|
|
1347
1853
|
skField: idx.sk,
|
|
1348
1854
|
entityTypes: [entityType],
|
|
1349
1855
|
decoder: indexDecoder(idx),
|
|
1856
|
+
// As for `query.events` (#147) — the `"keys"` hydration path decodes
|
|
1857
|
+
// its fetched rows through the same hooks.
|
|
1858
|
+
rawRow: rawEventRow,
|
|
1859
|
+
packed: dataGuards,
|
|
1350
1860
|
resolveTableName: config.table.Tag.useSync((tc) => tc.name),
|
|
1351
1861
|
// DynamoDB's rejection of the key condition (an empty bound, a reversed
|
|
1352
1862
|
// `between`) is named against the stream index; so, as for every
|
|
@@ -1367,6 +1877,7 @@ export const makeStream = (config) => {
|
|
|
1367
1877
|
}
|
|
1368
1878
|
: {}),
|
|
1369
1879
|
});
|
|
1880
|
+
return idx.partition === "derived" ? Query.filterExpr(query, streamPkFilter(streamPk)) : query;
|
|
1370
1881
|
};
|
|
1371
1882
|
/**
|
|
1372
1883
|
* @internal The decoder of events read through `idx`.
|
|
@@ -1413,7 +1924,11 @@ export const makeStream = (config) => {
|
|
|
1413
1924
|
if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) {
|
|
1414
1925
|
return yield* refuse(`limit must be a positive integer; received ${String(limit)}. Nothing was sent.`);
|
|
1415
1926
|
}
|
|
1416
|
-
|
|
1927
|
+
const partitionProblem = invalidIndexPartition(name, options?.partition);
|
|
1928
|
+
if (partitionProblem !== undefined) {
|
|
1929
|
+
return yield* refuse(`${partitionProblem} Nothing was sent.`);
|
|
1930
|
+
}
|
|
1931
|
+
let query = buildIndexQuery(name, streamId, options?.partition);
|
|
1417
1932
|
if (options?.beginsWith !== undefined) {
|
|
1418
1933
|
query = Query.where(query, { beginsWith: options.beginsWith });
|
|
1419
1934
|
}
|
|
@@ -1430,7 +1945,7 @@ export const makeStream = (config) => {
|
|
|
1430
1945
|
});
|
|
1431
1946
|
const queryNamespace = {
|
|
1432
1947
|
events: (streamId) => buildEventsQuery(streamId),
|
|
1433
|
-
index: (name, streamId) => buildIndexQuery(name, streamId),
|
|
1948
|
+
index: (name, streamId, options) => buildIndexQuery(name, streamId, options?.partition),
|
|
1434
1949
|
};
|
|
1435
1950
|
// ---------------------------------------------------------------------------
|
|
1436
1951
|
// Return EventStream
|
|
@@ -1449,19 +1964,23 @@ export const makeStream = (config) => {
|
|
|
1449
1964
|
streamName: config.streamName,
|
|
1450
1965
|
eventSchema: eventUnion,
|
|
1451
1966
|
snapshotConfig: snapshotSettings,
|
|
1967
|
+
storedAs: storage,
|
|
1452
1968
|
indexes: indexSettings,
|
|
1453
|
-
writeSnapshot,
|
|
1454
|
-
readSnapshot,
|
|
1455
|
-
append,
|
|
1456
|
-
read,
|
|
1457
|
-
readFrom,
|
|
1458
|
-
currentVersion,
|
|
1459
|
-
readLatest,
|
|
1460
|
-
readIndex,
|
|
1969
|
+
writeSnapshot: checked(writeSnapshot),
|
|
1970
|
+
readSnapshot: checked(readSnapshot),
|
|
1971
|
+
append: checked(append),
|
|
1972
|
+
read: checked(read),
|
|
1973
|
+
readFrom: checked(readFrom),
|
|
1974
|
+
currentVersion: checked(currentVersion),
|
|
1975
|
+
readLatest: checked(readLatest),
|
|
1976
|
+
readIndex: checked(readIndex),
|
|
1977
|
+
measureAppend: checked(measureAppend),
|
|
1978
|
+
measureSnapshot: checked(measureSnapshot),
|
|
1979
|
+
decodeStreamImage: checked(decodeStreamImage),
|
|
1461
1980
|
query: queryNamespace,
|
|
1462
1981
|
[StreamIdFormatter]: composeStreamIdString,
|
|
1463
|
-
[CommandSentinelProbe]: hasCommandSentinel,
|
|
1464
|
-
[HeadAboveProbe]: headAbove,
|
|
1982
|
+
[CommandSentinelProbe]: checked(hasCommandSentinel),
|
|
1983
|
+
[HeadAboveProbe]: checked(headAbove),
|
|
1465
1984
|
};
|
|
1466
1985
|
};
|
|
1467
1986
|
// ---------------------------------------------------------------------------
|
|
@@ -1483,6 +2002,11 @@ export const makeStream = (config) => {
|
|
|
1483
2002
|
*/
|
|
1484
2003
|
export const bind = (stream) => Effect.gen(function* () {
|
|
1485
2004
|
const ctx = yield* Effect.context();
|
|
2005
|
+
// The stream meets values compressed with every codec its `storedAs`
|
|
2006
|
+
// names, so the registry captured here must have them (#147).
|
|
2007
|
+
const codecError = unregisteredCodecError("EventStore.bind", streamCodecRequirements(stream.streamName, stream.storedAs), Context.getOrUndefined(ctx, Compression));
|
|
2008
|
+
if (codecError !== undefined)
|
|
2009
|
+
throw codecError;
|
|
1486
2010
|
const provide = (effect) => Effect.provide(effect, ctx);
|
|
1487
2011
|
const probe = commandSentinelProbeOf(stream);
|
|
1488
2012
|
const headProbe = headAboveProbeOf(stream);
|
|
@@ -1495,6 +2019,7 @@ export const bind = (stream) => Effect.gen(function* () {
|
|
|
1495
2019
|
streamName: stream.streamName,
|
|
1496
2020
|
eventSchema: stream.eventSchema,
|
|
1497
2021
|
snapshotConfig: stream.snapshotConfig,
|
|
2022
|
+
storedAs: stream.storedAs,
|
|
1498
2023
|
indexes: stream.indexes,
|
|
1499
2024
|
writeSnapshot: (streamId, state, asOfVersion) => provide(stream.writeSnapshot(streamId, state, asOfVersion)),
|
|
1500
2025
|
readSnapshot: (streamId) => provide(stream.readSnapshot(streamId)),
|
|
@@ -1503,7 +2028,12 @@ export const bind = (stream) => Effect.gen(function* () {
|
|
|
1503
2028
|
readFrom: (streamId, afterVersion, options) => provide(stream.readFrom(streamId, afterVersion, options)),
|
|
1504
2029
|
currentVersion: (streamId, options) => provide(stream.currentVersion(streamId, options)),
|
|
1505
2030
|
readLatest: (streamId, options) => provide(stream.readLatest(streamId, options)),
|
|
1506
|
-
readIndex: (name, streamId, options) => provide(stream.readIndex(name, streamId, options)),
|
|
2031
|
+
readIndex: (name, streamId, ...options) => provide(stream.readIndex(name, streamId, ...options)),
|
|
2032
|
+
// The captured context carries a `Compression` registry provided where
|
|
2033
|
+
// the stream was bound (#147).
|
|
2034
|
+
measureAppend: (streamId, events, expectedVersion, options) => provide(stream.measureAppend(streamId, events, expectedVersion, options)),
|
|
2035
|
+
measureSnapshot: (streamId, state, asOfVersion) => provide(stream.measureSnapshot(streamId, state, asOfVersion)),
|
|
2036
|
+
decodeStreamImage: ((image, options) => provide(stream.decodeStreamImage(image, options))),
|
|
1507
2037
|
query: stream.query,
|
|
1508
2038
|
provide,
|
|
1509
2039
|
[StreamIdFormatter]: (id) => formatStreamIdOf(stream, id),
|
|
@@ -1523,12 +2053,22 @@ export const bind = (stream) => Effect.gen(function* () {
|
|
|
1523
2053
|
* Neither list names a key attribute: table and index keys are always
|
|
1524
2054
|
* projected.
|
|
1525
2055
|
*/
|
|
1526
|
-
const projectionDefinition = (projection) => projection === "all"
|
|
2056
|
+
const projectionDefinition = (projection, snapshot) => projection === "all"
|
|
1527
2057
|
? { ProjectionType: "ALL" }
|
|
1528
2058
|
: {
|
|
1529
2059
|
ProjectionType: "INCLUDE",
|
|
1530
|
-
NonKeyAttributes: projection === "keys"
|
|
2060
|
+
NonKeyAttributes: projection === "keys"
|
|
2061
|
+
? ["__edd_e__"]
|
|
2062
|
+
: snapshot
|
|
2063
|
+
? [...EVENT_ITEM_ATTRIBUTES, ...SNAPSHOT_ONLY_ATTRIBUTES].sort()
|
|
2064
|
+
: [...EVENT_ITEM_ATTRIBUTES],
|
|
1531
2065
|
};
|
|
2066
|
+
/**
|
|
2067
|
+
* @internal The snapshot item's attributes an event item does not carry: what
|
|
2068
|
+
* a `"withoutMetadata"` index that holds snapshots (#149) projects besides
|
|
2069
|
+
* {@link EVENT_ITEM_ATTRIBUTES}.
|
|
2070
|
+
*/
|
|
2071
|
+
const SNAPSHOT_ONLY_ATTRIBUTES = ["asOfVersion", "state"];
|
|
1532
2072
|
/**
|
|
1533
2073
|
* Derive the `CreateTable` fragments for the stream indexes (#140) of the given
|
|
1534
2074
|
* streams (`EventStream` or `BoundEventStream`) — `AttributeDefinitions`,
|
|
@@ -1543,7 +2083,7 @@ const projectionDefinition = (projection) => projection === "all"
|
|
|
1543
2083
|
* | `projection` | `Projection` |
|
|
1544
2084
|
* |---|---|
|
|
1545
2085
|
* | `"all"` (default) | `{ ProjectionType: "ALL" }` |
|
|
1546
|
-
* | `"withoutMetadata"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__", "data", "eventType", "streamId", "timestamp", "version"] }` |
|
|
2086
|
+
* | `"withoutMetadata"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__", "data", "eventType", "streamId", "timestamp", "version"] }` — plus `"asOfVersion"` and `"state"` when a stream declaring the index puts its snapshot in it (#149) |
|
|
1547
2087
|
* | `"keys"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__"] }` — not `KEYS_ONLY`: every read filters on `__edd_e__` |
|
|
1548
2088
|
*
|
|
1549
2089
|
* The deployed index must project what is declared — copy `Projection` from
|
|
@@ -1554,7 +2094,12 @@ const projectionDefinition = (projection) => projection === "all"
|
|
|
1554
2094
|
* table over the limit).
|
|
1555
2095
|
*
|
|
1556
2096
|
* An index shared by several streams with the same definition is emitted
|
|
1557
|
-
* once
|
|
2097
|
+
* once — the way several streams write one GSI whose partition they derive
|
|
2098
|
+
* (`partition`, #149): an order's line, payment and shipment streams under
|
|
2099
|
+
* `ORDER#<orderId>`, say. Whether each declaration derives its partition or
|
|
2100
|
+
* indexes its snapshot is not part of the definition: the index's key schema
|
|
2101
|
+
* and projection kind are. Lists are sorted by index name, attributes by
|
|
2102
|
+
* attribute name.
|
|
1558
2103
|
*
|
|
1559
2104
|
* - An LSI can only be created with the table, and caps the item collection
|
|
1560
2105
|
* of every partition key value in the table at 10 GB — entity partitions
|
|
@@ -1582,25 +2127,32 @@ const projectionDefinition = (projection) => projection === "all"
|
|
|
1582
2127
|
*
|
|
1583
2128
|
* @throws `[EDD-9067]` when two streams define the same physical index
|
|
1584
2129
|
* differently (another type, other key attributes, or another projection).
|
|
2130
|
+
* Whether a declaration derives its partition or indexes its snapshot is not
|
|
2131
|
+
* part of the index's definition: streams that differ only there share it.
|
|
1585
2132
|
*/
|
|
1586
2133
|
export const indexDefinitions = (...streams) => {
|
|
2134
|
+
/** Per physical index: its definition, its first declarer, and whether any declarer indexes snapshots. */
|
|
1587
2135
|
const byIndex = new Map();
|
|
1588
2136
|
for (const stream of streams) {
|
|
1589
2137
|
for (const [name, raw] of Object.entries(stream.indexes)) {
|
|
1590
|
-
const settings = {
|
|
2138
|
+
const settings = {
|
|
2139
|
+
type: raw.type,
|
|
2140
|
+
index: raw.index,
|
|
2141
|
+
pk: raw.pk,
|
|
2142
|
+
sk: raw.sk,
|
|
2143
|
+
projection: raw.projection ?? "all",
|
|
2144
|
+
};
|
|
2145
|
+
const snapshot = raw.snapshot === true;
|
|
1591
2146
|
const owner = `index "${name}" of stream "${stream.streamName}"`;
|
|
1592
2147
|
const existing = byIndex.get(settings.index);
|
|
1593
2148
|
if (existing === undefined) {
|
|
1594
|
-
byIndex.set(settings.index, {
|
|
1595
|
-
type: settings.type,
|
|
1596
|
-
index: settings.index,
|
|
1597
|
-
pk: settings.pk,
|
|
1598
|
-
sk: settings.sk,
|
|
1599
|
-
projection: settings.projection,
|
|
1600
|
-
owner,
|
|
1601
|
-
});
|
|
2149
|
+
byIndex.set(settings.index, { ...settings, owner, snapshot });
|
|
1602
2150
|
continue;
|
|
1603
2151
|
}
|
|
2152
|
+
// Several streams writing one GSI they derive the partition of (#149)
|
|
2153
|
+
// is the point: one declaration indexing snapshots and another not
|
|
2154
|
+
// still share it, and the projection covers the snapshots.
|
|
2155
|
+
existing.snapshot ||= snapshot;
|
|
1604
2156
|
if (existing.type !== settings.type ||
|
|
1605
2157
|
existing.pk !== settings.pk ||
|
|
1606
2158
|
existing.sk !== settings.sk ||
|
|
@@ -1623,7 +2175,7 @@ export const indexDefinitions = (...streams) => {
|
|
|
1623
2175
|
{ AttributeName: settings.pk, KeyType: "HASH" },
|
|
1624
2176
|
{ AttributeName: settings.sk, KeyType: "RANGE" },
|
|
1625
2177
|
],
|
|
1626
|
-
Projection: projectionDefinition(settings.projection),
|
|
2178
|
+
Projection: projectionDefinition(settings.projection, settings.snapshot),
|
|
1627
2179
|
};
|
|
1628
2180
|
attributes.add(settings.sk);
|
|
1629
2181
|
if (settings.type === "gsi") {
|