effect-dynamodb 1.24.0 → 1.25.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 (77) hide show
  1. package/dist/Aggregate.d.ts +5 -2
  2. package/dist/Aggregate.d.ts.map +1 -1
  3. package/dist/Aggregate.js +136 -53
  4. package/dist/Aggregate.js.map +1 -1
  5. package/dist/Batch.d.ts +14 -5
  6. package/dist/Batch.d.ts.map +1 -1
  7. package/dist/Batch.js +184 -119
  8. package/dist/Batch.js.map +1 -1
  9. package/dist/Collection.d.ts.map +1 -1
  10. package/dist/Collection.js +2 -4
  11. package/dist/Collection.js.map +1 -1
  12. package/dist/DynamoClient.d.ts.map +1 -1
  13. package/dist/DynamoClient.js +42 -14
  14. package/dist/DynamoClient.js.map +1 -1
  15. package/dist/Entity.d.ts +19 -13
  16. package/dist/Entity.d.ts.map +1 -1
  17. package/dist/Entity.js +287 -226
  18. package/dist/Entity.js.map +1 -1
  19. package/dist/EventStore.d.ts +416 -73
  20. package/dist/EventStore.d.ts.map +1 -1
  21. package/dist/EventStore.js +525 -181
  22. package/dist/EventStore.js.map +1 -1
  23. package/dist/Query.d.ts +47 -14
  24. package/dist/Query.d.ts.map +1 -1
  25. package/dist/Query.js +190 -68
  26. package/dist/Query.js.map +1 -1
  27. package/dist/Table.d.ts +56 -6
  28. package/dist/Table.d.ts.map +1 -1
  29. package/dist/Table.js +134 -54
  30. package/dist/Table.js.map +1 -1
  31. package/dist/Transaction.d.ts +8 -0
  32. package/dist/Transaction.d.ts.map +1 -1
  33. package/dist/Transaction.js +67 -22
  34. package/dist/Transaction.js.map +1 -1
  35. package/dist/VectorSearchEmulation.d.ts +3 -2
  36. package/dist/VectorSearchEmulation.d.ts.map +1 -1
  37. package/dist/VectorSearchEmulation.js +14 -2
  38. package/dist/VectorSearchEmulation.js.map +1 -1
  39. package/dist/internal/BoundQuery.d.ts +3 -2
  40. package/dist/internal/BoundQuery.d.ts.map +1 -1
  41. package/dist/internal/BoundQuery.js.map +1 -1
  42. package/dist/internal/BoundVectorQuery.d.ts +5 -1
  43. package/dist/internal/BoundVectorQuery.d.ts.map +1 -1
  44. package/dist/internal/BoundVectorQuery.js +17 -5
  45. package/dist/internal/BoundVectorQuery.js.map +1 -1
  46. package/dist/internal/EntityCombinators.d.ts +8 -5
  47. package/dist/internal/EntityCombinators.d.ts.map +1 -1
  48. package/dist/internal/EntityCombinators.js +8 -5
  49. package/dist/internal/EntityCombinators.js.map +1 -1
  50. package/dist/internal/EntityOps.d.ts +1 -1
  51. package/dist/internal/Expr.d.ts +4 -0
  52. package/dist/internal/Expr.d.ts.map +1 -1
  53. package/dist/internal/Expr.js +6 -4
  54. package/dist/internal/Expr.js.map +1 -1
  55. package/dist/internal/ExpressionLimits.d.ts +45 -0
  56. package/dist/internal/ExpressionLimits.d.ts.map +1 -0
  57. package/dist/internal/ExpressionLimits.js +89 -0
  58. package/dist/internal/ExpressionLimits.js.map +1 -0
  59. package/dist/internal/Hydrate.d.ts +62 -0
  60. package/dist/internal/Hydrate.d.ts.map +1 -0
  61. package/dist/internal/Hydrate.js +191 -0
  62. package/dist/internal/Hydrate.js.map +1 -0
  63. package/dist/internal/ItemSize.d.ts +60 -24
  64. package/dist/internal/ItemSize.d.ts.map +1 -1
  65. package/dist/internal/ItemSize.js +122 -39
  66. package/dist/internal/ItemSize.js.map +1 -1
  67. package/dist/internal/TransactWriteOps.d.ts +22 -46
  68. package/dist/internal/TransactWriteOps.d.ts.map +1 -1
  69. package/dist/internal/TransactWriteOps.js +26 -95
  70. package/dist/internal/TransactWriteOps.js.map +1 -1
  71. package/dist/internal/WriteDiagnosis.d.ts +353 -0
  72. package/dist/internal/WriteDiagnosis.d.ts.map +1 -0
  73. package/dist/internal/WriteDiagnosis.js +1028 -0
  74. package/dist/internal/WriteDiagnosis.js.map +1 -0
  75. package/dist/internal/sparse_check.js +43 -79
  76. package/dist/internal/sparse_check.js.map +1 -1
  77. package/package.json +2 -2
package/dist/Entity.js CHANGED
@@ -9,7 +9,7 @@
9
9
  import { getIdentifierField, getSparseFields, isConfiguredModel, isRefField, } from "@effect-dynamodb/schema/DynamoModel.js";
10
10
  import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
11
11
  import { Embedder } from "@effect-dynamodb/schema/Embedder.js";
