effect-dynamodb 1.31.0 → 1.32.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.
@@ -11,7 +11,11 @@
11
11
  * - `commandHandler` combinator for the load-decide-fold-append cycle
12
12
  * (snapshot-aware single-request loads, consistent loads, an optional
13
13
  * `VersionConflict` retry policy, caller-supplied `expectedVersion`,
14
- * decision-derived `additionalItems`, inline snapshots)
14
+ * decision-derived `additionalItems`, inline snapshots, a caller's own
15
+ * state record as the load via `loadState`)
16
+ * - Head proofs (`append({ headProof })`, #164): an additional item or the
17
+ * inline snapshot proves the stream head in place of the version-contiguity
18
+ * `ConditionCheck`
15
19
  * - Stream indexes (`makeStream({ indexes })`): sub-streams ordered by a key
16
20
  * derived from each event, on an LSI or GSI, each projecting all of the
17
21
  * event, all but its metadata, or only its keys — `readIndex`,
@@ -48,7 +52,7 @@ import { itemBytes, transactItemBytes } from "./internal/ItemSize.js";
48
52
  import { isMultiProjection, } from "./internal/MultiProjection.js";
49
53
  import { codecRequirements, currentRegistry, decodeImage, imageEntityType, runtimeCodecCheck, unregisteredCodecError, withRegistry, } from "./internal/Packed.js";
50
54
  import { createPathBuilder } from "./internal/PathBuilder.js";
51
- import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation, transactItemTarget, } from "./internal/TransactWriteOps.js";
55
+ import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation, opConditionFailed, transactItemTarget, } from "./internal/TransactWriteOps.js";
52
56
  import { diagnosedValidationError, diagnoseFailure, mergeKeyAttributes, withCause, } from "./internal/WriteDiagnosis.js";
53
57
  import { fromAttributeMap, toAttributeMap } from "./Marshaller.js";
54
58
  import * as Query from "./Query.js";
@@ -410,6 +414,38 @@ const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings
410
414
  `snapshot mode "inline" without "every" — the only mode that writes the snapshot in every ` +
411
415
  `append, so it is normally at the head. This stream's snapshot is mode "${settings.mode}"` +
412
416
  `${settings.every === undefined ? "" : ` with every: ${settings.every}`}.`;
