effect-dynamodb 1.24.0 → 1.26.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 (106) hide show
  1. package/dist/Aggregate.d.ts +12 -2
  2. package/dist/Aggregate.d.ts.map +1 -1
  3. package/dist/Aggregate.js +284 -55
  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 +9 -1
  10. package/dist/Collection.d.ts.map +1 -1
  11. package/dist/Collection.js +5 -4
  12. package/dist/Collection.js.map +1 -1
  13. package/dist/DynamoClient.d.ts +80 -28
  14. package/dist/DynamoClient.d.ts.map +1 -1
  15. package/dist/DynamoClient.js +125 -25
  16. package/dist/DynamoClient.js.map +1 -1
  17. package/dist/Entity.d.ts +99 -40
  18. package/dist/Entity.d.ts.map +1 -1
  19. package/dist/Entity.js +693 -302
  20. package/dist/Entity.js.map +1 -1
  21. package/dist/EventStore.d.ts +598 -75
  22. package/dist/EventStore.d.ts.map +1 -1
  23. package/dist/EventStore.js +852 -221
  24. package/dist/EventStore.js.map +1 -1
  25. package/dist/Query.d.ts +100 -14
  26. package/dist/Query.d.ts.map +1 -1
  27. package/dist/Query.js +275 -71
  28. package/dist/Query.js.map +1 -1
  29. package/dist/StorageEncoding.d.ts +160 -0
  30. package/dist/StorageEncoding.d.ts.map +1 -0
  31. package/dist/StorageEncoding.js +258 -0
  32. package/dist/StorageEncoding.js.map +1 -0
  33. package/dist/Table.d.ts +56 -6
  34. package/dist/Table.d.ts.map +1 -1
  35. package/dist/Table.js +142 -60
  36. package/dist/Table.js.map +1 -1
  37. package/dist/Transaction.d.ts +8 -0
  38. package/dist/Transaction.d.ts.map +1 -1
  39. package/dist/Transaction.js +72 -22
  40. package/dist/Transaction.js.map +1 -1
  41. package/dist/VectorSearchEmulation.d.ts +3 -2
  42. package/dist/VectorSearchEmulation.d.ts.map +1 -1
  43. package/dist/VectorSearchEmulation.js +14 -2
  44. package/dist/VectorSearchEmulation.js.map +1 -1
  45. package/dist/compression/zlib.d.ts +75 -0
  46. package/dist/compression/zlib.d.ts.map +1 -0
  47. package/dist/compression/zlib.js +109 -0
  48. package/dist/compression/zlib.js.map +1 -0
  49. package/dist/index.d.ts +4 -1
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +3 -1
  52. package/dist/index.js.map +1 -1
  53. package/dist/internal/BoundCrud.d.ts +39 -35
  54. package/dist/internal/BoundCrud.d.ts.map +1 -1
  55. package/dist/internal/BoundCrud.js.map +1 -1
  56. package/dist/internal/BoundQuery.d.ts +6 -5
  57. package/dist/internal/BoundQuery.d.ts.map +1 -1
  58. package/dist/internal/BoundQuery.js.map +1 -1
  59. package/dist/internal/BoundVectorQuery.d.ts +17 -1
  60. package/dist/internal/BoundVectorQuery.d.ts.map +1 -1
  61. package/dist/internal/BoundVectorQuery.js +31 -6
  62. package/dist/internal/BoundVectorQuery.js.map +1 -1
  63. package/dist/internal/EntityCombinators.d.ts +8 -5
  64. package/dist/internal/EntityCombinators.d.ts.map +1 -1
  65. package/dist/internal/EntityCombinators.js +24 -6
  66. package/dist/internal/EntityCombinators.js.map +1 -1
  67. package/dist/internal/EntityOps.d.ts +1 -1
  68. package/dist/internal/Expr.d.ts +9 -3
  69. package/dist/internal/Expr.d.ts.map +1 -1
  70. package/dist/internal/Expr.js +7 -5
  71. package/dist/internal/Expr.js.map +1 -1
  72. package/dist/internal/ExpressionLimits.d.ts +45 -0
  73. package/dist/internal/ExpressionLimits.d.ts.map +1 -0
  74. package/dist/internal/ExpressionLimits.js +89 -0
  75. package/dist/internal/ExpressionLimits.js.map +1 -0
  76. package/dist/internal/Hydrate.d.ts +62 -0
  77. package/dist/internal/Hydrate.d.ts.map +1 -0
  78. package/dist/internal/Hydrate.js +191 -0
  79. package/dist/internal/Hydrate.js.map +1 -0
  80. package/dist/internal/ItemSize.d.ts +60 -24
  81. package/dist/internal/ItemSize.d.ts.map +1 -1
  82. package/dist/internal/ItemSize.js +122 -39
  83. package/dist/internal/ItemSize.js.map +1 -1
  84. package/dist/internal/Packed.d.ts +289 -0
  85. package/dist/internal/Packed.d.ts.map +1 -0
  86. package/dist/internal/Packed.js +654 -0
  87. package/dist/internal/Packed.js.map +1 -0
  88. package/dist/internal/PathBuilder.d.ts +45 -2
  89. package/dist/internal/PathBuilder.d.ts.map +1 -1
  90. package/dist/internal/PathBuilder.js +4 -0
  91. package/dist/internal/PathBuilder.js.map +1 -1
  92. package/dist/internal/TransactWriteOps.d.ts +22 -46
  93. package/dist/internal/TransactWriteOps.d.ts.map +1 -1
  94. package/dist/internal/TransactWriteOps.js +26 -95
  95. package/dist/internal/TransactWriteOps.js.map +1 -1
  96. package/dist/internal/TransactableOps.d.ts +1 -1
  97. package/dist/internal/TransactableOps.d.ts.map +1 -1
  98. package/dist/internal/TransactableOps.js +2 -0
  99. package/dist/internal/TransactableOps.js.map +1 -1
  100. package/dist/internal/WriteDiagnosis.d.ts +353 -0
  101. package/dist/internal/WriteDiagnosis.d.ts.map +1 -0
  102. package/dist/internal/WriteDiagnosis.js +1028 -0
  103. package/dist/internal/WriteDiagnosis.js.map +1 -0
  104. package/dist/internal/sparse_check.js +43 -79
  105. package/dist/internal/sparse_check.js.map +1 -1
  106. package/package.json +6 -2
package/dist/Entity.js CHANGED
@@ -9,9 +9,10 @@
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
+ import { packedIndexRefusal } from "@effect-dynamodb/schema/internal/EntityPacked.js";
15
16
  import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
16
17
  import { normalizeGsiConfig } from "@effect-dynamodb/schema/KeyComposer.js";
17
18
  import { deriveSourceText } from "@effect-dynamodb/schema/VectorIndex.js";
@@ -20,8 +21,12 @@ import { DynamoClient } from "./DynamoClient.js";
20
21
  import { isBoundOp, makeBoundAppend, makeBoundDelete, makeBoundGet, makeBoundPut, makeBoundUpdate, } from "./internal/BoundCrud.js";
21
22
  import { BoundQueryImpl, entityNaming, } from "./internal/BoundQuery.js";
22
23
  import { compileExpr, createConditionOps, emptyPartProblem, isExpr, nonEmptyCondition, parseShorthand, parseSimpleShorthand, toExpr, } from "./internal/Expr.js";
24
+ import { countOperators, expressionFits } from "./internal/ExpressionLimits.js";
25
+ import { BASE_DELAY_MS, MAX_RETRIES } from "./internal/Hydrate.js";
26
+ import { decodeImage, imageEntityType, ownRowsRawRow, packAttributeValueSync, packedExprRefusal, packedProjectionRefusal, packItemSync, unpackRow, withRegistry, } from "./internal/Packed.js";
23
27
  import { compilePath, createPathBuilder } from "./internal/PathBuilder.js";
24
28
  import { freshIncarnationToken, generateTimestampPrimitive, INCARNATION_TOKEN, UNSENTINELED_DEFAULTS, } from "./internal/TransactableOps.js";
29
+ import { batchEntries, diagnosedValidationError, diagnoseFailure, keyAttributesOf, targetsFor, transactItemTarget, } from "./internal/WriteDiagnosis.js";
25
30
  import { decodeSparseFields, encodeSparseFields, fromAttributeMap, toAttributeMap, toAttributeValue, } from "./Marshaller.js";
26
31
  import * as Query from "./Query.js";
27
32
  import { filterExpr, selectPaths } from "./Query.js";
@@ -131,58 +136,8 @@ const keyFormFor = (target, record) => {
131
136
  */
132
137
  class MissingForCreate extends Data.TaggedError("MissingForCreate") {
133
138
  }
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
- };
139
+ /** @internal Re-exported for tests: what DynamoDB counts against an expression's 300 operators. */
140
+ export { countOperators };
186
141
  const bytesKey = (bytes) => Array.from(bytes).join(",");
187
142
  /** Structural equality of two marshalled values (sets compare as sets). */
188
143
  const attributeValueEquals = (a, b) => {
@@ -270,9 +225,6 @@ Effect.catch(() => Effect.fail(new ValidationError({
270
225
  operation: `${operation}.decode`,
271
226
  cause: primaryCause,
272
227
  }))))));
273
- // ---------------------------------------------------------------------------
274
- // Transaction limit pre-check (100-item DynamoDB limit)
275
- // ---------------------------------------------------------------------------
276
228
  /**
277
229
  * Compile a condition that may be either an Expr ADT node or a ConditionInput object.
278
230
  * Routes both paths through the Expr ADT compiler for a single compilation backend.
@@ -284,15 +236,6 @@ const compileCondition = (cond, resolveDbNameFn) => {
284
236
  const expr = isExpr(cond) ? cond : parseShorthand(cond);
285
237
  return compileExpr(expr, resolveDbNameFn);
286
238
  };
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
239
  /** Attempts a guarded put makes before it reports a lost race (#133). */
297
240
  const GUARDED_PUT_ATTEMPTS = 3;
298
241
  /**
@@ -673,17 +616,46 @@ const makeImpl = (config, precomputedData) => {
673
616
  if (encoded.issue !== undefined) {
674
617
  return `.set() of immutable "${field}": ${String(encoded.issue)}`;
675
618
  }
676
- out.push({ field, attr: resolveDbName(field), value: toAttributeValue(encoded.encoded) });
619
+ out.push({
620
+ field,
621
+ attr: resolveDbName(field),
622
+ value: toAttributeValue(encoded.encoded),
623
+ wire: encoded.encoded,
624
+ packed: field in packedFields,
625
+ });
677
626
  }
678
627
  return out;
679
628
  };
629
+ const immutableChangedMessage = (field) => `.set() of "${field}": "${field}" is immutable and the update's value ` +
630
+ "differs from the stored one.";
631
+ /** A restated immutable value that differs from the stored item's (packed fields excepted). */
680
632
  const immutableMismatch = (asserted, stored) => {
681
- const changed = asserted.find(({ attr, value }) => !attributeValueEquals(stored[attr], value));
682
- return changed === undefined
683
- ? undefined
684
- : `.set() of "${changed.field}": "${changed.field}" is immutable and the update's value ` +
685
- "differs from the stored one.";
633
+ const changed = asserted.find(({ attr, value, packed }) => !packed && !attributeValueEquals(stored[attr], value));
634
+ return changed === undefined ? undefined : immutableChangedMessage(changed.field);
686
635
  };