12
- import { CascadePartialFailure, ConcurrentModification, ConditionalCheckFailed, DeleteAppliedButUnreadable, EmbeddingError, ItemNotDeleted, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled, OptimisticLockError, RefNotFound, StaleAppend, TransactionOverflow, UniqueConstraintViolation, UpdateAppliedButUnreadable, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
12
+ import { CascadePartialFailure, ConcurrentModification, ConditionalCheckFailed, DeleteAppliedButUnreadable, DynamoError, EmbeddingError, ItemNotDeleted, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled, OptimisticLockError, RefNotFound, StaleAppend, TransactionOverflow, UniqueConstraintViolation, UpdateAppliedButUnreadable, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
13
13
  import { makeCompositeKeyForm, toCompositeKeyRecord, } from "@effect-dynamodb/schema/internal/CompositeCodec.js";
14
14
  import { makeDefaultCrypto } from "@effect-dynamodb/schema/internal/DefaultCrypto.js";
15
15
  import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
@@ -20,8 +20,11 @@ import { DynamoClient } from "./DynamoClient.js";
20
20
  import { isBoundOp, makeBoundAppend, makeBoundDelete, makeBoundGet, makeBoundPut, makeBoundUpdate, } from "./internal/BoundCrud.js";
21
21
  import { BoundQueryImpl, entityNaming, } from "./internal/BoundQuery.js";
22
22
  import { compileExpr, createConditionOps, emptyPartProblem, isExpr, nonEmptyCondition, parseShorthand, parseSimpleShorthand, toExpr, } from "./internal/Expr.js";
23
+ import { countOperators, expressionFits } from "./internal/ExpressionLimits.js";
24
+ import { BASE_DELAY_MS, MAX_RETRIES } from "./internal/Hydrate.js";
23
25
  import { compilePath, createPathBuilder } from "./internal/PathBuilder.js";
24
26
  import { freshIncarnationToken, generateTimestampPrimitive, INCARNATION_TOKEN, UNSENTINELED_DEFAULTS, } from "./internal/TransactableOps.js";
27
+ import { batchEntries, diagnosedValidationError, diagnoseFailure, keyAttributesOf, targetsFor, transactItemTarget, } from "./internal/WriteDiagnosis.js";
25
28
  import { decodeSparseFields, encodeSparseFields, fromAttributeMap, toAttributeMap, toAttributeValue, } from "./Marshaller.js";
26
29
  import * as Query from "./Query.js";
27
30
  import { filterExpr, selectPaths } from "./Query.js";
@@ -131,58 +134,8 @@ const keyFormFor = (target, record) => {
131
134
  */
132
135
  class MissingForCreate extends Data.TaggedError("MissingForCreate") {
133
136
  }
134
- /** DynamoDB's limits on one expression: its length, and its operators + functions. */
135
- const EXPRESSION_LIMIT = 4096;
136
- const OPERATOR_LIMIT = 300;
137
- /**
138
- * The tokens of a compiled expression. Names and values are placeholders
139
- * (`#…`, `:…`), so no operator can hide inside one.
140
- */
141
- const EXPRESSION_TOKEN = /#\w+|:\w+|<>|<=|>=|[=<>()+\-,.[\]]|[A-Za-z_]\w*|\d+/g;
142
- /**
143
- * The operators DynamoDB counts against an expression's 300 (#133), measured
144
- * against DynamoDB: in a condition, each comparison (`=`, `<>`, `<`, `<=`,
145
- * `>`, `>=`), `AND` / `OR` / `NOT`, `BETWEEN` (whose own `AND` is part of it,
146
- * not another operator), `IN`, and each function; in an update, each `+` /
147
- * `-` and each function (`if_not_exists`, `list_append`) — a `SET` clause's
148
- * `=` is not an operator.
149
- */
150
- /** @internal */
151
- export const countOperators = (expression, kind) => {
152
- const tokens = expression.match(EXPRESSION_TOKEN) ?? [];
153
- let count = 0;
154
- let betweens = 0;
155
- for (const [i, token] of tokens.entries()) {
156
- const word = token.toUpperCase();
157
- const isFunction = /^[A-Za-z_]\w*$/.test(token) && tokens[i + 1] === "(";
158
- if (kind === "update") {
159
- if (token === "+" || token === "-" || isFunction)
160
- count++;
161
- continue;
162
- }
163
- if (isFunction)
164
- count++;
165
- else if (["=", "<>", "<", "<=", ">", ">="].includes(token))
166
- count++;
167
- else if (word === "BETWEEN") {
168
- count++;
169
- betweens++;
170
- }
171
- else if (word === "AND" && betweens > 0)
172
- betweens--;
173
- else if (word === "AND" || word === "OR" || word === "NOT" || word === "IN")
174
- count++;
175
- }
176
- return count;
177
- };
178
- /** What DynamoDB counts against an expression's limits. */
179
- const expressionCost = (expression, kind = "condition") => expression === undefined
180
- ? { length: 0, operators: 0 }
181
- : { length: expression.length, operators: countOperators(expression, kind) };
182
- const expressionFits = (expression, kind = "condition") => {
183
- const cost = expressionCost(expression, kind);
184
- return cost.length <= EXPRESSION_LIMIT && cost.operators <= OPERATOR_LIMIT;
185
- };
137
+ /** @internal Re-exported for tests: what DynamoDB counts against an expression's 300 operators. */
138
+ export { countOperators };
186
139
  const bytesKey = (bytes) => Array.from(bytes).join(",");
187
140
  /** Structural equality of two marshalled values (sets compare as sets). */
188
141
  const attributeValueEquals = (a, b) => {
@@ -270,9 +223,6 @@ Effect.catch(() => Effect.fail(new ValidationError({
270
223
  operation: `${operation}.decode`,
271
224
  cause: primaryCause,
272
225
  }))))));
273
- // ---------------------------------------------------------------------------
274
- // Transaction limit pre-check (100-item DynamoDB limit)
275
- // ---------------------------------------------------------------------------
276
226
  /**
277
227
  * Compile a condition that may be either an Expr ADT node or a ConditionInput object.
278
228
  * Routes both paths through the Expr ADT compiler for a single compilation backend.
@@ -284,15 +234,6 @@ const compileCondition = (cond, resolveDbNameFn) => {
284
234
  const expr = isExpr(cond) ? cond : parseShorthand(cond);
285
235
  return compileExpr(expr, resolveDbNameFn);
286
236
  };
287
- const TRANSACTION_LIMIT = 100;
288
- const checkTransactionLimit = (entityType, operation, items) => items.length > TRANSACTION_LIMIT
289
- ? Effect.fail(new TransactionOverflow({
290
- entityType,
291
- operation,
292
- itemCount: items.length,
293
- limit: TRANSACTION_LIMIT,
294
- }))
295
- : Effect.void;
296
237
  /** Attempts a guarded put makes before it reports a lost race (#133). */
297
238
  const GUARDED_PUT_ATTEMPTS = 3;
298
239
  /**
@@ -833,6 +774,59 @@ const makeImpl = (config, precomputedData) => {
833
774
  * through `keyForm(...)`.
834
775
  */
835
776
  const keyForm = (record) => toCompositeKeyRecord(compositeKeyForm, record);
777
+ // -------------------------------------------------------------------------
778
+ // Write-limit diagnosis (#144)
779
+ //
780
+ // DynamoDB's limits — 400 KB per item, 100 items and 4 MB per transaction,
781
+ // 1024-byte sort keys — are not pre-checked. Every write is sent; when
782
+ // DynamoDB rejects it, `internal/WriteDiagnosis.ts` recognises the
783
+ // rejection, confirms it against the request and names the item. Applied
784
+ // after each site's conditional-failure mapping; nothing runs on success.
785
+ // -------------------------------------------------------------------------
786
+ const entityKeyFields = () => [
787
+ config.indexes.primary.pk.field,
788
+ config.indexes.primary.sk.field,
789
+ ];
790
+ const entityKeys = () => keyAttributesOf(config.indexes);
791
+ /**
792
+ * Diagnose a rejected single-item write (`PutItem` / `UpdateItem` /
793
+ * `DeleteItem`). `callerCondition`: the caller's own `.condition()` within
794
+ * the write's condition, to say how much of an over-long one is theirs.
795
+ */
796
+ const diagnoseItemWrite = (operation, item, callerCondition) => diagnoseFailure({
797
+ sent: () => ({
798
+ _tag: "Item",
799
+ item,
800
+ target: () => targetsFor([item], entityKeyFields(), entityType, () => `the ${entityType} ${operation}`)[0],
801
+ callerCondition,
802
+ }),
803
+ keys: entityKeys,
804
+ rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, { operation, entityType }),
805
+ });
806
+ /**
807
+ * Diagnose a rejected `TransactWriteItems` of this entity's write: over 100
808
+ * items is `TransactionOverflow`, every other limit `ValidationError`.
809
+ */
810
+ const diagnoseTransactionWrite = (operation, items,
811
+ /** The caller's own `.condition()` within the first item's (its main write's) condition. */
812
+ callerCondition) => diagnoseFailure({
813
+ sent: () => ({
814
+ _tag: "Transaction",
815
+ items,
816
+ targets: () => targetsFor(items, entityKeyFields(), entityType, (index) => index === 0 ? "the item" : `item ${index} of the ${operation} transaction`),
817
+ callerCondition: (index) => (index === 0 ? callerCondition?.() : undefined),
818
+ }),
819
+ keys: entityKeys,
820
+ rewrite: (diagnosis, original) => diagnosis._tag === "TooManyItems"
821
+ ? new TransactionOverflow({
822
+ entityType,
823
+ operation,
824
+ itemCount: diagnosis.count,
825
+ limit: diagnosis.limit,
826
+ cause: original,
827
+ })
828
+ : diagnosedValidationError(diagnosis, original, { operation, entityType }),
829
+ });
836
830
  const composePrimaryKey = (record) => {
837
831
  const primary = config.indexes.primary;
838
832
  const recordKeyForm = keyForm(record);
@@ -1485,25 +1479,6 @@ const makeImpl = (config, precomputedData) => {
1485
1479
  }
1486
1480
  return new ConditionalCheckFailed({ entityType, key });
1487
1481
  };