417
+ /**
418
+ * @internal `[EDD-9085]`: a snapshot head proof (`headProof: "snapshot"`) is
419
+ * accepted only by a stream whose snapshot is written in every append
420
+ * (`mode: "inline"` without `every`) — any other snapshot lags the head by
421
+ * design, and would fail the proof's condition. Returns the refusal message,
422
+ * or `undefined` when the stream accepts it.
423
+ */
424
+ const snapshotProofRefusal = (streamName, settings, operation) => settings !== undefined && settings.mode === "inline" && settings.every === undefined
425
+ ? undefined
426
+ : `[EDD-9085] EventStream "${streamName}": ${operation} with headProof: "snapshot" requires ` +
427
+ `snapshot mode "inline" without "every" — the only mode that writes the snapshot in every ` +
428
+ `append, so it can prove the head. ` +
429
+ (settings === undefined
430
+ ? "This stream has no snapshot config."
431
+ : `This stream's snapshot is mode "${settings.mode}"` +
432
+ `${settings.every === undefined ? "" : ` with every: ${settings.every}`}.`);
433
+ /**
434
+ * @internal Why a `headProof` is malformed — not `"snapshot"` nor
435
+ * `{ additionalItem: n }` with `n` a non-negative integer — or `undefined`
436
+ * when it is well-formed.
437
+ */
438
+ const headProofShapeProblem = (headProof) => {
439
+ if (headProof === "snapshot")
440
+ return undefined;
441
+ const n = typeof headProof === "object" && headProof !== null
442
+ ? headProof.additionalItem
443
+ : undefined;
444
+ return typeof n === "number" && Number.isInteger(n) && n >= 0
445
+ ? undefined
446
+ : `headProof must be "snapshot" or { additionalItem: n } with n a non-negative integer; ` +
447
+ `received ${JSON.stringify(headProof) ?? String(headProof)}. Nothing was written.`;
448
+ };
413
449
  /**
414
450
  * @internal Why an `eventTimestamps` value cannot be written for `count`
415
451
  * events — not an array, the wrong length, or an entry that is neither a
@@ -946,6 +982,20 @@ export const makeStream = (config) => {
946
982
  }));
947
983
  }
948
984
  }
985
+ // The head proof (#164) stands in for the contiguity check, so its
986
+ // own condition failing shares their verdict — ahead of the other
987
+ // additional items.
988
+ const proofFailed = tx.headProof === "snapshot"
989
+ ? failedAt(tx.snapshotIndex)
990
+ : tx.headProof !== undefined &&
991
+ opConditionFailed(tx.additional, rawReasons, tx.headProof.additionalItem, tx.guardCount + tx.eventCount);
992
+ if (proofFailed) {
993
+ return Effect.fail(new VersionConflict({
994
+ streamName: config.streamName,
995
+ streamId: streamIdStr,
996
+ expectedVersion: tx.expectedVersion,
997
+ }));
998
+ }
949
999
  // The additional items, attributed back to the caller OPS that
950
1000
  // produced them (several items can belong to one op). Only an op's
951
1001
  // own condition is `AdditionalItemConditionFailed`: a guarded put
@@ -1153,6 +1203,33 @@ export const makeStream = (config) => {
1153
1203
  if (snapshotState !== undefined && snapshot === undefined) {
1154
1204
  return yield* snapshotUnavailable("append({ snapshot })");
1155
1205
  }
1206
+ // The head proof (#164), checked ahead of the no-op fast path, as the
1207
+ // per-event options are. An index into `additionalItems` is checked by
1208
+ // `append`, which has them (`measureAppend` does not take them).
1209
+ const headProof = options?.headProof;
1210
+ if (headProof !== undefined) {
1211
+ const problem = headProofShapeProblem(headProof);
1212
+ if (problem !== undefined) {
1213
+ return yield* new ValidationError({
1214
+ entityType,
1215
+ operation: "EventStore.append.headProof",
1216
+ cause: problem,
1217
+ });
1218
+ }
1219
+ if (headProof === "snapshot") {
1220
+ const refusal = snapshotProofRefusal(config.streamName, snapshotSettings, 'append({ headProof: "snapshot" })');
1221
+ if (refusal !== undefined)
1222
+ return yield* Effect.die(new Error(refusal));
1223
+ if (snapshotState === undefined) {
1224
+ return yield* new ValidationError({
1225
+ entityType: snapshotEntityType,
1226
+ operation: "EventStore.append.headProof",
1227
+ cause: 'headProof: "snapshot" requires the inline snapshot it names: pass `snapshot` ' +
1228
+ "with the state as of this append. Nothing was written.",
1229
+ });
1230
+ }
1231
+ }
1232
+ }
1156
1233
  // Nothing at all to write — preserve the historical no-op fast path.
1157
1234
  // With additional items or a dedup sentinel the transaction still runs:
1158
1235
  // a caller who asked for a side write means it, and silently dropping it
@@ -1180,7 +1257,8 @@ export const makeStream = (config) => {
1180
1257
  // (#144): an append is never split — splitting would break its
1181
1258
  // atomicity — so one over the limit is rejected whole by DynamoDB and
1182
1259
  // reported as `AppendTooLarge`, with the expanded item count.
1183
- const needsContiguityCheck = expectedVersion > 0 && events.length > 0;
1260
+ // A head proof (#164) proves the same thing, so it replaces the check.
1261
+ const needsContiguityCheck = expectedVersion > 0 && events.length > 0 && headProof === undefined;
1184
1262
  const tableConfig = yield* config.table.Tag;
1185
1263
  const tableName = tableConfig.name;
1186
1264
  // A custom TTL attribute is only known at runtime. An index key attribute
@@ -1347,16 +1425,31 @@ export const makeStream = (config) => {
1347
1425
  },
1348
1426
  };
1349
1427
  }
1350
- // The inline snapshot (#138): unconditional — see AppendOptions.snapshot.
1351
- // Packed per `snapshot.storedAs` (#147), so the transaction-size and
1352
- // item-size diagnosis count its stored bytes.
1428
+ // The inline snapshot (#138): unconditional — see AppendOptions.snapshot
1429
+ // — unless it is the head proof (#164), when it is conditioned on being
1430
+ // at `expectedVersion`. Packed per `snapshot.storedAs` (#147), so the
1431
+ // transaction-size and item-size diagnosis count its stored bytes.
1353
1432
  let snapshotPut;
1354
1433
  if (snapshotState !== undefined) {
1434
+ const item = yield* buildSnapshotItem(streamId, snapshotState, expectedVersion + events.length, now, "EventStore.append.snapshot", eventPartitions);
1355
1435
  snapshotPut = {
1356
- Put: {
1357
- TableName: tableName,
1358
- Item: yield* buildSnapshotItem(streamId, snapshotState, expectedVersion + events.length, now, "EventStore.append.snapshot", eventPartitions),
1359
- },
1436
+ Put: headProof !== "snapshot"
1437
+ ? { TableName: tableName, Item: item }
1438
+ : expectedVersion === 0
1439
+ ? {
1440
+ TableName: tableName,
1441
+ Item: item,
1442
+ ConditionExpression: "attribute_not_exists(pk)",
1443
+ }
1444
+ : {
1445
+ TableName: tableName,
1446
+ Item: item,
1447
+ ConditionExpression: "#asOfVersion = :expectedVersion",
1448
+ ExpressionAttributeNames: { "#asOfVersion": "asOfVersion" },
1449
+ ExpressionAttributeValues: toAttributeMap({
1450
+ ":expectedVersion": expectedVersion,
1451
+ }),
1452
+ },
1360
1453
  };
1361
1454
  }
1362
1455
  /**
@@ -1393,6 +1486,22 @@ export const makeStream = (config) => {
1393
1486
  const append = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
1394
1487
  const additionalOps = options?.additionalItems ?? [];
1395
1488
  const idempotency = options?.idempotency;
1489
+ const headProof = options?.headProof;
1490
+ // The additional item a head proof names (#164) must exist — checked
1491
+ // before anything is read, and ahead of the no-op fast path. Its shape
1492
+ // is checked first, by `buildAppend`'s own rule.
1493
+ const proofOp = typeof headProof === "object" && headProofShapeProblem(headProof) === undefined
1494
+ ? headProof.additionalItem
1495
+ : undefined;
1496
+ if (proofOp !== undefined && proofOp >= additionalOps.length) {
1497
+ return yield* new ValidationError({
1498
+ entityType,
1499
+ operation: "EventStore.append.headProof",
1500
+ cause: `headProof names additionalItems[${proofOp}], but the append has ` +
1501
+ `${additionalOps.length} additional item${additionalOps.length === 1 ? "" : "s"}. ` +
1502
+ "Nothing was written.",
1503
+ });
1504
+ }
1396
1505
  const parts = yield* buildAppend(streamId, events, expectedVersion, options);
1397
1506
  if (parts === undefined)
1398
1507
  return { version: expectedVersion, events: [] };
@@ -1449,6 +1558,7 @@ export const makeStream = (config) => {
1449
1558
  sentinelIndex,
1450
1559
  snapshotIndex,
1451
1560
  expectedVersion,
1561
+ headProof,
1452
1562
  };
1453
1563
  };
1454
1564
  // A guarded put among the additional items reads its item; a race with
@@ -1458,6 +1568,17 @@ export const makeStream = (config) => {
1458
1568
  // Caller-owned items, compiled through the same builder
1459
1569
  // `Transaction.transactWrite` uses, so the two APIs cannot drift.
1460
1570
  const additional = yield* buildTransactWriteItems(additionalOps, "EventStore.append.additionalItems");
1571
+ // An item without a condition of its own proves nothing about the
1572
+ // head: accepting it would drop the gap protection silently.
1573
+ if (proofOp !== undefined && !additional.conditioned.has(proofOp)) {
1574
+ return yield* new ValidationError({
1575
+ entityType,
1576
+ operation: "EventStore.append.headProof",
1577
+ cause: `headProof names additionalItems[${proofOp}], which carries no condition of its ` +
1578
+ "own: the proof must be conditioned on the stream being at expectedVersion " +
1579
+ "(`.condition()`, `create()` or `Transaction.check`). Nothing was written.",
1580
+ });
1581
+ }
1461
1582
  const tx = layout(additional);
1462
1583
  // DynamoDB's rules — one operation per item (an additional item
1463
1584
  // repeating an event, the contiguity check, the idempotency sentinel
@@ -2264,6 +2385,34 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
2264
2385
  }
2265
2386
  // Ignored on a stream without a snapshot config: there is no snapshot to trust.
2266
2387
  const unverifiedLoads = options?.verifySnapshot === false && snapshotSettings !== undefined;
2388
+ // The head proof and a caller's load (#164), refused up front (EDD-9085)
2389
+ // when they cannot work together or on this stream.
2390
+ const loadState = options?.loadState;
2391
+ const headProof = options?.headProof;
2392
+ const refuse = (problem) => {
2393
+ throw new Error(`[EDD-9085] EventStream "${stream.streamName}": commandHandler ${problem}`);
2394
+ };
2395
+ if (loadState !== undefined && typeof loadState !== "function") {
2396
+ refuse("loadState must be a function of the stream id.");
2397
+ }
2398
+ if (headProof !== undefined) {
2399
+ const problem = headProofShapeProblem(headProof);
2400
+ if (problem !== undefined)
2401
+ refuse(problem.replace(" Nothing was written.", ""));
2402
+ if (headProof === "snapshot") {
2403
+ const refusal = snapshotProofRefusal(stream.streamName, snapshotSettings, "commandHandler");
2404
+ if (refusal !== undefined)
2405
+ throw new Error(refusal);
2406
+ }
2407
+ }
2408
+ if (loadState !== undefined && options?.verifySnapshot === false) {
2409
+ refuse("with loadState and verifySnapshot: false — loadState replaces the stream load, so " +
2410
+ "there is no snapshot load to leave unverified.");
2411
+ }
2412
+ if (loadState !== undefined && headProof === "snapshot") {
2413
+ refuse('with loadState and headProof: "snapshot" — the handler never reads the snapshot, so ' +
2414
+ "it cannot know the snapshot is at the head.");
2415
+ }
2267
2416
  const retryPolicy = options?.retry;
2268
2417
  const schedule = retryPolicy === undefined
2269
2418
  ? undefined
@@ -2282,6 +2431,43 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
2282
2431
  // replays from the beginning.
2283
2432
  const load = (verify) => Effect.gen(function* () {
2284
2433
  let state = decider.initialState;
2434
+ // The caller's own record (#164), trusted as the head: the append's
2435
+ // check (or the head proof) catches one that is not.
2436
+ if (loadState !== undefined) {
2437
+ const result = yield* loadState(streamId);
2438
+ const refused = (cause) => new ValidationError({
2439
+ entityType: stream.streamName,
2440
+ operation: "EventStore.commandHandler.loadState",
2441
+ cause,
2442
+ });
2443
+ if (!Option.isOption(result)) {
2444
+ return yield* refused("loadState must return an Option of { state, version }: None for a new stream. " +
2445
+ "Nothing was written.");
2446
+ }
2447
+ if (Option.isNone(result)) {
2448
+ const fresh = {
2449
+ state,
2450
+ version: 0,
2451
+ snapshotAsOfVersion: undefined,
2452
+ verified: true,
2453
+ eventsAfterSnapshot: 0,
2454
+ };
2455
+ return fresh;
2456
+ }
2457
+ const record = result.value;
2458
+ if (!Number.isInteger(record.version) || record.version < 0) {
2459
+ return yield* refused(`loadState returned version ${String(record.version)}: it must be a non-negative ` +
2460
+ "integer, the stream version the state is as of. Nothing was written.");
2461
+ }
2462
+ const loaded = {
2463
+ state: record.state,
2464
+ version: record.version,
2465
+ snapshotAsOfVersion: undefined,
2466
+ verified: true,
2467
+ eventsAfterSnapshot: 0,
2468
+ };
2469
+ return loaded;
2470
+ }
2285
2471
  if (snapshotSettings !== undefined) {
2286
2472
  const latest = (yield* stream.readLatest(streamId, verify ? readOptions : unverifiedReadOptions));
2287
2473
  let snapshotAsOfVersion = 0;
@@ -2400,14 +2586,31 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
2400
2586
  }
2401
2587
  // Inline snapshots (#138) ride in the append transaction: on every
2402
2588
  // append, or once `every` events have accumulated since the snapshot.
2589
+ // A `loadState` load never saw the snapshot (#164), so its cadence is
2590
+ // an append that crosses a multiple of `every`.
2403
2591
  const mode = snapshotSettings?.mode ?? "after-append";
2404
2592
  const every = snapshotSettings?.every;
2405
2593
  const newVersion = baseVersion + newEvents.length;
2594
+ const snapshotDue = (version) => every !== undefined &&
2595
+ (loaded.snapshotAsOfVersion === undefined
2596
+ ? Math.floor(version / every) > Math.floor(baseVersion / every)
2597
+ : version - loaded.snapshotAsOfVersion >= every);
2406
2598
  if (snapshotSettings !== undefined &&
2407
2599
  mode === "inline" &&
2408
- (every === undefined || newVersion - loaded.snapshotAsOfVersion >= every)) {
2600
+ (every === undefined || snapshotDue(newVersion))) {
2409
2601
  appendOptions.snapshot = next;
2410
2602
  }
2603
+ // The head proof (#164). The caller's record proves it on every
2604
+ // append; the inline snapshot only when this load found it at the
2605
+ // head — a lagging one would fail the condition, so the check stays.
2606
+ if (typeof headProof === "object") {
2607
+ appendOptions.headProof = headProof;
2608
+ }
2609
+ else if (headProof === "snapshot" &&
2610
+ appendOptions.snapshot !== undefined &&
2611
+ loaded.snapshotAsOfVersion === baseVersion) {
2612
+ appendOptions.headProof = "snapshot";
2613
+ }
2411
2614
  const appended = stream.append(streamId, newEvents, baseVersion, appendOptions);
2412
2615
  const outcome = yield* returnAppendConflict
2413
2616
  ? appended.pipe(Effect.catchIf((error) => error instanceof VersionConflict, (conflict) => Effect.succeed(conflict)))
@@ -2419,9 +2622,7 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
2419
2622
  // Best-effort: the events are already durable, so a snapshot-write
2420
2623
  // failure must not report the command as failed. The next threshold
2421
2624
  // crossing retries it.
2422
- if (mode === "after-append" &&
2423
- every !== undefined &&
2424
- result.version - loaded.snapshotAsOfVersion >= every) {
2625
+ if (mode === "after-append" && snapshotDue(result.version)) {
2425
2626
  yield* stream
2426
2627
  .writeSnapshot(streamId, next, result.version)
2427
2628
  .pipe(Effect.catchCause((cause) => Effect.logWarning(`EventStore: snapshot write failed for stream "${stream.streamName}" at version ${result.version}`, cause)));