636
+ /**
637
+ * A restated immutable PACKED value that differs from the stored item's,
638
+ * compared by the stored value's unpacked JSON (#147) — whatever codec or
639
+ * threshold it was written under.
640
+ */
641
+ const packedImmutableMismatch = (asserted, stored) => Effect.gen(function* () {
642
+ for (const { field, attr, wire, packed } of asserted) {
643
+ if (!packed)
644
+ continue;
645
+ const storedValue = stored[attr];
646
+ if (storedValue === undefined) {
647
+ if (wire === undefined || wire === null)
648
+ continue;
649
+ return immutableChangedMessage(field);
650
+ }
651
+ const raw = fromAttributeMap({ [attr]: storedValue });
652
+ yield* unpackFields(raw, "update.decode");
653
+ if (!attributeValueEquals(toAttributeValue(raw[attr]), toAttributeValue(wire))) {
654
+ return immutableChangedMessage(field);
655
+ }
656
+ }
657
+ return undefined;
658
+ });
687
659
  /**
688
660
  * Whether an update can safely create the item it names (#133): its payload
689
661
  * supplies every required model field and every primary-key composite, so
@@ -739,6 +711,136 @@ const makeImpl = (config, precomputedData) => {
739
711
  return;
740
712
  decodeSparseFields(raw, sparseFields);
741
713
  };
714
+ // ---------------------------------------------------------------------------
715
+ // Packed fields (`storedAs: DynamoModel.Packed`) — see
716
+ // `docs/designs/storage-encoding.md` §6. Packing runs after renames and
717
+ // sparse flattening, before `toAttributeMap`; unpacking after
718
+ // `fromAttributeMap`, before sparse rebuild and renames — the same points as
719
+ // the sparse layout, keyed by stored attribute names.
720
+ // ---------------------------------------------------------------------------
721
+ const { packedFields, packedAttributes, dispatchAttributes, hasPackedFields } = data;
722
+ /**
723
+ * Pack, in place, the packed attributes of a stored-name item. `only`
724
+ * restricts it to those stored attributes (the read-merge update path packs
725
+ * only the fields it names: the others still hold their stored value).
726
+ */
727
+ const packFields = (item, operation, only) => {
728
+ if (!hasPackedFields)
729
+ return Effect.void;
730
+ const targets = only === undefined
731
+ ? packedAttributes
732
+ : Object.fromEntries(Object.entries(packedAttributes).filter(([attr]) => only.has(attr)));
733
+ if (Object.keys(targets).length === 0)
734
+ return Effect.void;
735
+ return withRegistry((registry) => packItemSync(item, targets, registry, { entityType, operation: `${operation}.packed` }));
736
+ };
737
+ /** Pack one attribute's wire value straight to its `AttributeValue` (an `UpdateItem` SET). */
738
+ const packAttribute = (attribute, wire, operation) => withRegistry((registry) => packAttributeValueSync(packedAttributes[attribute], wire, registry, { entityType, operation: `${operation}.packed` }, attribute));
739
+ /**
740
+ * Read, in place, the dispatch-eligible attributes of a stored-name row by
741
+ * their stored form. A row with no string or binary value on any of them
742
+ * (every native row) costs one `typeof` per attribute.
743
+ */
744
+ const unpackFields = (raw, operation = "decode") => dispatchAttributes.length === 0
745
+ ? Effect.void
746
+ : unpackRow(raw, dispatchAttributes, { entityType, operation });
747
+ /**
748
+ * A domain-named copy of `record` with its packed (or once-packed) attributes
749
+ * read by their stored form. The update paths derive vector source text from
750
+ * it: an untouched packed field still holds its stored value there (a JSON
751
+ * string or compressed bytes), not the value the source `compose` expects.
752
+ * `record` itself is left as is, so the write still carries the stored bytes.
753
+ */
754
+ const unpackedDomainCopy = (record, operation) => {
755
+ if (dispatchAttributes.length === 0)
756
+ return Effect.succeed(record);
757
+ const stored = { ...record };
758
+ renameToDynamo(stored);
759
+ return Effect.map(unpackFields(stored, operation), () => {
760
+ renameFromDynamo(stored);
761
+ return stored;
762
+ });
763
+ };
764
+ /** The packed attributes an expression or projection may not look inside (EDD-9076). */
765
+ const packedGuards = Object.entries(packedFields).flatMap(([field, storedAs]) => {
766
+ const guard = (attribute) => ({
767
+ attribute,
768
+ owner: `Entity "${entityType}"`,
769
+ storedAs,
770
+ alternative: () => `To filter on it, use .filterBy((item) => ...), which runs on the decoded item; to ` +
771
+ `condition a write on it, read the item and compare in code, then write the whole ` +
772
+ `value with .set({ ${field} }).`,
773
+ });
774
+ const stored = resolveDbName(field);
775
+ return stored === field ? [guard(field)] : [guard(field), guard(stored)];
776
+ });
777
+ /** Throw `[EDD-9076]` when an expression looks inside a packed field. */
778
+ const refusePackedExpr = (expr, usage) => {
779
+ const refused = packedExprRefusal(expr, packedGuards, usage);
780
+ if (refused !== undefined)
781
+ throw new Error(refused);
782
+ };
783
+ /**
784
+ * The `Query.make` options of this entity's queries: the packed guards, and
785
+ * the raw-row hook that reads packed attributes of a projected row of THIS
786
+ * entity type by their stored form (parsed JSON, not schema-decoded) — rows
787
+ * of other types (`ignoreOwnership()`) are left untouched.
788
+ */
789
+ const packedQueryOptions = {
790
+ packed: packedGuards,
791
+ rawRow: ownRowsRawRow(entityType, dispatchAttributes, (raw) => unpackFields(raw)),
792
+ };
793
+ /**
794
+ * `[EDD-9077]`: an update applies anything but a whole-value `.set()` /
795
+ * `.remove()` to a packed field — DynamoDB cannot compute inside one opaque
796
+ * value. `undefined` when the update is fine.
797
+ */
798
+ const refusedPackedUpdate = (uState) => {
799
+ if (!hasPackedFields)
800
+ return undefined;
801
+ const refuse = (op, field) => `[EDD-9077] Entity "${entityType}": ${op} on "${field}", but "${field}" is stored packed ` +
802
+ `(format "${packedFields[field].format}"): DynamoDB holds it as one opaque string or ` +
803
+ `binary value and cannot apply ${op} inside it. Write the whole value with ` +
804
+ `.set({ ${field} }) (or .remove(["${field}"])).`;
805
+ const records = [
806
+ [".add()", uState.add],
807
+ [".subtract()", uState.subtract],
808
+ [".append()", uState.append],
809
+ [".deleteFromSet()", uState.deleteFromSet],
810
+ ];
811
+ for (const [op, values] of records) {
812
+ for (const field of Object.keys(values ?? {})) {
813
+ if (field in packedFields)
814
+ return refuse(op, field);
815
+ }
816
+ }
817
+ const pathOps = [
818
+ ["pathSet", uState.pathSets ?? []],
819
+ ["pathRemove", (uState.pathRemoves ?? []).map((segments) => ({ segments }))],
820
+ ["pathAdd", uState.pathAdds ?? []],
821
+ ["pathSubtract", uState.pathSubtracts ?? []],
822
+ ["pathAppend", uState.pathAppends ?? []],
823
+ ["pathPrepend", uState.pathPrepends ?? []],
824
+ ["pathIfNotExists", uState.pathIfNotExists ?? []],
825
+ ["pathDelete", uState.pathDeletes ?? []],
826
+ ];
827
+ for (const [op, ops] of pathOps) {
828
+ for (const { segments } of ops) {
829
+ const head = segments[0];
830
+ if (typeof head === "string" && head in packedFields)
831
+ return refuse(op, head);
832
+ }
833
+ }
834
+ for (const { field } of uState.sparseRemoveEntries ?? []) {
835
+ if (field in packedFields)
836
+ return refuse(".removeEntries()", field);
837
+ }
838
+ for (const field of uState.sparseClearFields ?? []) {
839
+ if (field in packedFields)
840
+ return refuse(".clearMap()", field);
841
+ }
842
+ return undefined;
843
+ };
742
844
  /**
743
845
  * A DOMAIN-keyed VIEW of a row that came off the wire.
744
846
  *
@@ -833,6 +935,59 @@ const makeImpl = (config, precomputedData) => {
833
935
  * through `keyForm(...)`.
834
936
  */
835
937
  const keyForm = (record) => toCompositeKeyRecord(compositeKeyForm, record);
938
+ // -------------------------------------------------------------------------
939
+ // Write-limit diagnosis (#144)
940
+ //
941
+ // DynamoDB's limits — 400 KB per item, 100 items and 4 MB per transaction,
942
+ // 1024-byte sort keys — are not pre-checked. Every write is sent; when
943
+ // DynamoDB rejects it, `internal/WriteDiagnosis.ts` recognises the
944
+ // rejection, confirms it against the request and names the item. Applied
945
+ // after each site's conditional-failure mapping; nothing runs on success.
946
+ // -------------------------------------------------------------------------
947
+ const entityKeyFields = () => [
948
+ config.indexes.primary.pk.field,
949
+ config.indexes.primary.sk.field,
950
+ ];
951
+ const entityKeys = () => keyAttributesOf(config.indexes);
952
+ /**
953
+ * Diagnose a rejected single-item write (`PutItem` / `UpdateItem` /
954
+ * `DeleteItem`). `callerCondition`: the caller's own `.condition()` within
955
+ * the write's condition, to say how much of an over-long one is theirs.
956
+ */
957
+ const diagnoseItemWrite = (operation, item, callerCondition) => diagnoseFailure({
958
+ sent: () => ({
959
+ _tag: "Item",
960
+ item,
961
+ target: () => targetsFor([item], entityKeyFields(), entityType, () => `the ${entityType} ${operation}`)[0],
962
+ callerCondition,
963
+ }),
964
+ keys: entityKeys,
965
+ rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, { operation, entityType }),
966
+ });
967
+ /**
968
+ * Diagnose a rejected `TransactWriteItems` of this entity's write: over 100
969
+ * items is `TransactionOverflow`, every other limit `ValidationError`.
970
+ */
971
+ const diagnoseTransactionWrite = (operation, items,
972
+ /** The caller's own `.condition()` within the first item's (its main write's) condition. */
973
+ callerCondition) => diagnoseFailure({
974
+ sent: () => ({
975
+ _tag: "Transaction",
976
+ items,
977
+ targets: () => targetsFor(items, entityKeyFields(), entityType, (index) => index === 0 ? "the item" : `item ${index} of the ${operation} transaction`),
978
+ callerCondition: (index) => (index === 0 ? callerCondition?.() : undefined),
979
+ }),
980
+ keys: entityKeys,
981
+ rewrite: (diagnosis, original) => diagnosis._tag === "TooManyItems"
982
+ ? new TransactionOverflow({
983
+ entityType,
984
+ operation,
985
+ itemCount: diagnosis.count,
986
+ limit: diagnosis.limit,
987
+ cause: original,
988
+ })
989
+ : diagnosedValidationError(diagnosis, original, { operation, entityType }),
990
+ });
836
991
  const composePrimaryKey = (record) => {
837
992
  const primary = config.indexes.primary;
838
993
  const recordKeyForm = keyForm(record);
@@ -979,43 +1134,68 @@ const makeImpl = (config, precomputedData) => {
979
1134
  const corruption = versionCorruption(raw, "decode");
980
1135
  if (corruption !== undefined)
981
1136
  return Effect.fail(corruption);
982
- deserializeSparseFields(raw);
983
- renameFromDynamo(raw);
984
- return Schema.decodeUnknownEffect(schemas.recordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
985
- entityType,
986
- operation: "decode",
987
- cause,
988
- })));
1137
+ return Effect.flatMap(unpackFields(raw), () => {
1138
+ deserializeSparseFields(raw);
1139
+ renameFromDynamo(raw);
1140
+ return Schema.decodeUnknownEffect(schemas.recordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
1141
+ entityType,
1142
+ operation: "decode",
1143
+ cause,
1144
+ })));
1145
+ });
989
1146
  };