1488
- /**
1489
- * A condition the library sends — its own guard plus the user's
1490
- * `.condition()` — that exceeds DynamoDB's limits on one expression (4 KB,
1491
- * 300 operators and functions): refused BEFORE writing (#133).
1492
- */
1493
- const oversizedCondition = (operation, condition, user) => {
1494
- if (condition === undefined || expressionFits(condition))
1495
- return undefined;
1496
- const own = expressionCost(user);
1497
- return new ValidationError({
1498
- entityType,
1499
- operation,
1500
- cause: `The ${operation}'s condition is ${expressionCost(condition).length} characters / ` +
1501
- `${expressionCost(condition).operators} operators — beyond DynamoDB's limits on one ` +
1502
- `expression (${EXPRESSION_LIMIT} characters, ${OPERATOR_LIMIT} operators). The ` +
1503
- `.condition() alone is ${own.length} characters / ${own.operators} operators, and ` +
1504
- "the library's own guard needs room beside it: simplify the condition.",
1505
- });
1506
- };
1507
1482
  /** The domain field a stored attribute holds (`storedAs` renames reversed). */
1508
1483
  const domainNameOf = (attr) => Object.keys(data.modelFields).find((field) => resolveDbName(field) === attr) ?? attr;
1509
1484
  /**
@@ -1514,7 +1489,9 @@ const makeImpl = (config, precomputedData) => {
1514
1489
  * absent). `"item"` means the whole item — a tombstone copies all of it —
1515
1490
  * so every stored attribute is guarded, plus every attribute the entity
1516
1491
  * knows of that was absent; an item too wide for that falls back to the
1517
- * strongest guard within DynamoDB's expression limit (never a refusal).
1492
+ * strongest guard within DynamoDB's expression limit (never a refusal). A
1493
+ * guard that cannot fit beside `others` at all is sent as it is: DynamoDB
1494
+ * rejects the expression, and the rejection is diagnosed (#144).
1518
1495
  */
