effect-dynamodb 1.34.1 → 1.36.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.
Files changed (76) hide show
  1. package/dist/Aggregate.d.ts +1 -1
  2. package/dist/Aggregate.d.ts.map +1 -1
  3. package/dist/Aggregate.js +22 -6
  4. package/dist/Aggregate.js.map +1 -1
  5. package/dist/Batch.d.ts +1 -1
  6. package/dist/Batch.d.ts.map +1 -1
  7. package/dist/Batch.js +7 -5
  8. package/dist/Batch.js.map +1 -1
  9. package/dist/ConsumedCapacity.d.ts +107 -0
  10. package/dist/ConsumedCapacity.d.ts.map +1 -0
  11. package/dist/ConsumedCapacity.js +86 -0
  12. package/dist/ConsumedCapacity.js.map +1 -0
  13. package/dist/DynamoClient.d.ts.map +1 -1
  14. package/dist/DynamoClient.js +6 -1
  15. package/dist/DynamoClient.js.map +1 -1
  16. package/dist/Entity.d.ts +1 -1
  17. package/dist/Entity.d.ts.map +1 -1
  18. package/dist/Entity.js +76 -42
  19. package/dist/Entity.js.map +1 -1
  20. package/dist/EventStore.d.ts +78 -10
  21. package/dist/EventStore.d.ts.map +1 -1
  22. package/dist/EventStore.js +219 -35
  23. package/dist/EventStore.js.map +1 -1
  24. package/dist/Query.d.ts +15 -0
  25. package/dist/Query.d.ts.map +1 -1
  26. package/dist/Query.js +23 -8
  27. package/dist/Query.js.map +1 -1
  28. package/dist/Transaction.d.ts +1 -1
  29. package/dist/Transaction.d.ts.map +1 -1
  30. package/dist/Transaction.js +7 -5
  31. package/dist/Transaction.js.map +1 -1
  32. package/dist/change-feed/ChangeFeed.d.ts.map +1 -1
  33. package/dist/change-feed/ChangeFeed.js +7 -4
  34. package/dist/change-feed/ChangeFeed.js.map +1 -1
  35. package/dist/change-feed/CheckpointStore.d.ts +1 -1
  36. package/dist/change-feed/CheckpointStore.d.ts.map +1 -1
  37. package/dist/change-feed/CheckpointStore.js +11 -3
  38. package/dist/change-feed/CheckpointStore.js.map +1 -1
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/internal/BoundCrud.js +3 -3
  44. package/dist/internal/BoundCrud.js.map +1 -1
  45. package/dist/internal/BoundQuery.d.ts +16 -0
  46. package/dist/internal/BoundQuery.d.ts.map +1 -1
  47. package/dist/internal/BoundQuery.js +21 -4
  48. package/dist/internal/BoundQuery.js.map +1 -1
  49. package/dist/internal/BoundVectorQuery.d.ts +1 -1
  50. package/dist/internal/BoundVectorQuery.d.ts.map +1 -1
  51. package/dist/internal/BoundVectorQuery.js +4 -3
  52. package/dist/internal/BoundVectorQuery.js.map +1 -1
  53. package/dist/internal/Capacity.d.ts +254 -0
  54. package/dist/internal/Capacity.d.ts.map +1 -0
  55. package/dist/internal/Capacity.js +653 -0
  56. package/dist/internal/Capacity.js.map +1 -0
  57. package/dist/internal/EntityCombinators.d.ts.map +1 -1
  58. package/dist/internal/EntityCombinators.js +4 -4
  59. package/dist/internal/EntityCombinators.js.map +1 -1
  60. package/dist/internal/EntityOps.d.ts +10 -1
  61. package/dist/internal/EntityOps.d.ts.map +1 -1
  62. package/dist/internal/EntityOps.js +22 -4
  63. package/dist/internal/EntityOps.js.map +1 -1
  64. package/dist/internal/EventProjection.d.ts.map +1 -1
  65. package/dist/internal/EventProjection.js +12 -8
  66. package/dist/internal/EventProjection.js.map +1 -1
  67. package/dist/internal/MultiProjection.d.ts.map +1 -1
  68. package/dist/internal/MultiProjection.js +11 -8
  69. package/dist/internal/MultiProjection.js.map +1 -1
  70. package/dist/internal/ProjectionEngine.d.ts.map +1 -1
  71. package/dist/internal/ProjectionEngine.js +5 -5
  72. package/dist/internal/ProjectionEngine.js.map +1 -1
  73. package/dist/internal/TransactPlan.d.ts.map +1 -1
  74. package/dist/internal/TransactPlan.js +5 -3
  75. package/dist/internal/TransactPlan.js.map +1 -1
  76. package/package.json +2 -2