990
1147
  /** Decode a raw item using the schema selected by mode */
991
1148
  const decodeAs = (raw, marshalled, mode) => {
992
1149
  const corruption = versionCorruption(raw, "decode");
993
1150
  if (corruption !== undefined)
994
1151
  return Effect.fail(corruption);
1152
+ // Native: the item as stored — packed values stay their `S` / `B`.
995
1153
  if (mode === "native")
996
1154
  return Effect.succeed(marshalled);
997
- // Collect flattened sparse-map attributes back into the domain Record.
998
- // Must happen before rename so the rebuilt Record sits at the domain field
999
- // name (sparse field renaming is not supported).
1155
+ return Effect.flatMap(unpackFields(raw), () => {
1156
+ // Collect flattened sparse-map attributes back into the domain Record.
1157
+ // Must happen before rename so the rebuilt Record sits at the domain field
1158
+ // name (sparse field renaming is not supported).
1159
+ deserializeSparseFields(raw);
1160
+ // Rename DynamoDB attributes back to domain field names. Substituted
1161
+ // schemas handle wire→domain conversion (date primitives, Redacted, etc.).
1162
+ renameFromDynamo(raw);
1163
+ const targetSchema = mode === "model"
1164
+ ? schemas.modelSchema
1165
+ : mode === "record"
1166
+ ? schemas.recordSchema
1167
+ : schemas.itemSchema;
1168
+ return Schema.decodeUnknownEffect(targetSchema)(raw).pipe(Effect.map((decoded) => mode === "model" && isSchemaClass && !hasHiddenFields
1169
+ ? new rawModel(decoded)
1170
+ : mode !== "item"
1171
+ ? attachPrototype(decoded)
1172
+ : decoded), Effect.mapError((cause) => new ValidationError({
1173
+ entityType,
1174
+ operation: "decode",
1175
+ cause,
1176
+ })));
1177
+ });
1178
+ };
1179
+ /**
1180
+ * Decode a DynamoDB Streams image into an item record (`Entity.Item`): the
1181
+ * image normalised (DynamoDB JSON or unmarshalled; `B` as bytes or base64),
1182
+ * its dispatch-eligible attributes read by stored form, then sparse rebuild,
1183
+ * renames and the item-schema decode — exactly what a table read applies.
1184
+ */
1185
+ const decodeImageItem = (image) => Effect.gen(function* () {
1186
+ // An image of another entity type is decoded as 1.25.0 decoded it: its
1187
+ // attributes are never read as packed values (a plain string of that
1188
+ // type is not this entity's JSON). The type is read off the raw image.
1189
+ const owner = imageEntityType(image);
1190
+ const raw = yield* decodeImage(image, owner === undefined || owner === entityType ? dispatchAttributes : [], { entityType, operation: "decodeMarshalledItem" }, { probe: config.indexes.primary.pk.field });
1191
+ const corruption = versionCorruption(raw, "decodeMarshalledItem");
1192
+ if (corruption !== undefined)
1193
+ return yield* corruption;
1000
1194
  deserializeSparseFields(raw);
1001
- // Rename DynamoDB attributes back to domain field names. Substituted
1002
- // schemas handle wire→domain conversion (date primitives, Redacted, etc.).
1003
1195
  renameFromDynamo(raw);
1004
- const targetSchema = mode === "model"
1005
- ? schemas.modelSchema
1006
- : mode === "record"
1007
- ? schemas.recordSchema
1008
- : schemas.itemSchema;
1009
- return Schema.decodeUnknownEffect(targetSchema)(raw).pipe(Effect.map((decoded) => mode === "model" && isSchemaClass && !hasHiddenFields
1010
- ? new rawModel(decoded)
1011
- : mode !== "item"
1012
- ? attachPrototype(decoded)
1013
- : decoded), Effect.mapError((cause) => new ValidationError({
1014
- entityType,
1015
- operation: "decode",
1016
- cause,
1017
- })));
1018
- };
1196
+ const decoded = yield* Schema.decodeUnknownEffect(schemas.itemSchema)(raw).pipe(Effect.mapError((cause) => new ValidationError({ entityType, operation: "decodeMarshalledItem", cause })));
1197
+ return attachPrototype(decoded);
1198
+ });
1019
1199
  const nowIso = (now) => DateTime.formatIso(now);
1020
1200
  /**
1021
1201
  * Generate a timestamp value as a wire primitive ready for marshalling.
@@ -1320,6 +1500,9 @@ const makeImpl = (config, precomputedData) => {
1320
1500
  const current = yield* client.getItem({ TableName: tableName, Key: marshalledKey });
1321
1501
  if (current.Item) {
1322
1502
  const currentDomain = fromAttributeMap(current.Item);
1503
+ // Packed fields are read by their stored form: the source text is
1504
+ // derived from the value, not its JSON string or compressed bytes.
1505
+ yield* unpackFields(currentDomain, "update.vector");
1323
1506
  renameFromDynamo(currentDomain);
1324
1507
  merged = { ...currentDomain, ...merged };
1325
1508
  }
@@ -1485,25 +1668,6 @@ const makeImpl = (config, precomputedData) => {
1485
1668
  }
1486
1669
  return new ConditionalCheckFailed({ entityType, key });
1487
1670
  };
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
1671
  /** The domain field a stored attribute holds (`storedAs` renames reversed). */
1508
1672
  const domainNameOf = (attr) => Object.keys(data.modelFields).find((field) => resolveDbName(field) === attr) ?? attr;
1509
1673
  /**
@@ -1514,7 +1678,9 @@ const makeImpl = (config, precomputedData) => {
1514
1678
  * absent). `"item"` means the whole item — a tombstone copies all of it —
1515
1679
  * so every stored attribute is guarded, plus every attribute the entity
1516
1680
  * 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).
1681
+ * strongest guard within DynamoDB's expression limit (never a refusal). A
1682
+ * guard that cannot fit beside `others` at all is sent as it is: DynamoDB
1683
+ * rejects the expression, and the rejection is diagnosed (#144).
1518
1684
  */