1519
1496
  const deleteGuard = (read, dependsOn,
1520
1497
  /** The rest of the condition, ANDed with the guard (the user's, …). */
@@ -1540,15 +1517,10 @@ const makeImpl = (config, precomputedData) => {
1540
1517
  const incarnation = incarnationGuard(read, names, values);
1541
1518
  if (incarnation !== undefined)
1542
1519
  parts.push(incarnation);
1543
- const expression = parts.join(" AND ");
1544
- if (!fits(expression))
1545
- return tooLargeBeside(expression);
1546
- return { expression, names, values, inputs: [] };
1520
+ return { expression: parts.join(" AND "), names, values, inputs: [] };
1547
1521
  }
1548
1522
  names["#dpk"] = pkField;
1549
1523
  parts.push("attribute_exists(#dpk)");
1550
- if (!fits(parts.join(" AND ")))
1551
- return tooLargeBeside(parts.join(" AND "));
1552
1524
  const attrs = dependsOn === "item"
1553
1525
  ? [
1554
1526
  ...new Set([
@@ -1604,9 +1576,6 @@ const makeImpl = (config, precomputedData) => {
1604
1576
  inputs.push(attr);
1605
1577
  }
1606
1578
  return { expression, names, values, inputs };
1607
- function tooLargeBeside(guard) {
1608
- return (oversizedCondition("write", others === undefined || others === "" ? guard : `${guard} AND (${others})`, others) ?? new ValidationError({ entityType, operation: "write", cause: "condition too large" }));
1609
- }
1610
1579
  };
1611
1580
  /** Which predicate rejected a guarded delete — from the stored item (ALL_OLD). */
1612
1581
  const deleteRejection = (key, read, inputs, stored, userCondition) => {
@@ -2192,15 +2161,12 @@ const makeImpl = (config, precomputedData) => {
2192
2161
  inputs: [],
2193
2162
  }
2194
2163
  : deleteGuard(current, uniqueAttrs, userCondition?.expression);
2195
- if (guard instanceof ValidationError)
2196
- return yield* guard;
2197
2164
  const mainValues = { ...guard.values, ...userCondition?.values };
2165
+ // Sent as built: a condition over DynamoDB's expression limits is
2166
+ // DynamoDB's to reject, and is diagnosed naming the caller's part (#144).
2198
2167
  const mainCondition = userCondition
2199
2168
  ? `${guard.expression} AND (${userCondition.expression})`
2200
2169
  : guard.expression;
2201
- const tooLarge = oversizedCondition(args.operation, mainCondition, userCondition?.expression);
2202
- if (tooLarge !== undefined)
2203
- return yield* tooLarge;
2204
2170
  const items = [
2205
2171
  {
2206
2172
  Put: {
@@ -2355,7 +2321,6 @@ const makeImpl = (config, precomputedData) => {
2355
2321
  let lost;
2356
2322
  for (let attempt = 0; attempt < GUARDED_PUT_ATTEMPTS; attempt++) {
2357
2323
  const plan = yield* planPut(args);
2358
- yield* checkTransactionLimit(entityType, args.operation, plan.items);
2359
2324
  const write = plan.items.length === 1
2360
2325
  ? client.putItem(plan.items[0].Put)
2361
2326
  : client.transactWriteItems({ TransactItems: [...plan.items] });
@@ -2367,7 +2332,11 @@ const makeImpl = (config, precomputedData) => {
2367
2332
  : undefined;
2368
2333
  const read = reasons !== undefined ? plan.verdict(reasons) : undefined;
2369
2334
  return read !== undefined ? Effect.succeed(read) : Effect.fail(err);
2370
- }));
2335
+ }),
2336
+ // Terminal: a diagnosed limit is never retried as a lost race.
2337
+ plan.items.length === 1
2338
+ ? diagnoseItemWrite(args.operation, plan.items[0], () => args.userCondition?.expression)
2339
+ : diagnoseTransactionWrite(args.operation, plan.items, () => args.userCondition?.expression));
2371
2340
  if (verdict === undefined)
2372
2341
  return plan;
2373
2342
  switch (verdict._tag) {
@@ -2518,6 +2487,11 @@ const makeImpl = (config, precomputedData) => {
2518
2487
  }
2519
2488
  // Resolve target table name
2520
2489
  const { name: targetTableName } = yield* target._tableTag;
2490
+ // The target's primary-key attributes. A target need not be keyed `pk` /
2491
+ // `sk`, and one cascade can span targets keyed differently, so each
2492
+ // update reads its key — and is diagnosed — by its own target's fields.
2493
+ const targetPrimary = target.indexes.primary;
2494
+ const keyFields = [targetPrimary.pk.field, targetPrimary.sk.field];
2521
2495
  // Compose GSI PK for the source entity's ID
2522
2496
  const targetSchema = target._schema;
2523
2497
  const gsiPkValue = KeyComposer.composePk(targetSchema, target.entityType, cascadeIdx.indexDef, keyFormFor(target, { [matchingRef.idFieldName]: sourceIdValue }));
@@ -2558,8 +2532,10 @@ const makeImpl = (config, precomputedData) => {
2558
2532
  });
2559
2533
  for (const item of queryResult.Items ?? []) {
2560
2534
  allUpdateOps.push({
2535
+ entityType: target.entityType,
2561
2536
  tableName: targetTableName,
2562
- key: { pk: item.pk, sk: item.sk },
2537
+ key: { [keyFields[0]]: item[keyFields[0]], [keyFields[1]]: item[keyFields[1]] },
2538
+ keyFields,
2563
2539
  refFieldName: matchingRef.fieldName,
2564
2540
  refData: toAttributeValue(sourceDomainData),
2565
2541
  });
@@ -2570,45 +2546,76 @@ const makeImpl = (config, precomputedData) => {
2570
2546
  if (allUpdateOps.length === 0)
2571
2547
  return;
2572
2548
  if (mode === "transactional") {
2573
- // Transactional mode — fail if >100 items
2574
- if (allUpdateOps.length > 100) {
2575
- return yield* new CascadePartialFailure({
2549
+ // Transactional mode — one atomic transaction. DynamoDB's limits (100
2550
+ // items, 4 MB, item size, sort keys) are not pre-checked (#144): a
2551
+ // rejection is diagnosed and reported as a cascade that wrote nothing.
2552
+ const cascadeItems = allUpdateOps.map((op) => ({
2553
+ Update: {
2554
+ TableName: op.tableName,
2555
+ Key: op.key,
2556
+ UpdateExpression: "SET #ref = :refData",
2557
+ ExpressionAttributeNames: { "#ref": op.refFieldName },
2558
+ ExpressionAttributeValues: { ":refData": op.refData },
2559
+ },
2560
+ }));
2561
+ yield* client.transactWriteItems({ TransactItems: [...cascadeItems] }).pipe(diagnoseFailure({
2562
+ sent: () => ({
2563
+ _tag: "Transaction",
2564
+ items: cascadeItems,
2565
+ targets: () => cascadeItems.map((item, index) => {
2566
+ const op = allUpdateOps[index];
2567
+ return transactItemTarget(item, op.tableName, op.keyFields, op.entityType, `the cascade update of ${op.entityType} (item ${index})`);
2568
+ }),
2569
+ }),
2570
+ keys: () => ({
2571
+ partition: [...new Set(allUpdateOps.map((op) => op.keyFields[0]))],
2572
+ sort: [...new Set(allUpdateOps.map((op) => op.keyFields[1]))],
2573
+ }),
2574
+ rewrite: (diagnosis, original) => new CascadePartialFailure({
2576
2575
  sourceEntity: sourceEntityType,
2577
2576
  sourceId: sourceIdValue,
2578
2577
  succeeded: 0,
2579
2578
  failed: allUpdateOps.length,
2580
- errors: [`Transactional cascade exceeded 100-item limit: ${allUpdateOps.length} items`],
2581
- });
2582
- }
2583
- yield* client.transactWriteItems({
2584
- TransactItems: allUpdateOps.map((op) => ({
2585
- Update: {
2586
- TableName: op.tableName,
2587
- Key: op.key,
2588
- UpdateExpression: "SET #ref = :refData",
2589
- ExpressionAttributeNames: { "#ref": op.refFieldName },
2590
- ExpressionAttributeValues: { ":refData": op.refData },
2591
- },
2592
- })),
2593
- });
2579
+ errors: [
2580
+ diagnosedValidationError(diagnosis, original, {
2581
+ operation: "cascade",
2582
+ entityType: sourceEntityType,
2583
+ }),
2584
+ ],
2585
+ }),
2586
+ }));
2594
2587
  }
2595
2588
  else {
2596
2589
  // Eventual mode — concurrent updates with partial failure tracking
2597
2590
  let succeeded = 0;
2598
2591
  const errors = [];
2599
- const updateEffects = allUpdateOps.map((op) => client
2600
- .updateItem({
2601
- TableName: op.tableName,
2602
- Key: op.key,
2603
- UpdateExpression: "SET #ref = :refData",
2604
- ExpressionAttributeNames: { "#ref": op.refFieldName },
2605
- ExpressionAttributeValues: { ":refData": op.refData },
2606
- })
2607
- .pipe(Effect.tap(() => Effect.sync(() => {
2608
- succeeded++;
2609
- })), Effect.catch((err) => Effect.sync(() => {
2610
- errors.push(err);
2611
- }))));
2592
+ const updateEffects = allUpdateOps.map((op) => {
2593
+ const update = {
2594
+ TableName: op.tableName,
2595
+ Key: op.key,
2596
+ UpdateExpression: "SET #ref = :refData",
2597
+ ExpressionAttributeNames: { "#ref": op.refFieldName },
2598
+ ExpressionAttributeValues: { ":refData": op.refData },
2599
+ };
2600
+ return client.updateItem(update).pipe(
2601
+ // An item the cascade grows past 400 KB is named in `errors`.
2602
+ diagnoseFailure({
2603
+ sent: () => ({
2604
+ _tag: "Item",
2605
+ item: { Update: update },
2606
+ target: () => transactItemTarget({ Update: update }, op.tableName, op.keyFields, op.entityType, `the cascade update of ${op.entityType}`),
2607
+ }),
2608
+ keys: () => ({ partition: [op.keyFields[0]], sort: [op.keyFields[1]] }),
2609
+ rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, {
2610
+ operation: "cascade",
2611
+ entityType: op.entityType,
2612
+ }),
2613
+ }), Effect.tap(() => Effect.sync(() => {
2614
+ succeeded++;
2615
+ })), Effect.catch((err) => Effect.sync(() => {
2616
+ errors.push(err);
2617
+ })));
2618
+ });
2612
2619
  yield* Effect.all(updateEffects, { concurrency: 10 });
2613
2620
  if (errors.length > 0) {
2614
2621
  return yield* new CascadePartialFailure({
@@ -2778,7 +2785,7 @@ const makeImpl = (config, precomputedData) => {
2778
2785
  });
2779
2786
  }
2780
2787
  return err;
2781
- }));
2788
+ }), diagnoseItemWrite("put", { Put: putInput }));
2782
2789
  }