@@ -15,7 +15,10 @@
15
15
  * state record as the load via `loadState`)
16
16
  * - Head proofs (`append({ headProof })`, #164): an additional item or the
17
17
  * inline snapshot proves the stream head in place of the version-contiguity
18
- * `ConditionCheck`
18
+ * `ConditionCheck`, or (`headProof: "none"`, #173) the caller owns the head
19
+ * and no proof is written
20
+ * - A one-item append (its one event, nothing else) is a plain conditional
21
+ * `PutItem` rather than a transaction (#173)
19
22
  * - Sealing (#166): `append(..., { seal: true })` makes its last event the
20
23
  * stream's final one (`StreamSealed` for any later append), and
21
24
  * `stream.appendOp(...)` carries one stream's append — sealing or not — in
@@ -45,11 +48,11 @@ import { Compression } from "@effect-dynamodb/schema/Compression.js";
45
48
  import { isPackedConfig } from "@effect-dynamodb/schema/DynamoModel.js";
46
49
  import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
47
50
  import { normalizeTtlSeconds } from "@effect-dynamodb/schema/Entity.js";
48
- import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled, StreamSealed, TransactionCancelled, ValidationError, VersionConflict, } from "@effect-dynamodb/schema/Errors.js";
51
+ import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled, isAwsValidationError, StreamSealed, TransactionCancelled, ValidationError, VersionConflict, } from "@effect-dynamodb/schema/Errors.js";
49
52
  import { isDispatchEligible, packedShape, packValue, unpackStoredSync, } from "@effect-dynamodb/schema/internal/PackedCodec.js";
50
53
  import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
51
54
  import { Context, DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
52
- import { DynamoClient } from "./DynamoClient.js";
55
+ import * as Capacity from "./internal/Capacity.js";
53
56
  import { ProjectionRoutes } from "./internal/EventProjection.js";
54
57
  import { eq } from "./internal/Expr.js";
55
58
  import { itemBytes, transactItemBytes } from "./internal/ItemSize.js";
@@ -146,6 +149,37 @@ const PROJECTED_ENVELOPE_ATTRIBUTES = [
146
149
  * every deployed `"withoutMetadata"` index.
147
150
  */
148
151
  const SEALED_ATTRIBUTE = "__edd_sealed__";
152
+ /**
153
+ * @internal The write token of an event sent as a single-item `PutItem`
154
+ * (#173): random per `append` call, so the put can recognise its own item.
155
+ * A `TransactWriteItems` is idempotent under the AWS SDK's retries (the SDK
156
+ * fills its `ClientRequestToken`, and DynamoDB answers a resend of a
157
+ * committed transaction with success), but a `PutItem` has no token: a
158
+ * resend after a commit whose response was lost fails its own
159
+ * `attribute_not_exists(pk)`, and `ALL_OLD` returns the writer's own event.
160
+ * The token tells that case (success) from another writer's event at the
161
+ * same version (a conflict), however alike their content.
162
+ */
163
+ const WRITE_TOKEN_ATTRIBUTE = "__edd_wt__";
164
+ /** @internal The write token's length in hex digits: 128 random bits. */
165
+ const WRITE_TOKEN_LENGTH = 32;
166
+ /** @internal A fresh write token, from the platform's Web Crypto (CSPRNG). */
167
+ const makeWriteToken = Effect.sync(() => Array.from(globalThis.crypto.getRandomValues(new Uint8Array(WRITE_TOKEN_LENGTH / 2)), (byte) => byte.toString(16).padStart(2, "0")).join(""));
168
+ /**
169
+ * @internal The event `Put` of a single-item append with `token` as its write
170
+ * token (see {@link WRITE_TOKEN_ATTRIBUTE}).
171
+ */
172
+ const withWriteToken = (item, token) => ({
173
+ Put: {
174
+ ...item.Put,
175
+ Item: { ...item.Put.Item, [WRITE_TOKEN_ATTRIBUTE]: { S: token } },
176
+ },
177
+ });
178
+ /**
179
+ * @internal Whether an append's request is exactly one item, its one event
180
+ * `Put` — sent as a `PutItem` (#173) rather than a `TransactWriteItems`.
181
+ */
182
+ const isSingleEventPut = (items, eventCount) => items.length === 1 && eventCount === 1 && items[0].Put !== undefined;
149
183
  /**
150
184
  * @internal The version a sealed item returned by a failed condition
151
185
  * (`ReturnValuesOnConditionCheckFailure: "ALL_OLD"`) seals the stream at: an
@@ -162,6 +196,51 @@ const sealedVersionOf = (item) => {
162
196
  const version = typeof raw.version === "number" ? raw.version : raw.asOfVersion;
163
197
  return typeof version === "number" ? version : undefined;
164
198
  };
199
+ /**
200
+ * @internal A `PutItem` validation error that a transaction reports as its
201
+ * item's cancellation reason (`ValidationError`) rather than as a request
202
+ * error: a key whose type is not the table's or an index's ("Type mismatch
203
+ * for key", "Type mismatch for Index Key"). Observed on DynamoDB Local, where
204
+ * an oversized item, an empty or oversized index key are request errors of
205
+ * the transaction as of the `PutItem` — and named by the diagnosis on both —
206
+ * and an oversized table key is a cancellation reason the diagnosis names.
207
+ * The single-item fast path (#173) reports these as the transaction does.
208
+ */
209
+ const ITEM_REASON_VALIDATION = /Type mismatch/i;
210
+ /**
211
+ * @internal The cancellation reason code a one-item `TransactWriteItems`
212
+ * reports for its item where a `PutItem` fails with `cause` instead, or
213
+ * `undefined` when the transaction reports it as a request error too (or not
214
+ * at all). The single-item fast path (#173) turns these into the
215
+ * `TransactionCancelled` the transaction gives:
216
+ *
217
+ * - `ProvisionedThroughputExceededException` — `ProvisionedThroughputExceeded`
218
+ * (a provisioned table's capacity);
219
+ * - `ThrottlingException` — `ThrottlingError`;
220
+ * - `ItemCollectionSizeLimitExceededException` —
221
+ * `ItemCollectionSizeLimitExceeded` (an LSI's 10 GB item collection, which
222
+ * a stream index is by default);
223
+ * - `TransactionConflictException` — `TransactionConflict`;
224
+ * - a key of the wrong type ({@link ITEM_REASON_VALIDATION}) —
225
+ * `ValidationError`.
226
+ */
227
+ const putItemReason = (cause) => {
228
+ const name = cause?.name;
229
+ switch (name) {
230
+ case "ProvisionedThroughputExceededException":
231
+ return "ProvisionedThroughputExceeded";
232
+ case "ThrottlingException":
233
+ return "ThrottlingError";
234
+ case "ItemCollectionSizeLimitExceededException":
235
+ return "ItemCollectionSizeLimitExceeded";
236
+ case "TransactionConflictException":
237
+ return "TransactionConflict";
238
+ }
239
+ return isAwsValidationError(cause) &&
240
+ ITEM_REASON_VALIDATION.test(cause.message ?? "")
241
+ ? "ValidationError"
242
+ : undefined;
243
+ };
165
244
  /**
166
245
  * @internal What a cancelled append means for one stream, from the reasons of
167
246
  * its own items: `[0, guardCount)` the contiguity check,
@@ -221,6 +300,7 @@ const STREAM_OWNED_ATTRIBUTES = [
221
300
  "state",
222
301
  "commandId",
223
302
  SEALED_ATTRIBUTE,
303
+ WRITE_TOKEN_ATTRIBUTE,
224
304
  DEFAULT_TTL_ATTRIBUTE_NAME,
225
305
  ];
226
306
  /** @internal Every member a stream index entry may declare (`StreamIndexConfigKey`). */
@@ -501,20 +581,20 @@ const snapshotProofRefusal = (streamName, settings, operation) => settings !== u
501
581
  : `This stream's snapshot is mode "${settings.mode}"` +
502
582
  `${settings.every === undefined ? "" : ` with every: ${settings.every}`}.`);
503
583
  /**
504
- * @internal Why a `headProof` is malformed — not `"snapshot"` nor
584
+ * @internal Why a `headProof` is malformed — not `"snapshot"`, `"none"` nor
505
585
  * `{ additionalItem: n }` with `n` a non-negative integer — or `undefined`
506
586
  * when it is well-formed.
507
587
  */
508
588
  const headProofShapeProblem = (headProof) => {
509
- if (headProof === "snapshot")
589
+ if (headProof === "snapshot" || headProof === "none")
510
590
  return undefined;
511
591
  const n = typeof headProof === "object" && headProof !== null
512
592
  ? headProof.additionalItem
513
593
  : undefined;
514
594
  return typeof n === "number" && Number.isInteger(n) && n >= 0
515
595
  ? undefined
516
- : `headProof must be "snapshot" or { additionalItem: n } with n a non-negative integer; ` +
517
- `received ${JSON.stringify(headProof) ?? String(headProof)}. Nothing was written.`;
596
+ : `headProof must be "snapshot", "none" or { additionalItem: n } with n a non-negative ` +
597
+ `integer; received ${JSON.stringify(headProof) ?? String(headProof)}. Nothing was written.`;
518
598
  };
519
599
  /**
520
600
  * @internal Why an `eventTimestamps` value cannot be written for `count`
@@ -778,6 +858,12 @@ export const makeStream = (config) => {
778
858
  */
779
859
  const codecCheck = runtimeCodecCheck(`EventStore (first use of stream "${config.streamName}")`, streamCodecRequirements(config.streamName, storage));
780
860
  const checked = (op) => codecCheck === undefined ? op : (...args) => Effect.andThen(codecCheck, op(...args));
861
+ /**
862
+ * A stream method as a named operation for consumed-capacity reports
863
+ * (#174). Internal compositions call the unwrapped closures, so only the
864
+ * public method opens a scope.
865
+ */
866
+ const named = (name, op) => (...args) => Capacity.operation(`EventStream.${name}`, { target: config.streamName }, op(...args));
781
867
  /** The `[EDD-9076]` guard for queries of a stream whose `data` is packed. */
782
868
  const dataGuards = dataStoredAs === undefined
783
869
  ? []
@@ -1005,12 +1091,14 @@ export const makeStream = (config) => {
1005
1091
  */
1006
1092
  const encodeSnapshotState = (state, operation) => encodeToWire(snapshot.schema, state, operation, snapshotEntityType);
1007
1093
  /**
1008
- * Map a failed `TransactWriteItems` of an append to its verdict. Resolves to
1094
+ * Send an append — see {@link AppendTransaction} for its layout. Resolves to
1009
1095
  * the race a guarded additional put lost (so the transaction is built and
1010
1096
  * written again), and fails with every other verdict.
1097
+ *
1098
+ * A request of one item, the append's one event, is a `PutItem` (#173): see
1099
+ * {@link sendAppendPut}. Everything else is one `TransactWriteItems`.
1011
1100
  */
1012
1101
  const sendAppendTransaction = (client, tx, streamIdStr, commandId) => {
1013
- const sent = () => ({ _tag: "Transaction", items: tx.items, targets: tx.targets });
1014
1102
  const diagnosed = (diagnosis, original) => diagnosis._tag === "TooManyItems"
1015
1103
  ? new AppendTooLarge({
1016
1104
  streamName: config.streamName,
@@ -1020,6 +1108,16 @@ export const makeStream = (config) => {
1020
1108
  cause: original,
1021
1109
  })
1022
1110
  : appendLimitError(diagnosis, original, tx);
1111
+ // The stream's own key attributes and the additional items', for the
1112
+ // key-size diagnosis.
1113
+ const keys = () => mergeKeyAttributes({
1114
+ partition: ["pk", ...streamIndexes.map((idx) => idx.pk)],
1115
+ sort: ["sk", ...streamIndexes.map((idx) => idx.sk)],
1116
+ }, tx.additional.keys());
1117
+ if (isSingleEventPut(tx.items, tx.eventCount)) {
1118
+ return Effect.flatMap(makeWriteToken, (token) => sendAppendPut(client, tx, token, streamIdStr, keys, diagnosed));
1119
+ }
1120
+ const sent = () => ({ _tag: "Transaction", items: tx.items, targets: tx.targets });
1023
1121
  return client.transactWriteItems({ TransactItems: tx.items }).pipe(Effect.as(undefined),
1024
1122
  // One operation per item (#133, #144): diagnosed BEFORE the conditional
1025
1123
  // mapping below — DynamoDB can report a repeated item as a conditional
@@ -1062,10 +1160,10 @@ export const makeStream = (config) => {
1062
1160
  return Effect.fail(own);
1063
1161
  // The head proof (#164) stands in for the contiguity check, so its
1064
1162
  // own condition failing shares their verdict — ahead of the other
1065
- // additional items.
1163
+ // additional items. `"none"` (#173) has no item to fail.
1066
1164
  const proofFailed = tx.headProof === "snapshot"
1067
1165
  ? failedAt(tx.snapshotIndex)
1068
- : tx.headProof !== undefined &&
1166
+ : typeof tx.headProof === "object" &&
1069
1167
  opConditionFailed(tx.additional, rawReasons, tx.headProof.additionalItem, tx.guardCount + tx.eventCount);
1070
1168
  if (proofFailed) {
1071
1169
  return Effect.fail(new VersionConflict({
@@ -1111,13 +1209,70 @@ export const makeStream = (config) => {
1111
1209
  // transaction sent, naming the event, sentinel, snapshot or additional
1112
1210
  // item at fault. Over 100 items is `AppendTooLarge`, with the EXPANDED
1113
1211
  // count.
1212
+ diagnoseFailure({ sent, keys, rewrite: diagnosed }));
1213
+ };
1214
+ /**
1215
+ * The single-item fast path (#173): an append whose request is its one
1216
+ * event `Put` sends it as a `PutItem` — the same item, condition and
1217
+ * `ALL_OLD`, plus a write token (see {@link WRITE_TOKEN_ATTRIBUTE}) — at
1218
+ * 1 WCU per KB instead of a transaction's 2. One item commits atomically
1219
+ * either way, and every outcome maps to the one the transaction gives for
1220
+ * the same case:
1221
+ *
1222
+ * - a failed condition whose returned item carries this call's write token
1223
+ * is this call's own commit, met again by an SDK retry of a `PutItem`
1224
+ * whose response was lost: success, as the transaction's idempotent
1225
+ * retry is;
1226
+ * - any other failed condition is judged by {@link streamAppendVerdict}
1227
+ * from the one reason it stands for: `StreamSealed` when the returned
1228
+ * item is marked, `VersionConflict` otherwise;
1229
+ * - a rejection DynamoDB names (an item over 400 KB, a key too long or
1230
+ * empty) is diagnosed against the item sent and rewritten by the same
1231
+ * `rewrite`, so it names the event as the transaction's does;
1232
+ * - a failure a transaction reports as its item's cancellation reason is
1233
+ * `TransactionCancelled` with that reason (see {@link putItemReason}):
1234
+ * DynamoDB fails a `PutItem` with a request error where it cancels a
1235
+ * transaction for its item;
1236
+ * - anything else passes through, as it does from the transaction (the
1237
+ * other validation errors a one-item transaction gets are request
1238
+ * errors there too).
1239
+ */
1240
+ const sendAppendPut = (client, tx, token, streamIdStr, keys, diagnosed) => {
1241
+ const item = withWriteToken(tx.items[0], token);
1242
+ return client.putItem(item.Put).pipe(Effect.as(undefined),
1243
+ // Diagnosed first: what it names is terminal, and what it cannot
1244
+ // explain is mapped below.
1114
1245
  diagnoseFailure({
1115
- sent,
1116
- keys: () => mergeKeyAttributes({
1117
- partition: ["pk", ...streamIndexes.map((idx) => idx.pk)],
1118
- sort: ["sk", ...streamIndexes.map((idx) => idx.sk)],
1119
- }, tx.additional.keys()),
1246
+ sent: () => ({ _tag: "Item", item, target: () => tx.targets()[0] }),
1247
+ keys,
1120
1248
  rewrite: diagnosed,
1249
+ }), Effect.catch((error) => {
1250
+ if (error._tag === "AppendTooLarge" || error._tag === "ValidationError") {
1251
+ return Effect.fail(error);
1252
+ }
1253
+ const cause = error.cause;
1254
+ if (isAwsConditionalCheckFailed(cause)) {
1255
+ const returned = cause.Item;
1256
+ if (returned?.[WRITE_TOKEN_ATTRIBUTE]?.S === token)
1257
+ return Effect.succeed(undefined);
1258
+ const verdict = streamAppendVerdict([{ Code: "ConditionalCheckFailed", Item: returned }], {
1259
+ guardCount: 0,
1260
+ eventCount: 1,
1261
+ snapshotIndex: -1,
1262
+ streamName: config.streamName,
1263
+ streamId: streamIdStr,
1264
+ expectedVersion: tx.expectedVersion,
1265
+ });
1266
+ return Effect.fail(verdict ?? error);
1267
+ }
1268
+ const code = putItemReason(cause);
1269
+ if (code === undefined)
1270
+ return Effect.fail(error);
1271
+ return Effect.fail(new TransactionCancelled({
1272
+ operation: "PutItem",
1273
+ reasons: [{ code, message: cause.message }],
1274
+ cause,
1275
+ }));
1121
1276
  }));
1122
1277
  };
1123
1278
  /**
@@ -1652,7 +1807,7 @@ export const makeStream = (config) => {
1652
1807
  if (parts === undefined)
1653
1808
  return { version: expectedVersion, events: [] };
1654
1809
  const { tableName, streamIdStr, contiguityCheck, eventItems, sentinelPut, snapshotPut } = parts;
1655
- const client = yield* DynamoClient;
1810
+ const client = yield* Capacity.client;
1656
1811
  /**
1657
1812
  * Lay out the append's one transaction — see {@link AppendTransaction}:
1658
1813
  * [0, C) version-contiguity ConditionCheck (C is 0 or 1)
@@ -1834,14 +1989,21 @@ export const makeStream = (config) => {
1834
1989
  return { events: [], sentinel: undefined, snapshot: undefined, transactionBytes: 0 };
1835
1990
  }
1836
1991
  const putBytes = (item) => itemBytes(item.Put?.Item ?? {});
1837
- const owned = [
1992
+ const laidOut = [
1838
1993
  ...(parts.contiguityCheck === undefined ? [] : [parts.contiguityCheck]),
1839
1994
  ...parts.eventItems,
1840
1995
  ...(parts.sentinelPut === undefined ? [] : [parts.sentinelPut]),
1841
1996
  ...(parts.snapshotPut === undefined ? [] : [parts.snapshotPut]),
1842
1997
  ];
1998
+ // A single-item append is sent as a `PutItem` carrying a write token
1999
+ // (#173), whose bytes are measured here as a placeholder of its length.
2000
+ // A `{ additionalItem }` proof means the append carries that item, so
2001
+ // it is a transaction.
2002
+ const single = typeof options?.headProof !== "object" && isSingleEventPut(laidOut, parts.eventItems.length);
2003
+ const owned = single ? [withWriteToken(laidOut[0], "0".repeat(WRITE_TOKEN_LENGTH))] : laidOut;
2004
+ const eventItems = single ? owned : parts.eventItems;
1843
2005
  const measurement = {
1844
- events: parts.eventItems.map(putBytes),
2006
+ events: eventItems.map(putBytes),
1845
2007
  sentinel: parts.sentinelPut === undefined ? undefined : putBytes(parts.sentinelPut),
1846
2008
  snapshot: parts.snapshotPut === undefined ? undefined : putBytes(parts.snapshotPut),
1847
2009
  transactionBytes: owned.reduce((sum, item) => sum + transactItemBytes(item), 0),
@@ -1940,7 +2102,7 @@ export const makeStream = (config) => {
1940
2102
  const writeSnapshot = (streamId, state, asOfVersion) => Effect.gen(function* () {
1941
2103
  if (snapshot === undefined)
1942
2104
  return yield* snapshotUnavailable("writeSnapshot");
1943
- const client = yield* DynamoClient;
2105
+ const client = yield* Capacity.client;
1944
2106
  const { name: tableName } = yield* config.table.Tag;
1945
2107
  const now = DateTime.formatIso(yield* DateTime.now);
1946
2108
  // Packed per `snapshot.storedAs` (#147): the diagnosis below measures
@@ -1994,7 +2156,7 @@ export const makeStream = (config) => {
1994
2156
  })));
1995
2157
  /** The raw snapshot item, by its exact key, in one `GetItem`. */
1996
2158
  const getSnapshotItem = (streamId, consistentRead) => Effect.gen(function* () {
1997
- const client = yield* DynamoClient;
2159
+ const client = yield* Capacity.client;
1998
2160
  const { name: tableName } = yield* config.table.Tag;
1999
2161
  const result = yield* client.getItem({
2000
2162
  TableName: tableName,
@@ -2008,7 +2170,7 @@ export const makeStream = (config) => {
2008
2170
  });
2009
2171
  /** The raw event item at `version`, by its exact key, in one `GetItem` (#166). */
2010
2172
  const getEventItem = (streamId, version, consistentRead) => Effect.gen(function* () {
2011
- const client = yield* DynamoClient;
2173
+ const client = yield* Capacity.client;
2012
2174
  const { name: tableName } = yield* config.table.Tag;
2013
2175
  const result = yield* client.getItem({
2014
2176
  TableName: tableName,
@@ -2084,7 +2246,7 @@ export const makeStream = (config) => {
2084
2246
  }
2085
2247
  }
2086
2248
  }
2087
- const client = yield* DynamoClient;
2249
+ const client = yield* Capacity.client;
2088
2250
  const { name: tableName } = yield* config.table.Tag;
2089
2251
  // One reverse Query over `[first event SK, snapshot SK]`: the snapshot
2090
2252
  // sorts after every event and the command sentinels before them (see
@@ -2180,7 +2342,7 @@ export const makeStream = (config) => {
2180
2342
  // hasCommandSentinel (internal — see CommandSentinelProbe)
2181
2343
  // ---------------------------------------------------------------------------
2182
2344
  const hasCommandSentinel = (streamId, commandId) => Effect.gen(function* () {
2183
- const client = yield* DynamoClient;
2345
+ const client = yield* Capacity.client;
2184
2346
  const { name: tableName } = yield* config.table.Tag;
2185
2347
  const result = yield* client.getItem({
2186
2348
  TableName: tableName,
@@ -2210,6 +2372,9 @@ export const makeStream = (config) => {
2210
2372
  packed: dataGuards,
2211
2373
  resolveTableName: config.table.Tag.useSync((tc) => tc.name),
2212
2374
  keyFields: ["pk", "sk"],
2375
+ // Consumed capacity reports these queries under the stream name, as
2376
+ // every `EventStream.*` operation does (#174).
2377
+ operationTarget: config.streamName,
2213
2378
  // Bound to the event SK range so non-event items in the stream partition
2214
2379
  // (the snapshot) are excluded at the key-condition level. A caller-supplied
2215
2380
  // `Query.where` replaces this — the `__edd_e__` filter still applies.
@@ -2290,6 +2455,7 @@ export const makeStream = (config) => {
2290
2455
  label: `EventStream "${config.streamName}" index "${idx.name}"`,
2291
2456
  },
2292
2457
  keyFields: [idx.pk, idx.sk, "pk", "sk"],
2458
+ operationTarget: config.streamName,
2293
2459
  // A "keys" index holds no events: an LSI has DynamoDB fetch them
2294
2460
  // (`Select: ALL_ATTRIBUTES`), a GSI fetches each page's with
2295
2461
  // `BatchGetItem` on the table key.
@@ -2393,15 +2559,15 @@ export const makeStream = (config) => {
2393
2559
  snapshotConfig: snapshotSettings,
2394
2560
  storedAs: storage,
2395
2561
  indexes: indexSettings,
2396
- writeSnapshot: checked(writeSnapshot),
2397
- readSnapshot: checked(readSnapshot),
2398
- append: checked(append),
2562
+ writeSnapshot: checked(named("writeSnapshot", writeSnapshot)),
2563
+ readSnapshot: checked(named("readSnapshot", readSnapshot)),
2564
+ append: checked(named("append", append)),
2399
2565
  appendOp,
2400
- read: checked(read),
2401
- readFrom: checked(readFrom),
2402
- currentVersion: checked(currentVersion),
2403
- readLatest: checked(readLatest),
2404
- readIndex: checked(readIndex),
2566
+ read: checked(named("read", read)),
2567
+ readFrom: checked(named("readFrom", readFrom)),
2568
+ currentVersion: checked(named("currentVersion", currentVersion)),
2569
+ readLatest: checked(named("readLatest", readLatest)),
2570
+ readIndex: checked(named("readIndex", readIndex)),
2405
2571
  measureAppend: checked(measureAppend),
2406
2572
  measureSnapshot: checked(measureSnapshot),
2407
2573
  decodeStreamImage: checked(decodeStreamImage),
@@ -2429,7 +2595,8 @@ export const makeStream = (config) => {
2429
2595
  * ```
2430
2596
  */
2431
2597
  export const bind = (stream) => Effect.gen(function* () {
2432
- const ctx = yield* Effect.context();
2598
+ // Detached: no current operation or parent span is pinned for later calls (#174).
2599
+ const ctx = Capacity.detachContext(yield* Effect.context());
2433
2600
  // The stream meets values compressed with every codec its `storedAs`
2434
2601
  // names, so the registry captured here must have them (#147).
2435
2602
  const codecError = unregisteredCodecError("EventStore.bind", streamCodecRequirements(stream.streamName, stream.storedAs), Context.getOrUndefined(ctx, Compression));
@@ -2440,6 +2607,7 @@ export const bind = (stream) => Effect.gen(function* () {
2440
2607
  const headProbe = headAboveProbeOf(stream);
2441
2608
  return {
2442
2609
  [EventStreamTypeId]: EventStreamTypeId,
2610
+ [Capacity.CapturedReporting]: Capacity.capturedSettings(ctx),
2443
2611
  pipe() {
2444
2612
  // eslint-disable-next-line prefer-rest-params
2445
2613
  return Pipeable.pipeArguments(this, arguments);
@@ -3007,6 +3175,17 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
3007
3175
  : stream.currentVersion(streamId, readOptions);
3008
3176
  };
3009
3177
  const attempt = Effect.gen(function* () {
3178
+ // `headProof: "none"` (#173) is `append`'s alone for now: refused for an
3179
+ // untyped caller before anything is read. Not retryable, as below.
3180
+ if (headProof === "none") {
3181
+ return yield* new ValidationError({
3182
+ entityType: stream.streamName,
3183
+ operation: "EventStore.commandHandler.headProof",
3184
+ cause: 'commandHandler does not accept headProof: "none": it is accepted by append only. ' +
3185
+ "Omit headProof to keep the contiguity check, or name a proof item. " +
3186
+ "Nothing was written.",
3187
+ });
3188
+ }
3010
3189
  // Backstop for JS callers and `any`-shaped call sites: silently degrading
3011
3190
  // to at-least-once would look like success right up until the day a
3012
3191
  // duplicate mattered. Not retryable — `while` below only retries
@@ -3143,7 +3322,7 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
3143
3322
  // answer, never a cue to re-read and re-decide. A conflict naming another
3144
3323
  // stream (a nested `appendOp`, #166) is retried either way: the If-Match
3145
3324
  // was not wrong, and the retried attempt checks it again on its reload.
3146
- return schedule === undefined
3325
+ const run = schedule === undefined
3147
3326
  ? attempt
3148
3327
  : Effect.retry(attempt, {
3149
3328
  // `while` runs before the schedule, so the schedule only ever sees a
@@ -3158,6 +3337,10 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
3158
3337
  while: (error) => error instanceof VersionConflict &&
3159
3338
  (expectedVersion === undefined || !isOwnConflict(error)),
3160
3339
  });
3340
+ // One report per invocation: the load and the append (and retries) nest
3341
+ // under it (#174). A bound stream's captured setting applies, as it does
3342
+ // to the bound calls inside.
3343
+ return Capacity.operation("EventStore.commandHandler", { target: stream.streamName }, run, stream[Capacity.CapturedReporting]);
3161
3344
  };
3162
3345
  };
3163
3346
  /**
@@ -3316,7 +3499,8 @@ export { removeRow, skipEvent, } from "./internal/ProjectionEngine.js";
3316
3499
  * ```
3317
3500
  */
3318
3501
  export const bindProjection = (projection) => Effect.gen(function* () {
3319
- const ctx = yield* Effect.context();
3502
+ // Detached: no current operation or parent span is pinned for later calls (#174).
3503
+ const ctx = Capacity.detachContext(yield* Effect.context());
3320
3504
  // The codec check `bind` makes, for every stream, against the registry captured here.
3321
3505
  const streams = isMultiProjection(projection)
3322
3506
  ? Object.values(projection.sources).map((source) => source.stream)