1519
1685
  const deleteGuard = (read, dependsOn,
1520
1686
  /** The rest of the condition, ANDed with the guard (the user's, …). */
@@ -1540,15 +1706,10 @@ const makeImpl = (config, precomputedData) => {
1540
1706
  const incarnation = incarnationGuard(read, names, values);
1541
1707
  if (incarnation !== undefined)
1542
1708
  parts.push(incarnation);
1543
- const expression = parts.join(" AND ");
1544
- if (!fits(expression))
1545
- return tooLargeBeside(expression);
1546
- return { expression, names, values, inputs: [] };
1709
+ return { expression: parts.join(" AND "), names, values, inputs: [] };
1547
1710
  }
1548
1711
  names["#dpk"] = pkField;
1549
1712
  parts.push("attribute_exists(#dpk)");
1550
- if (!fits(parts.join(" AND ")))
1551
- return tooLargeBeside(parts.join(" AND "));
1552
1713
  const attrs = dependsOn === "item"
1553
1714
  ? [
1554
1715
  ...new Set([
@@ -1604,9 +1765,6 @@ const makeImpl = (config, precomputedData) => {
1604
1765
  inputs.push(attr);
1605
1766
  }
1606
1767
  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
1768
  };
1611
1769
  /** Which predicate rejected a guarded delete — from the stored item (ALL_OLD). */
1612
1770
  const deleteRejection = (key, read, inputs, stored, userCondition) => {
@@ -1711,16 +1869,18 @@ const makeImpl = (config, precomputedData) => {
1711
1869
  if (mode === "native")
1712
1870
  return Effect.succeed(projected);
1713
1871
  const raw = fromAttributeMap(projected);
1714
- deserializeSparseFields(raw);
1715
- renameFromDynamo(raw);
1716
- const target = mode === "model"
1717
- ? schemas.modelSchema
1718
- : mode === "record"
1719
- ? schemas.recordSchema
1720
- : schemas.itemSchema;
1721
- const fields = target.fields;
1722
- const present = Object.fromEntries(Object.entries(fields).filter(([name]) => name in raw));
1723
- return Schema.decodeUnknownEffect(Schema.Struct(present))(raw).pipe(Effect.mapError((cause) => new ValidationError({ entityType, operation: "decode", cause })));
1872
+ return Effect.flatMap(unpackFields(raw), () => {
1873
+ deserializeSparseFields(raw);
1874
+ renameFromDynamo(raw);
1875
+ const target = mode === "model"
1876
+ ? schemas.modelSchema
1877
+ : mode === "record"
1878
+ ? schemas.recordSchema
1879
+ : schemas.itemSchema;
1880
+ const fields = target.fields;
1881
+ const present = Object.fromEntries(Object.entries(fields).filter(([name]) => name in raw));
1882
+ return Schema.decodeUnknownEffect(Schema.Struct(present))(raw).pipe(Effect.mapError((cause) => new ValidationError({ entityType, operation: "decode", cause })));
1883
+ });
1724
1884
  };
1725
1885
  /**
1726
1886
  * The value an update returns for its `returnValues` mode, from the item
@@ -2192,15 +2352,12 @@ const makeImpl = (config, precomputedData) => {
2192
2352
  inputs: [],
2193
2353
  }
2194
2354
  : deleteGuard(current, uniqueAttrs, userCondition?.expression);
2195
- if (guard instanceof ValidationError)
2196
- return yield* guard;
2197
2355
  const mainValues = { ...guard.values, ...userCondition?.values };
2356
+ // Sent as built: a condition over DynamoDB's expression limits is
2357
+ // DynamoDB's to reject, and is diagnosed naming the caller's part (#144).
2198
2358
  const mainCondition = userCondition
2199
2359
  ? `${guard.expression} AND (${userCondition.expression})`
2200
2360
  : guard.expression;
2201
- const tooLarge = oversizedCondition(args.operation, mainCondition, userCondition?.expression);
2202
- if (tooLarge !== undefined)
2203
- return yield* tooLarge;
2204
2361
  const items = [
2205
2362
  {
2206
2363
  Put: {
@@ -2355,7 +2512,6 @@ const makeImpl = (config, precomputedData) => {
2355
2512
  let lost;
2356
2513
  for (let attempt = 0; attempt < GUARDED_PUT_ATTEMPTS; attempt++) {
2357
2514
  const plan = yield* planPut(args);
2358
- yield* checkTransactionLimit(entityType, args.operation, plan.items);
2359
2515
  const write = plan.items.length === 1
2360
2516
  ? client.putItem(plan.items[0].Put)
2361
2517
  : client.transactWriteItems({ TransactItems: [...plan.items] });
@@ -2367,7 +2523,11 @@ const makeImpl = (config, precomputedData) => {
2367
2523
  : undefined;
2368
2524
  const read = reasons !== undefined ? plan.verdict(reasons) : undefined;
2369
2525
  return read !== undefined ? Effect.succeed(read) : Effect.fail(err);
2370
- }));
2526
+ }),
2527
+ // Terminal: a diagnosed limit is never retried as a lost race.
2528
+ plan.items.length === 1
2529
+ ? diagnoseItemWrite(args.operation, plan.items[0], () => args.userCondition?.expression)
2530
+ : diagnoseTransactionWrite(args.operation, plan.items, () => args.userCondition?.expression));
2371
2531
  if (verdict === undefined)
2372
2532
  return plan;
2373
2533
  switch (verdict._tag) {
@@ -2518,6 +2678,11 @@ const makeImpl = (config, precomputedData) => {
2518
2678
  }
2519
2679
  // Resolve target table name
2520
2680
  const { name: targetTableName } = yield* target._tableTag;
2681
+ // The target's primary-key attributes. A target need not be keyed `pk` /
2682
+ // `sk`, and one cascade can span targets keyed differently, so each
2683
+ // update reads its key — and is diagnosed — by its own target's fields.
2684
+ const targetPrimary = target.indexes.primary;
2685
+ const keyFields = [targetPrimary.pk.field, targetPrimary.sk.field];
2521
2686
  // Compose GSI PK for the source entity's ID
2522
2687
  const targetSchema = target._schema;
2523
2688
  const gsiPkValue = KeyComposer.composePk(targetSchema, target.entityType, cascadeIdx.indexDef, keyFormFor(target, { [matchingRef.idFieldName]: sourceIdValue }));
@@ -2558,8 +2723,10 @@ const makeImpl = (config, precomputedData) => {
2558
2723
  });
2559
2724
  for (const item of queryResult.Items ?? []) {
2560
2725
  allUpdateOps.push({
2726
+ entityType: target.entityType,
2561
2727
  tableName: targetTableName,
2562
- key: { pk: item.pk, sk: item.sk },
2728
+ key: { [keyFields[0]]: item[keyFields[0]], [keyFields[1]]: item[keyFields[1]] },
2729
+ keyFields,
2563
2730
  refFieldName: matchingRef.fieldName,
2564
2731
  refData: toAttributeValue(sourceDomainData),
2565
2732
  });
@@ -2570,45 +2737,76 @@ const makeImpl = (config, precomputedData) => {
2570
2737
  if (allUpdateOps.length === 0)
2571
2738
  return;
2572
2739
  if (mode === "transactional") {
2573
- // Transactional mode — fail if >100 items
2574
- if (allUpdateOps.length > 100) {
2575
- return yield* new CascadePartialFailure({
2740
+ // Transactional mode — one atomic transaction. DynamoDB's limits (100
2741
+ // items, 4 MB, item size, sort keys) are not pre-checked (#144): a
2742
+ // rejection is diagnosed and reported as a cascade that wrote nothing.
2743
+ const cascadeItems = allUpdateOps.map((op) => ({
2744
+ Update: {
2745
+ TableName: op.tableName,
2746
+ Key: op.key,
2747
+ UpdateExpression: "SET #ref = :refData",
2748
+ ExpressionAttributeNames: { "#ref": op.refFieldName },
2749
+ ExpressionAttributeValues: { ":refData": op.refData },
2750
+ },
2751
+ }));
2752
+ yield* client.transactWriteItems({ TransactItems: [...cascadeItems] }).pipe(diagnoseFailure({
2753
+ sent: () => ({
2754
+ _tag: "Transaction",
2755
+ items: cascadeItems,
2756
+ targets: () => cascadeItems.map((item, index) => {
2757
+ const op = allUpdateOps[index];
2758
+ return transactItemTarget(item, op.tableName, op.keyFields, op.entityType, `the cascade update of ${op.entityType} (item ${index})`);
2759
+ }),
2760
+ }),
2761
+ keys: () => ({
2762
+ partition: [...new Set(allUpdateOps.map((op) => op.keyFields[0]))],
2763
+ sort: [...new Set(allUpdateOps.map((op) => op.keyFields[1]))],
2764
+ }),
2765
+ rewrite: (diagnosis, original) => new CascadePartialFailure({
2576
2766
  sourceEntity: sourceEntityType,
2577
2767
  sourceId: sourceIdValue,
2578
2768
  succeeded: 0,
2579
2769
  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
- });
2770
+ errors: [
2771
+ diagnosedValidationError(diagnosis, original, {
2772
+ operation: "cascade",
2773
+ entityType: sourceEntityType,
2774
+ }),
2775
+ ],
2776
+ }),
2777
+ }));
2594
2778
  }
2595
2779
  else {
2596
2780
  // Eventual mode — concurrent updates with partial failure tracking
2597
2781
  let succeeded = 0;
2598
2782
  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
- }))));
2783
+ const updateEffects = allUpdateOps.map((op) => {
2784
+ const update = {
2785
+ TableName: op.tableName,
2786
+ Key: op.key,
2787
+ UpdateExpression: "SET #ref = :refData",
2788
+ ExpressionAttributeNames: { "#ref": op.refFieldName },
2789
+ ExpressionAttributeValues: { ":refData": op.refData },
2790
+ };
2791
+ return client.updateItem(update).pipe(
2792
+ // An item the cascade grows past 400 KB is named in `errors`.
2793
+ diagnoseFailure({
2794
+ sent: () => ({
2795
+ _tag: "Item",
2796
+ item: { Update: update },
2797
+ target: () => transactItemTarget({ Update: update }, op.tableName, op.keyFields, op.entityType, `the cascade update of ${op.entityType}`),
2798
+ }),
2799
+ keys: () => ({ partition: [op.keyFields[0]], sort: [op.keyFields[1]] }),
2800
+ rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, {
2801
+ operation: "cascade",
2802
+ entityType: op.entityType,
2803
+ }),
2804
+ }), Effect.tap(() => Effect.sync(() => {
2805
+ succeeded++;
2806
+ })), Effect.catch((err) => Effect.sync(() => {
2807
+ errors.push(err);
2808
+ })));
2809
+ });
2612
2810
  yield* Effect.all(updateEffects, { concurrency: 10 });
2613
2811
  if (errors.length > 0) {
2614
2812
  return yield* new CascadePartialFailure({
@@ -2730,6 +2928,11 @@ const makeImpl = (config, precomputedData) => {
2730
2928
  cause: e instanceof Error ? e.message : String(e),
2731
2929
  });
2732
2930
  }
2931
+ // Pack `storedAs: DynamoModel.Packed` fields to their stored form
2932
+ // (JSON string, or compressed binary over the threshold) — after
2933
+ // renames and sparse flattening, before marshalling, so every size
2934
+ // measured from here on is the stored size.
2935
+ yield* packFields(item, operation);
2733
2936
  // The caller's condition, ANDed onto `create`'s not-exists guard —
2734
2937
  // a guard no `.condition()` can replace (#133). `upsert`'s create
2735
2938
  // attempt brings its own (`runGuardedPut` requires the item missing).
@@ -2778,7 +2981,7 @@ const makeImpl = (config, precomputedData) => {
2778
2981
  });
2779
2982
  }
2780
2983
  return err;
2781
- }));
2984
+ }), diagnoseItemWrite("put", { Put: putInput }));
2782
2985
  }
2783
2986
  // Decode and return using selected mode
2784
2987
  return yield* decodeAs(item, marshalledItem, mode);
@@ -2816,6 +3019,14 @@ const makeImpl = (config, precomputedData) => {
2816
3019
  let projectionExpression;
2817
3020
  let projectionNames;
2818
3021
  if (opts.projection && opts.projection.length > 0) {
3022
+ const packedProjection = packedProjectionRefusal(opts.projection.map((attribute) => [attribute]), packedGuards);
3023
+ if (packedProjection !== undefined) {
3024
+ return yield* new ValidationError({
3025
+ entityType,
3026
+ operation: "get.projection",
3027
+ cause: packedProjection,
3028
+ });
3029
+ }
2819
3030
  const proj = Projection.projection(opts.projection);
2820
3031
  projectionExpression = proj.expression;
2821
3032
  projectionNames = proj.names;
@@ -2832,9 +3043,12 @@ const makeImpl = (config, precomputedData) => {
2832
3043
  if (!result.Item) {
2833
3044
  return yield* new ItemNotFound({ entityType, key: encodedKey });
2834
3045
  }
2835
- // Raw mode: return unmarshalled record without schema decode
3046
+ // Raw mode: return unmarshalled record without schema decode — a
3047
+ // packed attribute as its parsed JSON (#147).
2836
3048
  if (mode === "raw") {
2837
- return fromAttributeMap(result.Item);
3049
+ const row = fromAttributeMap(result.Item);
3050
+ yield* unpackFields(row);
3051
+ return row;
2838
3052
  }
2839
3053
  // Unmarshall and decode using selected mode
2840
3054
  const raw = fromAttributeMap(result.Item);
@@ -2849,6 +3063,15 @@ const makeImpl = (config, precomputedData) => {
2849
3063
  * Used instead of reading it again: the write is guarded on it either way.
2850
3064
  */
2851
3065
  preRead) => Effect.gen(function* () {
3066
+ // A packed field takes only a whole-value .set() / .remove() (#147).
3067
+ const packedRefusal = refusedPackedUpdate(requested);
3068
+ if (packedRefusal !== undefined) {
3069
+ return yield* new ValidationError({
3070
+ entityType,
3071
+ operation: "update",
3072
+ cause: packedRefusal,
3073
+ });
3074
+ }
2852
3075
  // Path operations on a field that feeds a key or a unique sentinel
2853
3076
  // become the record operations they are equivalent to, or are
2854
3077
  // refused (#133) — before anything reads the state.
@@ -3241,7 +3464,10 @@ const makeImpl = (config, precomputedData) => {
3241
3464
  const embedFor = new Set(vectorIndexesNeedingUpdate(collectTouchedFields(hydratedUpdates, uState), uState.withVectors).map(([logicalName]) => logicalName));
3242
3465
  // `newItem` is the fully merged post-update item (removals already
3243
3466
  // applied above), so a put-style recompute is exact.
3244
- const vectorWrite = yield* computeVectorAttributes(newItem, uState.withVectors, embedFor);
3467
+ // Untouched packed fields still hold their stored form on `newItem`
3468
+ // (kept byte-identical for the write diff), so the source text is
3469
+ // derived from an unpacked copy.
3470
+ const vectorWrite = yield* computeVectorAttributes(yield* unpackedDomainCopy(newItem, "update.vector"), uState.withVectors, embedFor);
3245
3471
  Object.assign(newItem, vectorWrite.sets);
3246
3472
  for (const field of vectorWrite.removes)
3247
3473
  delete newItem[field];
@@ -3259,9 +3485,17 @@ const makeImpl = (config, precomputedData) => {
3259
3485
  cause: e instanceof Error ? e.message : String(e),
3260
3486
  });
3261
3487
  }
3488
+ // Pack only the packed fields this update names: the others still
3489
+ // hold the stored value read above (a string or bytes), carried
3490
+ // byte-identical so the "write only what differs" diff below leaves
3491
+ // them alone (#147).
3492
+ yield* packFields(newItem, "update", new Set(Object.entries(hydratedUpdates)
3493
+ .filter(([field, value]) => field in packedFields && value != null)
3494
+ .map(([field]) => resolveDbName(field))));
3262
3495
  const currentItem = currentResult.Item;
3263
3496
  yield* checkVersion(currentItem, "update");
3264
- const immutableChange = immutableMismatch(immutables, currentItem);
3497
+ const immutableChange = immutableMismatch(immutables, currentItem) ??
3498
+ (yield* packedImmutableMismatch(immutables, currentItem));
3265
3499
  if (immutableChange !== undefined) {
3266
3500
  return yield* new ValidationError({
3267
3501
  entityType,
@@ -3414,12 +3648,9 @@ const makeImpl = (config, precomputedData) => {
3414
3648
  // versioned (exact), otherwise under the strongest guard that fits —
3415
3649
  // the same fallback a wide soft delete uses.
3416
3650
  const wide = !expressionFits(updateExpression, "update");
3417
- const wideGuardOrRefusal = wide && !systemFields.version
3651
+ const wideGuard = wide && !systemFields.version
3418
3652
  ? deleteGuard(currentItem, "item", condParts.slice(1).join(" AND "))
3419
3653
  : undefined;
3420
- if (wideGuardOrRefusal instanceof ValidationError)
3421
- return yield* wideGuardOrRefusal;
3422
- const wideGuard = wideGuardOrRefusal;
3423
3654
  const putCondition = wideGuard === undefined
3424
3655
  ? condParts.join(" AND ")
3425
3656
  : [wideGuard.expression, ...condParts.slice(1)].join(" AND ");
@@ -3435,11 +3666,8 @@ const makeImpl = (config, precomputedData) => {
3435
3666
  ...(Object.keys(putValues).length > 0 && { ExpressionAttributeValues: putValues }),
3436
3667
  ReturnValuesOnConditionCheckFailure: "ALL_OLD",
3437
3668
  };
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;
3669
+ // A condition over DynamoDB's expression limits is sent as built:
3670
+ // DynamoDB rejects it, and the rejection is diagnosed (#144).
3443
3671
  // The stored attributes the rejection compares: the guarded inputs,
3444
3672
  // and on the wide fallback whatever else its guard covered.
3445
3673
  const guardedAttrs = [
@@ -3490,7 +3718,7 @@ const makeImpl = (config, precomputedData) => {
3490
3718
  // write's own SET / REMOVE is the exact new image as well.
3491
3719
  const result = yield* withCurrent((wide
3492
3720
  ? 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)));
3721
+ : 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
3722
  oldImage = result.Attributes ?? currentItem;
3495
3723
  const applied = {
3496
3724
  ...oldImage,
@@ -3538,7 +3766,6 @@ const makeImpl = (config, precomputedData) => {
3538
3766
  });
3539
3767
  }
3540
3768
  }
3541
- yield* checkTransactionLimit(entityType, "update", transactItems);
3542
3769
  yield* withCurrent(client.transactWriteItems({ TransactItems: transactItems }).pipe(Effect.mapError((err) => {
3543
3770
  if (isAwsTransactionCancelled(err.cause)) {
3544
3771
  const reasons = err.cause.CancellationReasons;
@@ -3571,7 +3798,7 @@ const makeImpl = (config, precomputedData) => {
3571
3798
  return recordRejected(err.cause.Item);
3572
3799
  }
3573
3800
  return err;
3574
- })));
3801
+ }), diagnoseTransactionWrite("update", transactItems, () => uc?.expression)));
3575
3802
  }
3576
3803
  // Execute cascade if configured (retain path) — from the new item.
3577
3804
  if (uState.cascade) {
@@ -3609,7 +3836,8 @@ const makeImpl = (config, precomputedData) => {
3609
3836
  return yield* missingItem();
3610
3837
  }
3611
3838
  yield* checkVersion(current.Item, "update");
3612
- const immutableChange = immutableMismatch(immutables, current.Item);
3839
+ const immutableChange = immutableMismatch(immutables, current.Item) ??
3840
+ (yield* packedImmutableMismatch(immutables, current.Item));
3613
3841
  if (immutableChange !== undefined) {
3614
3842
  return yield* new ValidationError({
3615
3843
  entityType,
@@ -3662,6 +3890,28 @@ const makeImpl = (config, precomputedData) => {
3662
3890
  [uState.remove, uState.sparseRemoveEntries, uState.sparseClearFields].every((ops) => ops === undefined || ops.length === 0) &&
3663
3891
  [uState.add, uState.subtract, uState.append, uState.deleteFromSet].every((ops) => ops === undefined || Object.keys(ops).length === 0) &&
3664
3892
  !hasPathOps(uState);
3893
+ // A restated packed immutable cannot be asserted by a condition on its
3894
+ // stored bytes (they depend on the codec and threshold it was written
3895
+ // under): an unread write reads it and compares the unpacked JSON
3896
+ // (#147). The write still requires the item (`attribute_exists`), and
3897
+ // no library write changes an immutable value in between.
3898
+ if (plainWrite && immutables.some((asserted) => asserted.packed)) {
3899
+ const stored = yield* client.getItem({
3900
+ TableName: tableName,
3901
+ Key: marshalledKey,
3902
+ ConsistentRead: true,
3903
+ });
3904
+ if (stored.Item !== undefined) {
3905
+ const packedChange = yield* packedImmutableMismatch(immutables, stored.Item);
3906
+ if (packedChange !== undefined) {
3907
+ return yield* new ValidationError({
3908
+ entityType,
3909
+ operation: "update",
3910
+ cause: packedChange,
3911
+ });
3912
+ }
3913
+ }
3914
+ }
3665
3915
  // Build UpdateExpression
3666
3916
  const setClauses = [];
3667
3917
  const names = {};
@@ -3723,8 +3973,13 @@ const makeImpl = (config, precomputedData) => {
3723
3973
  }
3724
3974
  const nameKey = `#u${counter}`;
3725
3975
  const valKey = `:u${counter}`;
3726
- names[nameKey] = resolveDbName(attr);
3727
- values[valKey] = toAttributeValue(val);
3976
+ const storedName = resolveDbName(attr);
3977
+ names[nameKey] = storedName;
3978
+ // A packed field is written whole, as its stored form (#147).
3979
+ values[valKey] =
3980
+ attr in packedFields
3981
+ ? (yield* packAttribute(storedName, val, "update"))
3982
+ : toAttributeValue(val);
3728
3983
  setClauses.push(`${nameKey} = ${valKey}`);
3729
3984
  counter++;
3730
3985
  }
@@ -4123,7 +4378,9 @@ const makeImpl = (config, precomputedData) => {
4123
4378
  }
4124
4379
  if (plainWrite) {
4125
4380
  // Restated immutable values must match the item's.
4126
- for (const [i, { attr, value }] of immutables.entries()) {
4381
+ for (const [i, { attr, value, packed }] of immutables.entries()) {
4382
+ if (packed)
4383
+ continue; // read and compared above (#147)
4127
4384
  names[`#imm${i}`] = attr;
4128
4385
  values[`:imm${i}`] = value;
4129
4386
  condParts.push(`#imm${i} = :imm${i}`);
@@ -4137,14 +4394,9 @@ const makeImpl = (config, precomputedData) => {
4137
4394
  if (incarnation !== undefined)
4138
4395
  condParts.push(incarnation);
4139
4396
  }
4397
+ // Sent as built: a condition over DynamoDB's expression limits is
4398
+ // DynamoDB's to reject, and is diagnosed naming the caller's part (#144).
4140
4399
  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
4400
  // The top-level attributes this update writes — what `updatedOld` /
4149
4401
  // `updatedNew` return.
4150
4402
  const written = expressionTargets(updateExpression, names);
@@ -4172,11 +4424,6 @@ const makeImpl = (config, precomputedData) => {
4172
4424
  if (incarnation !== undefined)
4173
4425
  guardParts.push(incarnation);
4174
4426
  }
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
4427
  const transactItems = [
4181
4428
  {
4182
4429
  Update: {
@@ -4193,7 +4440,6 @@ const makeImpl = (config, precomputedData) => {
4193
4440
  },
4194
4441
  { Put: snapshotPut(tableName, snapshot.item) },
4195
4442
  ];
4196
- yield* checkTransactionLimit(entityType, "update", transactItems);
4197
4443
  yield* client.transactWriteItems({ TransactItems: transactItems }).pipe(Effect.mapError((err) => {
4198
4444
  // The update is guarded by the snapshot version AND any user
4199
4445
  // condition; the stored item (ALL_OLD) says which rejected it.
@@ -4211,7 +4457,7 @@ const makeImpl = (config, precomputedData) => {
4211
4457
  if (isAwsConditionalCheckFailed(err.cause))
4212
4458
  return rejection(err.cause.Item);
4213
4459
  return err;
4214
- }));
4460
+ }), diagnoseTransactionWrite("update", transactItems, () => uc?.expression));
4215
4461
  // A transacted Update returns no attributes. The old item is exact:
4216
4462
  // the version condition proves `snapshot.raw` is what it replaced.
4217
4463
  // The new item is read back only when needed, and proven ours.
@@ -4234,8 +4480,7 @@ const makeImpl = (config, precomputedData) => {
4234
4480
  // DynamoDB rejects an empty `ExpressionAttributeValues` map. When
4235
4481
  // the UpdateExpression is REMOVE-only (e.g. clearMap with no other
4236
4482
  // combinators), `values` may be empty — omit the property entirely.
4237
- const result = yield* client
4238
- .updateItem({
4483
+ const updateInput = {
4239
4484
  TableName: tableName,
4240
4485
  Key: marshalledKey,
4241
4486
  UpdateExpression: updateExpression,
@@ -4246,8 +4491,8 @@ const makeImpl = (config, precomputedData) => {
4246
4491
  ReturnValuesOnConditionCheckFailure: "ALL_OLD",
4247
4492
  }),
4248
4493
  ReturnValues: returned,
4249
- })
4250
- .pipe(Effect.mapError((err) => {
4494
+ };
4495
+ const result = yield* client.updateItem(updateInput).pipe(Effect.mapError((err) => {
4251
4496
  // The stored item (ALL_OLD) says what rejected the update: no
4252
4497
  // item (it must exist), a restated immutable value, the
4253
4498
  // version CAS, or the user's condition.
@@ -4276,7 +4521,7 @@ const makeImpl = (config, precomputedData) => {
4276
4521
  : undefined, userCond !== undefined);
4277
4522
  }
4278
4523
  return err;
4279
- }));
4524
+ }), diagnoseItemWrite("update", { Update: updateInput }, () => uc?.expression));
4280
4525
  // A cascade never meets an old-image mode here: that combination is
4281
4526
  // routed to the read-then-write branch, or read first (`casRead`).
4282
4527
  const newImage = wantsOld ? undefined : result.Attributes;
@@ -4475,8 +4720,6 @@ const makeImpl = (config, precomputedData) => {
4475
4720
  // into them (#133).
4476
4721
  yield* checkVersion(result.Item, "delete");
4477
4722
  const softGuard = deleteGuard(result.Item, "item", userCondition?.expression);
4478
- if (softGuard instanceof ValidationError)
4479
- return yield* softGuard;
4480
4723
  const raw = fromAttributeMap(result.Item);
4481
4724
  // Clock-backed time source; `now` is the ISO timestamp used for the
4482
4725
  // deleted SK + deletedAt, `dtNow` drives the optional TTL + snapshot.
@@ -4545,8 +4788,7 @@ const makeImpl = (config, precomputedData) => {
4545
4788
  const releases = hasUniqueConstraints && !preserveUnique()
4546
4789
  ? yield* ownedReleases(tableName, raw, transactItems)
4547
4790
  : [];
4548
- yield* checkTransactionLimit(entityType, "delete", transactItems);
4549
- yield* guardedDelete(client.transactWriteItems({ TransactItems: transactItems }), result.Item, softGuard.inputs, snapshotAt, releases);
4791
+ yield* guardedDelete(client.transactWriteItems({ TransactItems: transactItems }), result.Item, softGuard.inputs, snapshotAt, releases).pipe(diagnoseTransactionWrite("delete", transactItems, () => userCondition?.expression));
4550
4792
  return result.Item;
4551
4793
  }
4552
4794
  else {
@@ -4569,8 +4811,7 @@ const makeImpl = (config, precomputedData) => {
4569
4811
  if (!userCondition)
4570
4812
  return undefined;
4571
4813
  const values = userCondition.values;
4572
- yield* client
4573
- .deleteItem({
4814
+ const missingDelete = {
4574
4815
  TableName: tableName,
4575
4816
  Key: marshalledKey,
4576
4817
  ConditionExpression: `attribute_not_exists(#dpk) AND (${userCondition.expression})`,
@@ -4580,10 +4821,12 @@ const makeImpl = (config, precomputedData) => {
4580
4821
  },
4581
4822
  ...(Object.keys(values).length > 0 && { ExpressionAttributeValues: values }),
4582
4823
  ReturnValuesOnConditionCheckFailure: "ALL_OLD",
4583
- })
4584
- .pipe(Effect.mapError((err) => isAwsConditionalCheckFailed(err.cause)
4824
+ };
4825
+ yield* client.deleteItem(missingDelete).pipe(Effect.mapError((err) => isAwsConditionalCheckFailed(err.cause)
4585
4826
  ? conditionRejection(encodedKey, err.cause.Item, err.cause.Item === undefined ? undefined : { version: 0 }, true)
4586
- : err));
4827
+ : err), diagnoseItemWrite("delete", {
4828
+ Delete: missingDelete,
4829
+ }));
4587
4830
  return undefined;
4588
4831
  }
4589
4832
  // The sentinel deletes are keyed by the unique values read, and the
@@ -4596,8 +4839,6 @@ const makeImpl = (config, precomputedData) => {
4596
4839
  ...new Set(Object.values(config.unique).flatMap((def) => resolveUniqueFields(def).map(resolveDbName))),
4597
4840
  ]
4598
4841
  : [], userCondition?.expression);
4599
- if (hardGuard instanceof ValidationError)
4600
- return yield* hardGuard;
4601
4842
  const raw = fromAttributeMap(result.Item);
4602
4843
  const transactItems = [];
4603
4844
  // Delete entity item — index 0, and the only item conditioned on the
@@ -4627,8 +4868,7 @@ const makeImpl = (config, precomputedData) => {
4627
4868
  const releases = hasUniqueConstraints
4628
4869
  ? yield* ownedReleases(tableName, raw, transactItems)
4629
4870
  : [];
4630
- yield* checkTransactionLimit(entityType, "delete", transactItems);
4631
- yield* guardedDelete(client.transactWriteItems({ TransactItems: transactItems }), result.Item, hardGuard.inputs, snapshotAt, releases);
4871
+ yield* guardedDelete(client.transactWriteItems({ TransactItems: transactItems }), result.Item, hardGuard.inputs, snapshotAt, releases).pipe(diagnoseTransactionWrite("delete", transactItems, () => userCondition?.expression));
4632
4872
  return result.Item;
4633
4873
  }
4634
4874
  });
@@ -4668,9 +4908,9 @@ const makeImpl = (config, precomputedData) => {
4668
4908
  if (opts.returnValues !== undefined) {
4669
4909
  deleteInput.ReturnValues = opts.returnValues === "allOld" ? "ALL_OLD" : "NONE";
4670
4910
  }
4671
- const output = yield* client
4672
- .deleteItem(deleteInput)
4673
- .pipe(Effect.mapError(mapDeleteConditionFailure));
4911
+ const output = yield* client.deleteItem(deleteInput).pipe(Effect.mapError(mapDeleteConditionFailure), diagnoseItemWrite("delete", {
4912
+ Delete: deleteInput,
4913
+ }));
4674
4914
  return yield* deletedResult(output.Attributes);
4675
4915
  }
4676
4916
  }), self, key);
@@ -4887,8 +5127,13 @@ const makeImpl = (config, precomputedData) => {
4887
5127
  }
4888
5128
  const nameKey = `#u${counter}`;
4889
5129
  const valKey = `:u${counter}`;
4890
- names[nameKey] = resolveDbName(attr);
4891
- values[valKey] = toAttributeValue(val);
5130
+ const storedName = resolveDbName(attr);
5131
+ names[nameKey] = storedName;
5132
+ // A packed field is written whole, as its stored form (#147).
5133
+ values[valKey] =
5134
+ attr in packedFields
5135
+ ? (yield* packAttribute(storedName, val, "upsert"))
5136
+ : toAttributeValue(val);
4892
5137
  // Immutable fields use if_not_exists — only set on first create
4893
5138
  if (immutableFields.has(attr)) {
4894
5139
  setClauses.push(`${nameKey} = if_not_exists(${nameKey}, ${valKey})`);
@@ -5005,8 +5250,7 @@ const makeImpl = (config, precomputedData) => {
5005
5250
  Object.assign(names, uc.names);
5006
5251
  Object.assign(values, uc.values);
5007
5252
  }
5008
- const result = yield* client
5009
- .updateItem({
5253
+ const upsertInput = {
5010
5254
  TableName: tableName,
5011
5255
  Key: marshalledKey,
5012
5256
  UpdateExpression: updateExpression,
@@ -5018,8 +5262,8 @@ const makeImpl = (config, precomputedData) => {
5018
5262
  ReturnValuesOnConditionCheckFailure: "ALL_OLD",
5019
5263
  }),
5020
5264
  ReturnValues: "ALL_NEW",
5021
- })
5022
- .pipe(Effect.mapError((err) => {
5265
+ };
5266
+ const result = yield* client.updateItem(upsertInput).pipe(Effect.mapError((err) => {
5023
5267
  if (isAwsConditionalCheckFailed(err.cause)) {
5024
5268
  const corrupt = versionCorruption(err.cause.Item, "upsert");
5025
5269
  if (corrupt !== undefined)
@@ -5028,6 +5272,8 @@ const makeImpl = (config, precomputedData) => {
5028
5272
  return new ConditionalCheckFailed({ entityType, key: item });
5029
5273
  }
5030
5274
  return err;
5275
+ }), diagnoseItemWrite("upsert", {
5276
+ Update: upsertInput,
5031
5277
  }));
5032
5278
  if (!result.Attributes) {
5033
5279
  return yield* new ItemNotFound({ entityType, key: item });
@@ -5118,6 +5364,16 @@ const makeImpl = (config, precomputedData) => {
5118
5364
  // `append` takes its condition as an argument — no `condition`
5119
5365
  // combinator — so an empty one becomes none here (#133).
5120
5366
  const userCondition = nonEmptyCondition(givenCondition);
5367
+ if (userCondition !== undefined) {
5368
+ const packedRefusal = packedExprRefusal(toExpr(userCondition), packedGuards, "condition");
5369
+ if (packedRefusal !== undefined) {
5370
+ return yield* new ValidationError({
5371
+ entityType,
5372
+ operation: "append.condition",
5373
+ cause: packedRefusal,
5374
+ });
5375
+ }
5376
+ }
5121
5377
  const emptyPart = userCondition === undefined ? undefined : emptyPartProblem(toExpr(userCondition));
5122
5378
  if (emptyPart !== undefined) {
5123
5379
  return yield* new ValidationError({
@@ -5215,6 +5471,15 @@ const makeImpl = (config, precomputedData) => {
5215
5471
  });
5216
5472
  // `encoded` is already in wire-form via Schema.encode — used directly.
5217
5473
  const serialisedInput = { ...encoded };
5474
+ // Packed `appendInput` fields (#147), packed ONCE: the same stored value
5475
+ // is SET on the current item and written on the event item.
5476
+ const packedStored = {};
5477
+ for (const field of Object.keys(packedFields)) {
5478
+ if (serialisedInput[field] !== undefined) {
5479
+ packedStored[resolveDbName(field)] = serialisedInput[field];
5480
+ }
5481
+ }
5482
+ yield* packFields(packedStored, "append");
5218
5483
  // ---------- Build UpdateItem (scoped SET + CAS) ----------
5219
5484
  const setClauses = [];
5220
5485
  const names = {};
@@ -5231,8 +5496,9 @@ const makeImpl = (config, precomputedData) => {
5231
5496
  continue;
5232
5497
  const nameKey = `#a${counter}`;
5233
5498
  const valKey = `:a${counter}`;
5234
- names[nameKey] = resolveDbName(attr);
5235
- values[valKey] = toAttributeValue(val);
5499
+ const storedName = resolveDbName(attr);
5500
+ names[nameKey] = storedName;
5501
+ values[valKey] = toAttributeValue(attr in packedFields ? packedStored[storedName] : val);
5236
5502
  setClauses.push(`${nameKey} = ${valKey}`);
5237
5503
  counter++;
5238
5504
  }
@@ -5331,6 +5597,8 @@ const makeImpl = (config, precomputedData) => {
5331
5597
  const eventItem = { ...encoded };
5332
5598
  // Rename domain → DB names for the Put (matches put path)
5333
5599
  renameToDynamo(eventItem);
5600
+ // The packed fields' stored values — the same ones SET above (#147).
5601
+ Object.assign(eventItem, packedStored);
5334
5602
  // pk + sk (event)
5335
5603
  eventItem[primary.pk.field] = pkValue;
5336
5604
  eventItem[primary.sk.field] = eventSk;
@@ -5368,30 +5636,42 @@ const makeImpl = (config, precomputedData) => {
5368
5636
  }
5369
5637
  const marshalledEventItem = toAttributeMap(eventItem);
5370
5638
  // ---------- 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
- },
5639
+ const appendItems = [
5640
+ {
5641
+ Update: {
5642
+ TableName: tableName,
5643
+ Key: marshalledKey,
5644
+ UpdateExpression: updateExpression,
5645
+ ConditionExpression: finalCondition,
5646
+ ExpressionAttributeNames: names,
5647
+ ExpressionAttributeValues: values,
5383
5648
  },
5384
- {
5385
- Put: {
5386
- TableName: tableName,
5387
- Item: marshalledEventItem,
5388
- },
5649
+ },
5650
+ {
5651
+ Put: {
5652
+ TableName: tableName,
5653
+ Item: marshalledEventItem,
5389
5654
  },
5390
- ],
5391
- })
5392
- .pipe(Effect.matchEffect({
5655
+ },
5656
+ ];
5657
+ const transactResult = yield* client.transactWriteItems({ TransactItems: appendItems }).pipe(
5658
+ // A limit DynamoDB enforced (#144) — the current item grown past
5659
+ // 400 KB, an oversized event item, an over-long sort key — is
5660
+ // diagnosed first: such a cancellation carries no conditional
5661
+ // failure, so it is never a stale append.
5662
+ diagnoseFailure({
5663
+ sent: () => ({
5664
+ _tag: "Transaction",
5665
+ items: appendItems,
5666
+ targets: () => targetsFor(appendItems, entityKeyFields(), entityType, (index) => index === 0 ? "the current item" : "the event item"),
5667
+ }),
5668
+ keys: entityKeys,
5669
+ rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, { operation: "append", entityType }),
5670
+ }), Effect.matchEffect({
5393
5671
  onSuccess: () => Effect.succeed({ stale: false }),
5394
5672
  onFailure: (err) => {
5673
+ if (err._tag === "ValidationError")
5674
+ return Effect.fail(err);
5395
5675
  // TransactionCancelled or ConditionalCheckFailed at the transaction
5396
5676
  // level both surface here. Disambiguation between CAS and
5397
5677
  // user-condition happens after the follow-up GetItem (when run).
@@ -5488,13 +5768,15 @@ const makeImpl = (config, precomputedData) => {
5488
5768
  // NOT carry `<prefix>#*` attributes. `deserializeSparseFields` populates
5489
5769
  // empty `{}` Records under the domain field names so the historyRecord
5490
5770
  // schema (which still includes the sparse Record fields) decodes.
5491
- deserializeSparseFields(raw);
5492
- renameFromDynamo(raw);
5493
- return Schema.decodeUnknownEffect(schemas.historyRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
5494
- entityType,
5495
- operation: "history.decode",
5496
- cause,
5497
- })));
5771
+ return Effect.flatMap(unpackFields(raw, "history.decode"), () => {
5772
+ deserializeSparseFields(raw);
5773
+ renameFromDynamo(raw);
5774
+ return Schema.decodeUnknownEffect(schemas.historyRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
5775
+ entityType,
5776
+ operation: "history.decode",
5777
+ cause,
5778
+ })));
5779
+ });
5498
5780
  };
5499
5781
  return Query.make({
5500
5782
  tableName: "",
@@ -5504,6 +5786,7 @@ const makeImpl = (config, precomputedData) => {
5504
5786
  skField: primary.sk.field,
5505
5787
  entityTypes: [entityType],
5506
5788
  decoder: (raw) => decodeHistory(raw),
5789
+ ...packedQueryOptions,
5507
5790
  resolveTableName: tableTag.useSync((tc) => tc.name),
5508
5791
  keyFields: [primary.pk.field, primary.sk.field],
5509
5792
  }).pipe(Query.where({ beginsWith: prefix }));
@@ -5525,12 +5808,13 @@ const makeImpl = (config, precomputedData) => {
5525
5808
  const query = Query.make({
5526
5809
  tableName: "",
5527
5810
  indexName: indexDef.index,
5528
- globalIndex: indexDef.index !== undefined,
5529
5811
  pkField: indexDef.pk.field,
5530
5812
  pkValue,
5531
5813
  skField: indexDef.sk.field,
5532
5814
  entityTypes: [entityType],
5533
5815
  decoder: (raw) => decodeRecord(raw),
5816
+ ...packedQueryOptions,
5817
+ ...packedQueryOptions,
5534
5818
  resolveTableName: tableTag.useSync((tc) => tc.name),
5535
5819
  keyFields: [
5536
5820
  indexDef.pk.field,
@@ -5538,6 +5822,8 @@ const makeImpl = (config, precomputedData) => {
5538
5822
  config.indexes.primary.pk.field,
5539
5823
  config.indexes.primary.sk.field,
5540
5824
  ],
5825
+ // DynamoDB's rejection of the key condition (#144) names the accessor.
5826
+ keyConditionDiagnosis: { operation: "query", label: `${entityType}.query.${indexName}` },
5541
5827
  });
5542
5828
  if (hasSkComposites) {
5543
5829
  // `composeSortKeyBeginsWith`, not `composeSortKeyPrefix` — the operand
@@ -5558,6 +5844,7 @@ const makeImpl = (config, precomputedData) => {
5558
5844
  indexName: undefined,
5559
5845
  entityTypes: [entityType],
5560
5846
  decoder: (raw) => decodeRecord(raw),
5847
+ ...packedQueryOptions,
5561
5848
  resolveTableName: tableTag.useSync((tc) => tc.name),
5562
5849
  keyFields: [config.indexes.primary.pk.field, config.indexes.primary.sk.field],
5563
5850
  liveRows: liveRows(),
@@ -5661,6 +5948,7 @@ const makeImpl = (config, precomputedData) => {
5661
5948
  skField: primary.sk.field,
5662
5949
  entityTypes: [entityType],
5663
5950
  decoder: (raw) => decodeRecord(raw),
5951
+ ...packedQueryOptions,
5664
5952
  resolveTableName: tableTag.useSync((tc) => tc.name),
5665
5953
  keyFields: [primary.pk.field, primary.sk.field],
5666
5954
  prepare: withLegacy,
@@ -5687,6 +5975,9 @@ const makeImpl = (config, precomputedData) => {
5687
5975
  // (itemSchema would fail because it expects GSI key fields that aren't present)
5688
5976
  if (mode === "native")
5689
5977
  return tombstone;
5978
+ // Packed fields: read by their stored form (copied as-is onto the
5979
+ // tombstone, #147).
5980
+ yield* unpackFields(raw, "deleted.get.decode");
5690
5981
  // Sparse Map fields: rebuild domain Records from flattened attrs so
5691
5982
  // soft-deleted items decode correctly. Sparse data is preserved
5692
5983
  // verbatim across soft-delete (GSI keys are stripped, sparse data is
@@ -5715,16 +6006,18 @@ const makeImpl = (config, precomputedData) => {
5715
6006
  const corruption = versionCorruption(raw, "deleted.list.decode");
5716
6007
  if (corruption !== undefined)
5717
6008
  return Effect.fail(corruption);
5718
- // Sparse fields are domain data and are preserved across soft-delete.
5719
- // Rebuild Records from flattened attrs before schema decode.
5720
- deserializeSparseFields(raw);
5721
- // Then attribute → domain names, same order as `decodeRecord` (#127).
5722
- renameFromDynamo(raw);
5723
- return Schema.decodeUnknownEffect(schemas.deletedRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
5724
- entityType,
5725
- operation: "deleted.list.decode",
5726
- cause,
5727
- })));
6009
+ return Effect.flatMap(unpackFields(raw, "deleted.list.decode"), () => {
6010
+ // Sparse fields are domain data and are preserved across soft-delete.
6011
+ // Rebuild Records from flattened attrs before schema decode.
6012
+ deserializeSparseFields(raw);
6013
+ // Then attribute → domain names, same order as `decodeRecord` (#127).
6014
+ renameFromDynamo(raw);
6015
+ return Schema.decodeUnknownEffect(schemas.deletedRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
6016
+ entityType,
6017
+ operation: "deleted.list.decode",
6018
+ cause,
6019
+ })));
6020
+ });
5728
6021
  };
5729
6022
  return Query.make({
5730
6023
  tableName: "",
@@ -5734,6 +6027,7 @@ const makeImpl = (config, precomputedData) => {
5734
6027
  skField: primary.sk.field,
5735
6028
  entityTypes: [],
5736
6029
  decoder: (raw) => decodeDeleted(raw),
6030
+ ...packedQueryOptions,
5737
6031
  resolveTableName: tableTag.useSync((tc) => tc.name),
5738
6032
  keyFields: [primary.pk.field, primary.sk.field],
5739
6033
  }).pipe(Query.where({ beginsWith: deletedPrefix }));
@@ -5874,7 +6168,6 @@ const makeImpl = (config, precomputedData) => {
5874
6168
  });
5875
6169
  }
5876
6170
  }
5877
- yield* checkTransactionLimit(entityType, "restore", transactItems);
5878
6171
  yield* client
5879
6172
  .transactWriteItems({
5880
6173
  TransactItems: transactItems,
@@ -5921,7 +6214,7 @@ const makeImpl = (config, precomputedData) => {
5921
6214
  }
5922
6215
  }
5923
6216
  return err;
5924
- }));
6217
+ }), diagnoseTransactionWrite("restore", transactItems));
5925
6218
  return yield* decodeAs(restoredItem, marshalledRestoredItem, mode);
5926
6219
  }), self, key);
5927
6220
  // ---------------------------------------------------------------------------
@@ -6067,24 +6360,77 @@ const makeImpl = (config, precomputedData) => {
6067
6360
  // Released one by one, each only while still this item's: a batch
6068
6361
  // delete cannot carry the ownership condition.
6069
6362
  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
6363
+ const release = sentinelRelease(tableName, sentinel, owner);
6364
+ yield* client.deleteItem(release.Delete).pipe(Effect.catchIf((err) => isAwsConditionalCheckFailed(err.cause), () => Effect.void),
6365
+ // A delete of a stored key cannot meet a size limit; wrapped
6366
+ // for uniformity (#144), so it passes through in practice.
6367
+ diagnoseItemWrite("purge", release));
6368
+ }
6369
+ }
6370
+ // Batch delete in chunks of 25. `UnprocessedItems` are retried with
6371
+ // `Batch.write`'s backoff; what the purge had deleted is always
6372
+ // stated exactly, whether a request is rejected or retries run out.
6373
+ let deleted = 0;
6374
+ const deletedSoFar = () => deleted === 0
6375
+ ? "the purge had deleted none of the partition's items"
6376
+ : `the purge had already deleted ${deleted} of the partition's ${allItems.length} items`;
6074
6377
  for (let i = 0; i < allItems.length; i += 25) {
6075
6378
  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
- },
6379
+ let requestItems = {
6380
+ [tableName]: chunk.map((item) => ({
6381
+ DeleteRequest: {
6382
+ Key: {
6383
+ [primary.pk.field]: item[primary.pk.field],
6384
+ [primary.sk.field]: item[primary.sk.field],
6084
6385
  },
6085
- })),
6086
- },
6087
- });
6386
+ },
6387
+ })),
6388
+ };
6389
+ let retries = 0;
6390
+ for (;;) {
6391
+ const sentItems = requestItems;
6392
+ const sentCount = Object.values(sentItems).reduce((sum, requests) => sum + requests.length, 0);
6393
+ const response = yield* client.batchWriteItem({ RequestItems: sentItems }).pipe(
6394
+ // Deletes of stored keys: wrapped for uniformity (#144).
6395
+ diagnoseFailure({
6396
+ sent: () => {
6397
+ const items = batchEntries(sentItems);
6398
+ return {
6399
+ _tag: "Batch",
6400
+ items,
6401
+ targets: () => targetsFor(items, entityKeyFields(), entityType, (index) => `purged item ${deleted + index}`),
6402
+ };
6403
+ },
6404
+ keys: entityKeys,
6405
+ rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, {
6406
+ operation: "purge",
6407
+ entityType,
6408
+ committed: `${deletedSoFar()} before it`,
6409
+ }),
6410
+ }));
6411
+ const unprocessed = {};
6412
+ let remaining = 0;
6413
+ for (const [table, requests] of Object.entries(response.UnprocessedItems ?? {})) {
6414
+ if (requests.length === 0)
6415
+ continue;
6416
+ unprocessed[table] = requests;
6417
+ remaining += requests.length;
6418
+ }
6419
+ deleted += sentCount - remaining;
6420
+ if (remaining === 0)
6421
+ break;
6422
+ retries++;
6423
+ if (retries > MAX_RETRIES) {
6424
+ return yield* new DynamoError({
6425
+ operation: "BatchWriteItem",
6426
+ cause: new Error(`purge: ${remaining} of the partition's ${allItems.length} items were still ` +
6427
+ `unprocessed after ${MAX_RETRIES} retries; ${deletedSoFar()}. The rest ` +
6428
+ "were not deleted: purge again to finish."),
6429
+ });
6430
+ }
6431
+ yield* Effect.sleep(`${BASE_DELAY_MS * 2 ** (retries - 1)} millis`);
6432
+ requestItems = unprocessed;
6433
+ }
6088
6434
  }