2783
2790
  // Decode and return using selected mode
2784
2791
  return yield* decodeAs(item, marshalledItem, mode);
@@ -3414,12 +3421,9 @@ const makeImpl = (config, precomputedData) => {
3414
3421
  // versioned (exact), otherwise under the strongest guard that fits —
3415
3422
  // the same fallback a wide soft delete uses.
3416
3423
  const wide = !expressionFits(updateExpression, "update");
3417
- const wideGuardOrRefusal = wide && !systemFields.version
3424
+ const wideGuard = wide && !systemFields.version
3418
3425
  ? deleteGuard(currentItem, "item", condParts.slice(1).join(" AND "))
3419
3426
  : undefined;
3420
- if (wideGuardOrRefusal instanceof ValidationError)
3421
- return yield* wideGuardOrRefusal;
3422
- const wideGuard = wideGuardOrRefusal;
3423
3427
  const putCondition = wideGuard === undefined
3424
3428
  ? condParts.join(" AND ")
3425
3429
  : [wideGuard.expression, ...condParts.slice(1)].join(" AND ");
@@ -3435,11 +3439,8 @@ const makeImpl = (config, precomputedData) => {
3435
3439
  ...(Object.keys(putValues).length > 0 && { ExpressionAttributeValues: putValues }),
3436
3440
  ReturnValuesOnConditionCheckFailure: "ALL_OLD",
3437
3441
  };
3438
- const tooLarge = oversizedCondition("update", wide ? putCondition : mainUpdate.ConditionExpression, userCond !== undefined
3439
- ? compileCondition(userCond, resolveDbName)?.expression
3440
- : undefined);
3441
- if (tooLarge !== undefined)
3442
- return yield* tooLarge;
3442
+ // A condition over DynamoDB's expression limits is sent as built:
3443
+ // DynamoDB rejects it, and the rejection is diagnosed (#144).
3443
3444
  // The stored attributes the rejection compares: the guarded inputs,
3444
3445
  // and on the wide fallback whatever else its guard covered.
3445
3446
  const guardedAttrs = [
@@ -3490,7 +3491,7 @@ const makeImpl = (config, precomputedData) => {
3490
3491
  // write's own SET / REMOVE is the exact new image as well.
3491
3492
  const result = yield* withCurrent((wide
3492
3493
  ? client.putItem({ ...mainPut, ReturnValues: "ALL_OLD" })
3493
- : client.updateItem({ ...mainUpdate, ReturnValues: "ALL_OLD" })).pipe(Effect.mapError((err) => isAwsConditionalCheckFailed(err.cause) ? recordRejected(err.cause.Item) : err)));
3494
+ : client.updateItem({ ...mainUpdate, ReturnValues: "ALL_OLD" })).pipe(Effect.mapError((err) => isAwsConditionalCheckFailed(err.cause) ? recordRejected(err.cause.Item) : err), diagnoseItemWrite("update", wide ? { Put: mainPut } : { Update: mainUpdate }, () => uc?.expression)));
3494
3495
  oldImage = result.Attributes ?? currentItem;
3495
3496
  const applied = {
3496
3497
  ...oldImage,
@@ -3538,7 +3539,6 @@ const makeImpl = (config, precomputedData) => {
3538
3539
  });
3539
3540
  }
3540
3541
  }
3541
- yield* checkTransactionLimit(entityType, "update", transactItems);
3542
3542
  yield* withCurrent(client.transactWriteItems({ TransactItems: transactItems }).pipe(Effect.mapError((err) => {
3543
3543
  if (isAwsTransactionCancelled(err.cause)) {
3544
3544
  const reasons = err.cause.CancellationReasons;
@@ -3571,7 +3571,7 @@ const makeImpl = (config, precomputedData) => {
3571
3571
  return recordRejected(err.cause.Item);
3572
3572
  }
3573
3573
  return err;
3574
- })));
3574
+ }), diagnoseTransactionWrite("update", transactItems, () => uc?.expression)));
3575
3575
  }
3576
3576
  // Execute cascade if configured (retain path) — from the new item.