6089
6435
  }), self, key);
6090
6436
  // ---------------------------------------------------------------------------
@@ -6102,6 +6448,7 @@ const makeImpl = (config, precomputedData) => {
6102
6448
  const expr = typeof cbOrShorthand === "function"
6103
6449
  ? cbOrShorthand(entityPathBuilder, entityConditionOps)
6104
6450
  : parseSimpleShorthand(cbOrShorthand);
6451
+ refusePackedExpr(expr, "condition");
6105
6452
  return (self) => conditionCombinator(self, expr);
6106
6453
  };
6107
6454
  /**
@@ -6112,17 +6459,25 @@ const makeImpl = (config, precomputedData) => {
6112
6459
  const expr = typeof cbOrShorthand === "function"
6113
6460
  ? cbOrShorthand(entityPathBuilder, entityConditionOps)
6114
6461
  : parseSimpleShorthand(cbOrShorthand);
6462
+ refusePackedExpr(expr, "filter");
6115
6463
  return (q) => filterExpr(q, expr);
6116
6464
  };
6117
6465
  /**
6118
6466
  * Build a select (projection) combinator from callback or string array.
6119
6467
  */
6120
6468
  const entitySelect = (cbOrAttrs) => {
6469
+ const refuse = (paths) => {
6470
+ const refused = packedProjectionRefusal(paths, packedGuards);
6471
+ if (refused !== undefined)
6472
+ throw new Error(refused);
6473
+ };
6121
6474
  if (typeof cbOrAttrs === "function") {
6122
6475
  const paths = cbOrAttrs(entityPathBuilder);
6123
6476
  const segments = paths.map((p) => p.segments);
6477
+ refuse(segments);
6124
6478
  return (q) => selectPaths(q, segments);
6125
6479
  }
6480
+ refuse(cbOrAttrs.map((attribute) => [attribute]));
6126
6481
  return (q) => Query.select(q, cbOrAttrs);
6127
6482
  };