3577
3577
  if (uState.cascade) {
@@ -4137,14 +4137,9 @@ const makeImpl = (config, precomputedData) => {
4137
4137
  if (incarnation !== undefined)
4138
4138
  condParts.push(incarnation);
4139
4139
  }
4140
+ // Sent as built: a condition over DynamoDB's expression limits is
4141
+ // DynamoDB's to reject, and is diagnosed naming the caller's part (#144).
4140
4142
  const conditionExpression = condParts.length > 0 ? condParts.join(" AND ") : undefined;
4141
- {
4142
- const tooLarge = oversizedCondition("update", conditionExpression, sentCond !== undefined
4143
- ? compileCondition(sentCond, resolveDbName)?.expression
4144
- : undefined);
4145
- if (tooLarge !== undefined)
4146
- return yield* tooLarge;
4147
- }
4148
4143
  // The top-level attributes this update writes — what `updatedOld` /
4149
4144
  // `updatedNew` return.
4150
4145
  const written = expressionTargets(updateExpression, names);
@@ -4172,11 +4167,6 @@ const makeImpl = (config, precomputedData) => {
4172
4167
  if (incarnation !== undefined)
4173
4168
  guardParts.push(incarnation);
4174
4169
  }
4175
- const tooLarge = oversizedCondition("update", guardParts.join(" AND "), sentCond !== undefined
4176
- ? compileCondition(sentCond, resolveDbName)?.expression
4177
- : undefined);
4178
- if (tooLarge !== undefined)
4179
- return yield* tooLarge;
4180
4170
  const transactItems = [
4181
4171
  {
4182
4172
  Update: {
@@ -4193,7 +4183,6 @@ const makeImpl = (config, precomputedData) => {
4193
4183
  },
4194
4184
  { Put: snapshotPut(tableName, snapshot.item) },
4195
4185
  ];
4196
- yield* checkTransactionLimit(entityType, "update", transactItems);
4197
4186
  yield* client.transactWriteItems({ TransactItems: transactItems }).pipe(Effect.mapError((err) => {
4198
4187
  // The update is guarded by the snapshot version AND any user
4199
4188
  // condition; the stored item (ALL_OLD) says which rejected it.
@@ -4211,7 +4200,7 @@ const makeImpl = (config, precomputedData) => {
4211
4200
  if (isAwsConditionalCheckFailed(err.cause))
4212
4201
  return rejection(err.cause.Item);
4213
4202
  return err;
4214
- }));
4203
+ }), diagnoseTransactionWrite("update", transactItems, () => uc?.expression));
4215
4204
  // A transacted Update returns no attributes. The old item is exact:
4216
4205
  // the version condition proves `snapshot.raw` is what it replaced.
4217
4206
  // The new item is read back only when needed, and proven ours.
@@ -4234,8 +4223,7 @@ const makeImpl = (config, precomputedData) => {
4234
4223
  // DynamoDB rejects an empty `ExpressionAttributeValues` map. When
4235
4224
  // the UpdateExpression is REMOVE-only (e.g. clearMap with no other
4236
4225
  // combinators), `values` may be empty — omit the property entirely.
4237
- const result = yield* client
4238
- .updateItem({
4226
+ const updateInput = {
4239
4227
  TableName: tableName,
4240
4228
  Key: marshalledKey,
4241
4229
  UpdateExpression: updateExpression,
@@ -4246,8 +4234,8 @@ const makeImpl = (config, precomputedData) => {
4246
4234
  ReturnValuesOnConditionCheckFailure: "ALL_OLD",
4247
4235
  }),
4248
4236
  ReturnValues: returned,
4249
- })
4250
- .pipe(Effect.mapError((err) => {
4237
+ };
4238
+ const result = yield* client.updateItem(updateInput).pipe(Effect.mapError((err) => {
4251
4239
  // The stored item (ALL_OLD) says what rejected the update: no
4252
4240
  // item (it must exist), a restated immutable value, the
4253
4241
  // version CAS, or the user's condition.
@@ -4276,7 +4264,7 @@ const makeImpl = (config, precomputedData) => {
4276
4264
  : undefined, userCond !== undefined);
4277
4265
  }
4278
4266
  return err;
4279
- }));
4267
+ }), diagnoseItemWrite("update", { Update: updateInput }, () => uc?.expression));
4280
4268
  // A cascade never meets an old-image mode here: that combination is
4281
4269
  // routed to the read-then-write branch, or read first (`casRead`).
4282
4270
  const newImage = wantsOld ? undefined : result.Attributes;
@@ -4475,8 +4463,6 @@ const makeImpl = (config, precomputedData) => {
4475
4463
  // into them (#133).
4476
4464
  yield* checkVersion(result.Item, "delete");
4477
4465
  const softGuard = deleteGuard(result.Item, "item", userCondition?.expression);
4478
- if (softGuard instanceof ValidationError)
4479
- return yield* softGuard;
4480
4466
  const raw = fromAttributeMap(result.Item);
4481
4467
  // Clock-backed time source; `now` is the ISO timestamp used for the
4482
4468
  // deleted SK + deletedAt, `dtNow` drives the optional TTL + snapshot.
@@ -4545,8 +4531,7 @@ const makeImpl = (config, precomputedData) => {
4545
4531
  const releases = hasUniqueConstraints && !preserveUnique()
4546
4532
  ? yield* ownedReleases(tableName, raw, transactItems)
4547
4533
  : [];
4548
- yield* checkTransactionLimit(entityType, "delete", transactItems);
4549
- yield* guardedDelete(client.transactWriteItems({ TransactItems: transactItems }), result.Item, softGuard.inputs, snapshotAt, releases);
4534
+ yield* guardedDelete(client.transactWriteItems({ TransactItems: transactItems }), result.Item, softGuard.inputs, snapshotAt, releases).pipe(diagnoseTransactionWrite("delete", transactItems, () => userCondition?.expression));
4550
4535
  return result.Item;
4551
4536
  }
4552
4537
  else {
@@ -4569,8 +4554,7 @@ const makeImpl = (config, precomputedData) => {
4569
4554
  if (!userCondition)
4570
4555
  return undefined;
4571
4556
  const values = userCondition.values;
4572
- yield* client
4573
- .deleteItem({
4557
+ const missingDelete = {
4574
4558
  TableName: tableName,
4575
4559
  Key: marshalledKey,
4576
4560
  ConditionExpression: `attribute_not_exists(#dpk) AND (${userCondition.expression})`,
@@ -4580,10 +4564,12 @@ const makeImpl = (config, precomputedData) => {
4580
4564
  },
4581
4565
  ...(Object.keys(values).length > 0 && { ExpressionAttributeValues: values }),
4582
4566
  ReturnValuesOnConditionCheckFailure: "ALL_OLD",
4583
- })
4584
- .pipe(Effect.mapError((err) => isAwsConditionalCheckFailed(err.cause)
4567
+ };
4568
+ yield* client.deleteItem(missingDelete).pipe(Effect.mapError((err) => isAwsConditionalCheckFailed(err.cause)
4585
4569
  ? conditionRejection(encodedKey, err.cause.Item, err.cause.Item === undefined ? undefined : { version: 0 }, true)
4586
- : err));
4570
+ : err), diagnoseItemWrite("delete", {
4571
+ Delete: missingDelete,
4572
+ }));
4587
4573
  return undefined;
4588
4574
  }
4589
4575
  // The sentinel deletes are keyed by the unique values read, and the
@@ -4596,8 +4582,6 @@ const makeImpl = (config, precomputedData) => {
4596
4582
  ...new Set(Object.values(config.unique).flatMap((def) => resolveUniqueFields(def).map(resolveDbName))),
4597
4583
  ]
4598
4584
  : [], userCondition?.expression);
4599
- if (hardGuard instanceof ValidationError)
4600
- return yield* hardGuard;
4601
4585
  const raw = fromAttributeMap(result.Item);
4602
4586
  const transactItems = [];
4603
4587
  // Delete entity item — index 0, and the only item conditioned on the
@@ -4627,8 +4611,7 @@ const makeImpl = (config, precomputedData) => {
4627
4611
  const releases = hasUniqueConstraints
4628
4612
  ? yield* ownedReleases(tableName, raw, transactItems)
4629
4613
  : [];
4630
- yield* checkTransactionLimit(entityType, "delete", transactItems);
4631
- yield* guardedDelete(client.transactWriteItems({ TransactItems: transactItems }), result.Item, hardGuard.inputs, snapshotAt, releases);
4614
+ yield* guardedDelete(client.transactWriteItems({ TransactItems: transactItems }), result.Item, hardGuard.inputs, snapshotAt, releases).pipe(diagnoseTransactionWrite("delete", transactItems, () => userCondition?.expression));
4632
4615
  return result.Item;
4633
4616
  }
4634
4617
  });
@@ -4668,9 +4651,9 @@ const makeImpl = (config, precomputedData) => {
4668
4651
  if (opts.returnValues !== undefined) {
4669
4652
  deleteInput.ReturnValues = opts.returnValues === "allOld" ? "ALL_OLD" : "NONE";
4670
4653
  }
4671
- const output = yield* client
4672
- .deleteItem(deleteInput)
4673
- .pipe(Effect.mapError(mapDeleteConditionFailure));
4654
+ const output = yield* client.deleteItem(deleteInput).pipe(Effect.mapError(mapDeleteConditionFailure), diagnoseItemWrite("delete", {
4655
+ Delete: deleteInput,
4656
+ }));
4674
4657
  return yield* deletedResult(output.Attributes);
4675
4658
  }
4676
4659
  }), self, key);
@@ -5005,8 +4988,7 @@ const makeImpl = (config, precomputedData) => {
5005
4988
  Object.assign(names, uc.names);
5006
4989
  Object.assign(values, uc.values);
5007
4990
  }
5008
- const result = yield* client
5009
- .updateItem({
4991
+ const upsertInput = {
5010
4992
  TableName: tableName,
5011
4993
  Key: marshalledKey,
5012
4994
  UpdateExpression: updateExpression,
@@ -5018,8 +5000,8 @@ const makeImpl = (config, precomputedData) => {
5018
5000
  ReturnValuesOnConditionCheckFailure: "ALL_OLD",
5019
5001
  }),
5020
5002
  ReturnValues: "ALL_NEW",
5021
- })
5022
- .pipe(Effect.mapError((err) => {
5003
+ };
5004
+ const result = yield* client.updateItem(upsertInput).pipe(Effect.mapError((err) => {
5023
5005
  if (isAwsConditionalCheckFailed(err.cause)) {
5024
5006
  const corrupt = versionCorruption(err.cause.Item, "upsert");
5025
5007
  if (corrupt !== undefined)
@@ -5028,6 +5010,8 @@ const makeImpl = (config, precomputedData) => {
5028
5010
  return new ConditionalCheckFailed({ entityType, key: item });
5029
5011
  }
5030
5012
  return err;
5013
+ }), diagnoseItemWrite("upsert", {
5014
+ Update: upsertInput,
5031
5015
  }));
5032
5016
  if (!result.Attributes) {
5033
5017
  return yield* new ItemNotFound({ entityType, key: item });
@@ -5368,30 +5352,42 @@ const makeImpl = (config, precomputedData) => {
5368
5352
  }
5369
5353
  const marshalledEventItem = toAttributeMap(eventItem);
5370
5354
  // ---------- Execute TransactWriteItems ----------
5371
- const transactResult = yield* client
5372
- .transactWriteItems({
5373
- TransactItems: [
5374
- {
5375
- Update: {
5376
- TableName: tableName,
5377
- Key: marshalledKey,
5378
- UpdateExpression: updateExpression,
5379
- ConditionExpression: finalCondition,
5380
- ExpressionAttributeNames: names,
5381
- ExpressionAttributeValues: values,
5382
- },
5355
+ const appendItems = [
5356
+ {
5357
+ Update: {
5358
+ TableName: tableName,
5359
+ Key: marshalledKey,
5360
+ UpdateExpression: updateExpression,
5361
+ ConditionExpression: finalCondition,
5362
+ ExpressionAttributeNames: names,
5363
+ ExpressionAttributeValues: values,
5383
5364
  },
5384
- {
5385
- Put: {
5386
- TableName: tableName,
5387
- Item: marshalledEventItem,
5388
- },
5365
+ },
5366
+ {
5367
+ Put: {
5368
+ TableName: tableName,
5369
+ Item: marshalledEventItem,
5389
5370
  },
5390
- ],
5391
- })
5392
- .pipe(Effect.matchEffect({
5371
+ },
5372
+ ];
5373
+ const transactResult = yield* client.transactWriteItems({ TransactItems: appendItems }).pipe(
5374
+ // A limit DynamoDB enforced (#144) — the current item grown past
5375
+ // 400 KB, an oversized event item, an over-long sort key — is
5376
+ // diagnosed first: such a cancellation carries no conditional
5377
+ // failure, so it is never a stale append.
5378
+ diagnoseFailure({
5379
+ sent: () => ({
5380
+ _tag: "Transaction",
5381
+ items: appendItems,
5382
+ targets: () => targetsFor(appendItems, entityKeyFields(), entityType, (index) => index === 0 ? "the current item" : "the event item"),
5383
+ }),
5384
+ keys: entityKeys,
5385
+ rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, { operation: "append", entityType }),
5386
+ }), Effect.matchEffect({
5393
5387
  onSuccess: () => Effect.succeed({ stale: false }),
5394
5388
  onFailure: (err) => {
5389
+ if (err._tag === "ValidationError")
5390
+ return Effect.fail(err);
5395
5391
  // TransactionCancelled or ConditionalCheckFailed at the transaction
5396
5392
  // level both surface here. Disambiguation between CAS and
5397
5393
  // user-condition happens after the follow-up GetItem (when run).
@@ -5525,7 +5521,6 @@ const makeImpl = (config, precomputedData) => {
5525
5521
  const query = Query.make({
5526
5522
  tableName: "",
5527
5523
  indexName: indexDef.index,
5528
- globalIndex: indexDef.index !== undefined,
5529
5524
  pkField: indexDef.pk.field,
5530
5525
  pkValue,
5531
5526
  skField: indexDef.sk.field,
@@ -5538,6 +5533,8 @@ const makeImpl = (config, precomputedData) => {
5538
5533
  config.indexes.primary.pk.field,
5539
5534
  config.indexes.primary.sk.field,
5540
5535
  ],
5536
+ // DynamoDB's rejection of the key condition (#144) names the accessor.
5537
+ keyConditionDiagnosis: { operation: "query", label: `${entityType}.query.${indexName}` },
5541
5538
  });
5542
5539
  if (hasSkComposites) {
5543
5540
  // `composeSortKeyBeginsWith`, not `composeSortKeyPrefix` — the operand
@@ -5874,7 +5871,6 @@ const makeImpl = (config, precomputedData) => {
5874
5871
  });
5875
5872
  }
5876
5873
  }
5877
- yield* checkTransactionLimit(entityType, "restore", transactItems);
5878
5874
  yield* client
5879
5875
  .transactWriteItems({
5880
5876
  TransactItems: transactItems,
@@ -5921,7 +5917,7 @@ const makeImpl = (config, precomputedData) => {
5921
5917
  }
5922
5918
  }
5923
5919
  return err;
5924
- }));
5920
+ }), diagnoseTransactionWrite("restore", transactItems));
5925
5921
  return yield* decodeAs(restoredItem, marshalledRestoredItem, mode);
5926
5922
  }), self, key);
5927
5923
  // ---------------------------------------------------------------------------
@@ -6067,24 +6063,77 @@ const makeImpl = (config, precomputedData) => {
6067
6063
  // Released one by one, each only while still this item's: a batch
6068
6064
  // delete cannot carry the ownership condition.
6069
6065
  for (const sentinel of yield* ownedSentinels(tableName, [...held.values()], owner)) {
6070
- yield* client.deleteItem(sentinelRelease(tableName, sentinel, owner).Delete).pipe(Effect.catchIf((err) => isAwsConditionalCheckFailed(err.cause), () => Effect.void));
6071
- }
6072
- }
6073
- // Batch delete in chunks of 25
6066
+ const release = sentinelRelease(tableName, sentinel, owner);
6067
+ yield* client.deleteItem(release.Delete).pipe(Effect.catchIf((err) => isAwsConditionalCheckFailed(err.cause), () => Effect.void),
6068
+ // A delete of a stored key cannot meet a size limit; wrapped
6069
+ // for uniformity (#144), so it passes through in practice.
6070
+ diagnoseItemWrite("purge", release));
6071
+ }
6072
+ }
6073
+ // Batch delete in chunks of 25. `UnprocessedItems` are retried with
6074
+ // `Batch.write`'s backoff; what the purge had deleted is always
6075
+ // stated exactly, whether a request is rejected or retries run out.
6076
+ let deleted = 0;
6077
+ const deletedSoFar = () => deleted === 0
6078
+ ? "the purge had deleted none of the partition's items"
6079
+ : `the purge had already deleted ${deleted} of the partition's ${allItems.length} items`;
6074
6080
  for (let i = 0; i < allItems.length; i += 25) {
6075
6081
  const chunk = allItems.slice(i, i + 25);
6076
- yield* client.batchWriteItem({
6077
- RequestItems: {
6078
- [tableName]: chunk.map((item) => ({
6079
- DeleteRequest: {
6080
- Key: {
6081
- [primary.pk.field]: item[primary.pk.field],
6082
- [primary.sk.field]: item[primary.sk.field],
6083
- },
6082
+ let requestItems = {
6083
+ [tableName]: chunk.map((item) => ({
6084
+ DeleteRequest: {
6085
+ Key: {
6086
+ [primary.pk.field]: item[primary.pk.field],
6087
+ [primary.sk.field]: item[primary.sk.field],
6084
6088
  },
6085
- })),
6086
- },
6087
- });
6089
+ },
6090
+ })),
6091
+ };
6092
+ let retries = 0;
6093
+ for (;;) {
6094
+ const sentItems = requestItems;
6095
+ const sentCount = Object.values(sentItems).reduce((sum, requests) => sum + requests.length, 0);
6096
+ const response = yield* client.batchWriteItem({ RequestItems: sentItems }).pipe(
6097
+ // Deletes of stored keys: wrapped for uniformity (#144).
6098
+ diagnoseFailure({
6099
+ sent: () => {
6100
+ const items = batchEntries(sentItems);
6101
+ return {
6102
+ _tag: "Batch",
6103
+ items,
6104
+ targets: () => targetsFor(items, entityKeyFields(), entityType, (index) => `purged item ${deleted + index}`),
6105
+ };
6106
+ },
6107
+ keys: entityKeys,
6108
+ rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, {
6109
+ operation: "purge",
6110
+ entityType,
6111
+ committed: `${deletedSoFar()} before it`,
6112
+ }),
6113
+ }));
6114
+ const unprocessed = {};
6115
+ let remaining = 0;
6116
+ for (const [table, requests] of Object.entries(response.UnprocessedItems ?? {})) {
6117
+ if (requests.length === 0)
6118
+ continue;
6119
+ unprocessed[table] = requests;
6120
+ remaining += requests.length;
6121
+ }
6122
+ deleted += sentCount - remaining;
6123
+ if (remaining === 0)
6124
+ break;
6125
+ retries++;
6126
+ if (retries > MAX_RETRIES) {
6127
+ return yield* new DynamoError({
6128
+ operation: "BatchWriteItem",
6129
+ cause: new Error(`purge: ${remaining} of the partition's ${allItems.length} items were still ` +
6130
+ `unprocessed after ${MAX_RETRIES} retries; ${deletedSoFar()}. The rest ` +
6131
+ "were not deleted: purge again to finish."),
6132
+ });
6133
+ }
6134
+ yield* Effect.sleep(`${BASE_DELAY_MS * 2 ** (retries - 1)} millis`);
6135
+ requestItems = unprocessed;
6136
+ }
6088
6137
  }
6089
6138
  }), self, key);