6128
6483
  // ---------------------------------------------------------------------------
@@ -6150,8 +6505,14 @@ const makeImpl = (config, precomputedData) => {
6150
6505
  generatedId: config.generatedId,
6151
6506
  vectorIndexes: config.vectorIndexes,
6152
6507
  _vectorIndexes: vectorIndexes,
6508
+ _packedFields: Object.keys(packedFields),
6509
+ _packFields: packFields,
6510
+ _unpackFields: unpackFields,
6511
+ _dispatchAttributes: dispatchAttributes,
6512
+ _packedGuards: packedGuards,
6513
+ _decodeImage: decodeImageItem,
6153
6514
  _resolvedRefs: resolvedRefs,
6154
- /** @internal Full decode pipeline: rename + schema decode. Used by Batch/Aggregate. */
6515
+ /** @internal Full decode pipeline: unpack + sparse + rename + schema decode. Used by Batch/Aggregate. */
6155
6516
  _decodeRecord: decodeRecord,
6156
6517
  /** @internal Domain field name → stored attribute name (`storedAs` renames). */
6157
6518
  _resolveDbName: resolveDbName,
@@ -6173,6 +6534,9 @@ const makeImpl = (config, precomputedData) => {
6173
6534
  tableTag = injectedTableTag;
6174
6535
  },
6175
6536
  _injectIndex: (name, def) => {
6537
+ const refused = packedIndexRefusal(entityType, packedFields, name, def);
6538
+ if (refused !== undefined)
6539
+ throw new Error(refused);
6176
6540
  allIndexes = { ...allIndexes, [name]: def };
6177
6541
  },
6178
6542
  get _schema() {
@@ -6360,8 +6724,7 @@ export const bind = (entity) => Effect.gen(function* () {
6360
6724
  // UpdateItem would resurrect it as a key-plus-vector fragment that
6361
6725
  // decodes to nothing but still answers searches. Guard on the item
6362
6726
  // still existing and treat the rejection as "skipped".
6363
- return yield* client
6364
- .updateItem({
6727
+ const update = {
6365
6728
  TableName: tableName,
6366
6729
  Key: {
6367
6730
  [primary.pk.field]: marshalled[primary.pk.field],
@@ -6371,8 +6734,21 @@ export const bind = (entity) => Effect.gen(function* () {
6371
6734
  ConditionExpression: "attribute_exists(#pk)",
6372
6735
  ExpressionAttributeNames: names,
6373
6736
  ExpressionAttributeValues: values,
6374
- })
6375
- .pipe(Effect.as(true), Effect.catchIf((err) => isAwsConditionalCheckFailed(err.cause), () => Effect.succeed(false)));
6737
+ };
6738
+ return yield* client.updateItem(update).pipe(Effect.as(true), Effect.catchIf((err) => isAwsConditionalCheckFailed(err.cause), () => Effect.succeed(false)),
6739
+ // An embedding that grows the item past 400 KB, named (#144).
6740
+ diagnoseFailure({
6741
+ sent: () => ({
6742
+ _tag: "Item",
6743
+ item: { Update: update },
6744
+ target: () => targetsFor([{ Update: update }], [primary.pk.field, primary.sk.field], entity.entityType, () => "the re-embedded item")[0],
6745
+ }),
6746
+ keys: () => keyAttributesOf(entity.indexes),
6747
+ rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, {
6748
+ operation: "reembed",
6749
+ entityType: entity.entityType,
6750
+ }),
6751
+ }));
6376
6752
  });
6377
6753
  let updated = 0;
6378
6754
  let exclusiveStartKey;
@@ -6575,12 +6951,27 @@ export const itemSchema = (entity) => entity.schemas.itemSchema;
6575
6951
  /**
6576
6952
  * Decode a marshalled DynamoDB item (AttributeValue format) into a typed Entity.Record.
6577
6953
  *
6578
- * Unmarshalls the AttributeValue map to plain JS, then decodes via the entity's item schema.
6579
- * Useful for consuming DynamoDB Streams events or raw SDK responses.
6954
+ * Built for DynamoDB Streams records (`NewImage` / `OldImage`) and raw SDK
6955
+ * responses. Applies exactly what a table read applies:
6956
+ *
6957
+ * - **Packed fields** (`storedAs: DynamoModel.Packed`) are read by their
6958
+ * stored form — a legacy native map, a JSON string (`S`), or compressed
6959
+ * binary (`B`). Lambda and EventBridge Pipes deliver `B` / `BS` members as
6960
+ * base64 text; they are decoded at any depth.
6961
+ * - Sparse-map fields are rebuilt and `field:` renames reversed.
6962
+ *
6963
+ * Accepts DynamoDB JSON (`{ pk: { S: "…" }, … }`, `B` as bytes or base64) or
6964
+ * an already-unmarshalled item. Fails with `ValidationError` if the item does
6965
+ * not match the schema, or when a compressed value's codec is not registered —
6966
+ * its `cause` is then a `CompressorNotRegistered` (register the codec with a
6967
+ * `Compression.layer([...])` provided where the decode runs).
6580
6968
  *
6581
- * Returns an Effect that fails with `ValidationError` if the item doesn't match the schema.
6969
+ * Note: {@link itemSchema} is a pure schema over unmarshalled items and does
6970
+ * not unpack — use this for entities with packed fields.
6582
6971
  */
6583
6972
  export const decodeMarshalledItem = (entity, marshalledItem) => {
6973
+ if (entity._decodeImage !== undefined)
6974
+ return entity._decodeImage(marshalledItem);
6584
6975
  const corruption = entity._versionCorruption?.(marshalledItem, "decodeMarshalledItem");
6585
6976
  if (corruption !== undefined)
6586
6977
  return Effect.fail(corruption);