6090
6139
  // ---------------------------------------------------------------------------
@@ -6360,8 +6409,7 @@ export const bind = (entity) => Effect.gen(function* () {
6360
6409
  // UpdateItem would resurrect it as a key-plus-vector fragment that
6361
6410
  // decodes to nothing but still answers searches. Guard on the item
6362
6411
  // still existing and treat the rejection as "skipped".
6363
- return yield* client
6364
- .updateItem({
6412
+ const update = {
6365
6413
  TableName: tableName,
6366
6414
  Key: {
6367
6415
  [primary.pk.field]: marshalled[primary.pk.field],
@@ -6371,8 +6419,21 @@ export const bind = (entity) => Effect.gen(function* () {
6371
6419
  ConditionExpression: "attribute_exists(#pk)",
6372
6420
  ExpressionAttributeNames: names,
6373
6421
  ExpressionAttributeValues: values,
6374
- })
6375
- .pipe(Effect.as(true), Effect.catchIf((err) => isAwsConditionalCheckFailed(err.cause), () => Effect.succeed(false)));
6422
+ };
6423
+ return yield* client.updateItem(update).pipe(Effect.as(true), Effect.catchIf((err) => isAwsConditionalCheckFailed(err.cause), () => Effect.succeed(false)),
6424
+ // An embedding that grows the item past 400 KB, named (#144).
6425
+ diagnoseFailure({
6426
+ sent: () => ({
6427
+ _tag: "Item",
6428
+ item: { Update: update },
6429
+ target: () => targetsFor([{ Update: update }], [primary.pk.field, primary.sk.field], entity.entityType, () => "the re-embedded item")[0],
6430
+ }),
6431
+ keys: () => keyAttributesOf(entity.indexes),
6432
+ rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, {
6433
+ operation: "reembed",
6434
+ entityType: entity.entityType,
6435
+ }),
6436
+ }));
6376
6437
  });
6377
6438
  let updated = 0;
6378
6439
  let exclusiveStartKey;