effect-dynamodb 1.25.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 (72) hide show
  1. package/dist/Aggregate.d.ts +7 -0
  2. package/dist/Aggregate.d.ts.map +1 -1
  3. package/dist/Aggregate.js +150 -4
  4. package/dist/Aggregate.js.map +1 -1
  5. package/dist/Collection.d.ts +9 -1
  6. package/dist/Collection.d.ts.map +1 -1
  7. package/dist/Collection.js +3 -0
  8. package/dist/Collection.js.map +1 -1
  9. package/dist/DynamoClient.d.ts +80 -28
  10. package/dist/DynamoClient.d.ts.map +1 -1
  11. package/dist/DynamoClient.js +83 -11
  12. package/dist/DynamoClient.js.map +1 -1
  13. package/dist/Entity.d.ts +80 -27
  14. package/dist/Entity.d.ts.map +1 -1
  15. package/dist/Entity.js +406 -76
  16. package/dist/Entity.js.map +1 -1
  17. package/dist/EventStore.d.ts +182 -2
  18. package/dist/EventStore.d.ts.map +1 -1
  19. package/dist/EventStore.js +328 -41
  20. package/dist/EventStore.js.map +1 -1
  21. package/dist/Query.d.ts +53 -0
  22. package/dist/Query.d.ts.map +1 -1
  23. package/dist/Query.js +88 -6
  24. package/dist/Query.js.map +1 -1
  25. package/dist/StorageEncoding.d.ts +160 -0
  26. package/dist/StorageEncoding.d.ts.map +1 -0
  27. package/dist/StorageEncoding.js +258 -0
  28. package/dist/StorageEncoding.js.map +1 -0
  29. package/dist/Table.d.ts.map +1 -1
  30. package/dist/Table.js +8 -6
  31. package/dist/Table.js.map +1 -1
  32. package/dist/Transaction.d.ts.map +1 -1
  33. package/dist/Transaction.js +5 -0
  34. package/dist/Transaction.js.map +1 -1
  35. package/dist/compression/zlib.d.ts +75 -0
  36. package/dist/compression/zlib.d.ts.map +1 -0
  37. package/dist/compression/zlib.js +109 -0
  38. package/dist/compression/zlib.js.map +1 -0
  39. package/dist/index.d.ts +4 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +3 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/internal/BoundCrud.d.ts +39 -35
  44. package/dist/internal/BoundCrud.d.ts.map +1 -1
  45. package/dist/internal/BoundCrud.js.map +1 -1
  46. package/dist/internal/BoundQuery.d.ts +3 -3
  47. package/dist/internal/BoundQuery.d.ts.map +1 -1
  48. package/dist/internal/BoundQuery.js.map +1 -1
  49. package/dist/internal/BoundVectorQuery.d.ts +12 -0
  50. package/dist/internal/BoundVectorQuery.d.ts.map +1 -1
  51. package/dist/internal/BoundVectorQuery.js +14 -1
  52. package/dist/internal/BoundVectorQuery.js.map +1 -1
  53. package/dist/internal/EntityCombinators.d.ts.map +1 -1
  54. package/dist/internal/EntityCombinators.js +16 -1
  55. package/dist/internal/EntityCombinators.js.map +1 -1
  56. package/dist/internal/Expr.d.ts +5 -3
  57. package/dist/internal/Expr.d.ts.map +1 -1
  58. package/dist/internal/Expr.js +1 -1
  59. package/dist/internal/Expr.js.map +1 -1
  60. package/dist/internal/Packed.d.ts +289 -0
  61. package/dist/internal/Packed.d.ts.map +1 -0
  62. package/dist/internal/Packed.js +654 -0
  63. package/dist/internal/Packed.js.map +1 -0
  64. package/dist/internal/PathBuilder.d.ts +45 -2
  65. package/dist/internal/PathBuilder.d.ts.map +1 -1
  66. package/dist/internal/PathBuilder.js +4 -0
  67. package/dist/internal/PathBuilder.js.map +1 -1
  68. package/dist/internal/TransactableOps.d.ts +1 -1
  69. package/dist/internal/TransactableOps.d.ts.map +1 -1
  70. package/dist/internal/TransactableOps.js +2 -0
  71. package/dist/internal/TransactableOps.js.map +1 -1
  72. package/package.json +6 -2
package/dist/Entity.js CHANGED
@@ -12,6 +12,7 @@ import { Embedder } from "@effect-dynamodb/schema/Embedder.js";
12
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";
@@ -22,6 +23,7 @@ import { BoundQueryImpl, entityNaming, } from "./internal/BoundQuery.js";
22
23
  import { compileExpr, createConditionOps, emptyPartProblem, isExpr, nonEmptyCondition, parseShorthand, parseSimpleShorthand, toExpr, } from "./internal/Expr.js";
23
24
  import { countOperators, expressionFits } from "./internal/ExpressionLimits.js";
24
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";
25
27
  import { compilePath, createPathBuilder } from "./internal/PathBuilder.js";
26
28
  import { freshIncarnationToken, generateTimestampPrimitive, INCARNATION_TOKEN, UNSENTINELED_DEFAULTS, } from "./internal/TransactableOps.js";
27
29
  import { batchEntries, diagnosedValidationError, diagnoseFailure, keyAttributesOf, targetsFor, transactItemTarget, } from "./internal/WriteDiagnosis.js";
@@ -614,17 +616,46 @@ const makeImpl = (config, precomputedData) => {
614
616
  if (encoded.issue !== undefined) {
615
617
  return `.set() of immutable "${field}": ${String(encoded.issue)}`;
616
618
  }
617
- 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
+ });
618
626
  }
619
627
  return out;
620
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). */
621
632
  const immutableMismatch = (asserted, stored) => {
622
- const changed = asserted.find(({ attr, value }) => !attributeValueEquals(stored[attr], value));
623
- return changed === undefined
624
- ? undefined
625
- : `.set() of "${changed.field}": "${changed.field}" is immutable and the update's value ` +
626
- "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);
627
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
+ });
628
659
  /**
629
660
  * Whether an update can safely create the item it names (#133): its payload
630
661
  * supplies every required model field and every primary-key composite, so
@@ -680,6 +711,136 @@ const makeImpl = (config, precomputedData) => {
680
711
  return;
681
712
  decodeSparseFields(raw, sparseFields);
682
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
+ };
683
844
  /**
684
845
  * A DOMAIN-keyed VIEW of a row that came off the wire.
685
846
  *
@@ -973,43 +1134,68 @@ const makeImpl = (config, precomputedData) => {
973
1134
  const corruption = versionCorruption(raw, "decode");
974
1135
  if (corruption !== undefined)
975
1136
  return Effect.fail(corruption);
976
- deserializeSparseFields(raw);
977
- renameFromDynamo(raw);
978
- return Schema.decodeUnknownEffect(schemas.recordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
979
- entityType,
980
- operation: "decode",
981
- cause,
982
- })));
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
+ });
983
1146
  };
984
1147
  /** Decode a raw item using the schema selected by mode */
985
1148
  const decodeAs = (raw, marshalled, mode) => {
986
1149
  const corruption = versionCorruption(raw, "decode");
987
1150
  if (corruption !== undefined)
988
1151
  return Effect.fail(corruption);
1152
+ // Native: the item as stored — packed values stay their `S` / `B`.
989
1153
  if (mode === "native")
990
1154
  return Effect.succeed(marshalled);
991
- // Collect flattened sparse-map attributes back into the domain Record.
992
- // Must happen before rename so the rebuilt Record sits at the domain field
993
- // 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;
994
1194
  deserializeSparseFields(raw);
995
- // Rename DynamoDB attributes back to domain field names. Substituted
996
- // schemas handle wire→domain conversion (date primitives, Redacted, etc.).
997
1195
  renameFromDynamo(raw);
998
- const targetSchema = mode === "model"
999
- ? schemas.modelSchema
1000
- : mode === "record"
1001
- ? schemas.recordSchema
1002
- : schemas.itemSchema;
1003
- return Schema.decodeUnknownEffect(targetSchema)(raw).pipe(Effect.map((decoded) => mode === "model" && isSchemaClass && !hasHiddenFields
1004
- ? new rawModel(decoded)
1005
- : mode !== "item"
1006
- ? attachPrototype(decoded)
1007
- : decoded), Effect.mapError((cause) => new ValidationError({
1008
- entityType,
1009
- operation: "decode",
1010
- cause,
1011
- })));
1012
- };
1196
+ const decoded = yield* Schema.decodeUnknownEffect(schemas.itemSchema)(raw).pipe(Effect.mapError((cause) => new ValidationError({ entityType, operation: "decodeMarshalledItem", cause })));
1197
+ return attachPrototype(decoded);
1198
+ });
1013
1199
  const nowIso = (now) => DateTime.formatIso(now);
1014
1200
  /**
1015
1201
  * Generate a timestamp value as a wire primitive ready for marshalling.
@@ -1314,6 +1500,9 @@ const makeImpl = (config, precomputedData) => {
1314
1500
  const current = yield* client.getItem({ TableName: tableName, Key: marshalledKey });
1315
1501
  if (current.Item) {
1316
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");
1317
1506
  renameFromDynamo(currentDomain);
1318
1507
  merged = { ...currentDomain, ...merged };
1319
1508
  }
@@ -1680,16 +1869,18 @@ const makeImpl = (config, precomputedData) => {
1680
1869
  if (mode === "native")
1681
1870
  return Effect.succeed(projected);
1682
1871
  const raw = fromAttributeMap(projected);
1683
- deserializeSparseFields(raw);
1684
- renameFromDynamo(raw);
1685
- const target = mode === "model"
1686
- ? schemas.modelSchema
1687
- : mode === "record"
1688
- ? schemas.recordSchema
1689
- : schemas.itemSchema;
1690
- const fields = target.fields;
1691
- const present = Object.fromEntries(Object.entries(fields).filter(([name]) => name in raw));
1692
- 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
+ });
1693
1884
  };
1694
1885
  /**
1695
1886
  * The value an update returns for its `returnValues` mode, from the item
@@ -2737,6 +2928,11 @@ const makeImpl = (config, precomputedData) => {
2737
2928
  cause: e instanceof Error ? e.message : String(e),
2738
2929
  });
2739
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);
2740
2936
  // The caller's condition, ANDed onto `create`'s not-exists guard —
2741
2937
  // a guard no `.condition()` can replace (#133). `upsert`'s create
2742
2938
  // attempt brings its own (`runGuardedPut` requires the item missing).
@@ -2823,6 +3019,14 @@ const makeImpl = (config, precomputedData) => {
2823
3019
  let projectionExpression;
2824
3020
  let projectionNames;
2825
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
+ }
2826
3030
  const proj = Projection.projection(opts.projection);
2827
3031
  projectionExpression = proj.expression;
2828
3032
  projectionNames = proj.names;
@@ -2839,9 +3043,12 @@ const makeImpl = (config, precomputedData) => {
2839
3043
  if (!result.Item) {
2840
3044
  return yield* new ItemNotFound({ entityType, key: encodedKey });
2841
3045
  }
2842
- // 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).
2843
3048
  if (mode === "raw") {
2844
- return fromAttributeMap(result.Item);
3049
+ const row = fromAttributeMap(result.Item);
3050
+ yield* unpackFields(row);
3051
+ return row;
2845
3052
  }
2846
3053
  // Unmarshall and decode using selected mode
2847
3054
  const raw = fromAttributeMap(result.Item);
@@ -2856,6 +3063,15 @@ const makeImpl = (config, precomputedData) => {
2856
3063
  * Used instead of reading it again: the write is guarded on it either way.
2857
3064
  */
2858
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
+ }
2859
3075
  // Path operations on a field that feeds a key or a unique sentinel
2860
3076
  // become the record operations they are equivalent to, or are
2861
3077
  // refused (#133) — before anything reads the state.
@@ -3248,7 +3464,10 @@ const makeImpl = (config, precomputedData) => {
3248
3464
  const embedFor = new Set(vectorIndexesNeedingUpdate(collectTouchedFields(hydratedUpdates, uState), uState.withVectors).map(([logicalName]) => logicalName));
3249
3465
  // `newItem` is the fully merged post-update item (removals already
3250
3466
  // applied above), so a put-style recompute is exact.
3251
- 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);
3252
3471
  Object.assign(newItem, vectorWrite.sets);
3253
3472
  for (const field of vectorWrite.removes)
3254
3473
  delete newItem[field];
@@ -3266,9 +3485,17 @@ const makeImpl = (config, precomputedData) => {
3266
3485
  cause: e instanceof Error ? e.message : String(e),
3267
3486
  });
3268
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))));
3269
3495
  const currentItem = currentResult.Item;
3270
3496
  yield* checkVersion(currentItem, "update");
3271
- const immutableChange = immutableMismatch(immutables, currentItem);
3497
+ const immutableChange = immutableMismatch(immutables, currentItem) ??
3498
+ (yield* packedImmutableMismatch(immutables, currentItem));
3272
3499
  if (immutableChange !== undefined) {
3273
3500
  return yield* new ValidationError({
3274
3501
  entityType,
@@ -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}`);
@@ -4870,8 +5127,13 @@ const makeImpl = (config, precomputedData) => {
4870
5127
  }
4871
5128
  const nameKey = `#u${counter}`;
4872
5129
  const valKey = `:u${counter}`;
4873
- names[nameKey] = resolveDbName(attr);
4874
- 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);
4875
5137
  // Immutable fields use if_not_exists — only set on first create
4876
5138
  if (immutableFields.has(attr)) {
4877
5139
  setClauses.push(`${nameKey} = if_not_exists(${nameKey}, ${valKey})`);
@@ -5102,6 +5364,16 @@ const makeImpl = (config, precomputedData) => {
5102
5364
  // `append` takes its condition as an argument — no `condition`
5103
5365
  // combinator — so an empty one becomes none here (#133).
5104
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
+ }
5105
5377
  const emptyPart = userCondition === undefined ? undefined : emptyPartProblem(toExpr(userCondition));
5106
5378
  if (emptyPart !== undefined) {
5107
5379
  return yield* new ValidationError({
@@ -5199,6 +5471,15 @@ const makeImpl = (config, precomputedData) => {
5199
5471
  });
5200
5472
  // `encoded` is already in wire-form via Schema.encode — used directly.
5201
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");
5202
5483
  // ---------- Build UpdateItem (scoped SET + CAS) ----------
5203
5484
  const setClauses = [];
5204
5485
  const names = {};
@@ -5215,8 +5496,9 @@ const makeImpl = (config, precomputedData) => {
5215
5496
  continue;
5216
5497
  const nameKey = `#a${counter}`;
5217
5498
  const valKey = `:a${counter}`;
5218
- names[nameKey] = resolveDbName(attr);
5219
- values[valKey] = toAttributeValue(val);
5499
+ const storedName = resolveDbName(attr);
5500
+ names[nameKey] = storedName;
5501
+ values[valKey] = toAttributeValue(attr in packedFields ? packedStored[storedName] : val);
5220
5502
  setClauses.push(`${nameKey} = ${valKey}`);
5221
5503
  counter++;
5222
5504
  }
@@ -5315,6 +5597,8 @@ const makeImpl = (config, precomputedData) => {
5315
5597
  const eventItem = { ...encoded };
5316
5598
  // Rename domain → DB names for the Put (matches put path)
5317
5599
  renameToDynamo(eventItem);
5600
+ // The packed fields' stored values — the same ones SET above (#147).
5601
+ Object.assign(eventItem, packedStored);
5318
5602
  // pk + sk (event)
5319
5603
  eventItem[primary.pk.field] = pkValue;
5320
5604
  eventItem[primary.sk.field] = eventSk;
@@ -5484,13 +5768,15 @@ const makeImpl = (config, precomputedData) => {
5484
5768
  // NOT carry `<prefix>#*` attributes. `deserializeSparseFields` populates
5485
5769
  // empty `{}` Records under the domain field names so the historyRecord
5486
5770
  // schema (which still includes the sparse Record fields) decodes.
5487
- deserializeSparseFields(raw);
5488
- renameFromDynamo(raw);
5489
- return Schema.decodeUnknownEffect(schemas.historyRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
5490
- entityType,
5491
- operation: "history.decode",
5492
- cause,
5493
- })));
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
+ });
5494
5780
  };
5495
5781
  return Query.make({
5496
5782
  tableName: "",
@@ -5500,6 +5786,7 @@ const makeImpl = (config, precomputedData) => {
5500
5786
  skField: primary.sk.field,
5501
5787
  entityTypes: [entityType],
5502
5788
  decoder: (raw) => decodeHistory(raw),
5789
+ ...packedQueryOptions,
5503
5790
  resolveTableName: tableTag.useSync((tc) => tc.name),
5504
5791
  keyFields: [primary.pk.field, primary.sk.field],
5505
5792
  }).pipe(Query.where({ beginsWith: prefix }));
@@ -5526,6 +5813,8 @@ const makeImpl = (config, precomputedData) => {
5526
5813
  skField: indexDef.sk.field,
5527
5814
  entityTypes: [entityType],
5528
5815
  decoder: (raw) => decodeRecord(raw),
5816
+ ...packedQueryOptions,
5817
+ ...packedQueryOptions,
5529
5818
  resolveTableName: tableTag.useSync((tc) => tc.name),
5530
5819
  keyFields: [
5531
5820
  indexDef.pk.field,
@@ -5555,6 +5844,7 @@ const makeImpl = (config, precomputedData) => {
5555
5844
  indexName: undefined,
5556
5845
  entityTypes: [entityType],
5557
5846
  decoder: (raw) => decodeRecord(raw),
5847
+ ...packedQueryOptions,
5558
5848
  resolveTableName: tableTag.useSync((tc) => tc.name),
5559
5849
  keyFields: [config.indexes.primary.pk.field, config.indexes.primary.sk.field],
5560
5850
  liveRows: liveRows(),
@@ -5658,6 +5948,7 @@ const makeImpl = (config, precomputedData) => {
5658
5948
  skField: primary.sk.field,
5659
5949
  entityTypes: [entityType],
5660
5950
  decoder: (raw) => decodeRecord(raw),
5951
+ ...packedQueryOptions,
5661
5952
  resolveTableName: tableTag.useSync((tc) => tc.name),
5662
5953
  keyFields: [primary.pk.field, primary.sk.field],
5663
5954
  prepare: withLegacy,
@@ -5684,6 +5975,9 @@ const makeImpl = (config, precomputedData) => {
5684
5975
  // (itemSchema would fail because it expects GSI key fields that aren't present)
5685
5976
  if (mode === "native")
5686
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");
5687
5981
  // Sparse Map fields: rebuild domain Records from flattened attrs so
5688
5982
  // soft-deleted items decode correctly. Sparse data is preserved
5689
5983
  // verbatim across soft-delete (GSI keys are stripped, sparse data is
@@ -5712,16 +6006,18 @@ const makeImpl = (config, precomputedData) => {
5712
6006
  const corruption = versionCorruption(raw, "deleted.list.decode");
5713
6007
  if (corruption !== undefined)
5714
6008
  return Effect.fail(corruption);
5715
- // Sparse fields are domain data and are preserved across soft-delete.
5716
- // Rebuild Records from flattened attrs before schema decode.
5717
- deserializeSparseFields(raw);
5718
- // Then attribute → domain names, same order as `decodeRecord` (#127).
5719
- renameFromDynamo(raw);
5720
- return Schema.decodeUnknownEffect(schemas.deletedRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
5721
- entityType,
5722
- operation: "deleted.list.decode",
5723
- cause,
5724
- })));
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
+ });
5725
6021
  };
5726
6022
  return Query.make({
5727
6023
  tableName: "",
@@ -5731,6 +6027,7 @@ const makeImpl = (config, precomputedData) => {
5731
6027
  skField: primary.sk.field,
5732
6028
  entityTypes: [],
5733
6029
  decoder: (raw) => decodeDeleted(raw),
6030
+ ...packedQueryOptions,
5734
6031
  resolveTableName: tableTag.useSync((tc) => tc.name),
5735
6032
  keyFields: [primary.pk.field, primary.sk.field],
5736
6033
  }).pipe(Query.where({ beginsWith: deletedPrefix }));
@@ -6151,6 +6448,7 @@ const makeImpl = (config, precomputedData) => {
6151
6448
  const expr = typeof cbOrShorthand === "function"
6152
6449
  ? cbOrShorthand(entityPathBuilder, entityConditionOps)
6153
6450
  : parseSimpleShorthand(cbOrShorthand);
6451
+ refusePackedExpr(expr, "condition");
6154
6452
  return (self) => conditionCombinator(self, expr);
6155
6453
  };
6156
6454
  /**
@@ -6161,17 +6459,25 @@ const makeImpl = (config, precomputedData) => {
6161
6459
  const expr = typeof cbOrShorthand === "function"
6162
6460
  ? cbOrShorthand(entityPathBuilder, entityConditionOps)
6163
6461
  : parseSimpleShorthand(cbOrShorthand);
6462
+ refusePackedExpr(expr, "filter");
6164
6463
  return (q) => filterExpr(q, expr);
6165
6464
  };
6166
6465
  /**
6167
6466
  * Build a select (projection) combinator from callback or string array.
6168
6467
  */
6169
6468
  const entitySelect = (cbOrAttrs) => {
6469
+ const refuse = (paths) => {
6470
+ const refused = packedProjectionRefusal(paths, packedGuards);
6471
+ if (refused !== undefined)
6472
+ throw new Error(refused);
6473
+ };
6170
6474
  if (typeof cbOrAttrs === "function") {
6171
6475
  const paths = cbOrAttrs(entityPathBuilder);
6172
6476
  const segments = paths.map((p) => p.segments);
6477
+ refuse(segments);
6173
6478
  return (q) => selectPaths(q, segments);
6174
6479
  }
6480
+ refuse(cbOrAttrs.map((attribute) => [attribute]));
6175
6481
  return (q) => Query.select(q, cbOrAttrs);
6176
6482
  };
6177
6483
  // ---------------------------------------------------------------------------
@@ -6199,8 +6505,14 @@ const makeImpl = (config, precomputedData) => {
6199
6505
  generatedId: config.generatedId,
6200
6506
  vectorIndexes: config.vectorIndexes,
6201
6507
  _vectorIndexes: vectorIndexes,
6508
+ _packedFields: Object.keys(packedFields),
6509
+ _packFields: packFields,
6510
+ _unpackFields: unpackFields,
6511
+ _dispatchAttributes: dispatchAttributes,
6512
+ _packedGuards: packedGuards,
6513
+ _decodeImage: decodeImageItem,
6202
6514
  _resolvedRefs: resolvedRefs,
6203
- /** @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. */
6204
6516
  _decodeRecord: decodeRecord,
6205
6517
  /** @internal Domain field name → stored attribute name (`storedAs` renames). */
6206
6518
  _resolveDbName: resolveDbName,
@@ -6222,6 +6534,9 @@ const makeImpl = (config, precomputedData) => {
6222
6534
  tableTag = injectedTableTag;
6223
6535
  },
6224
6536
  _injectIndex: (name, def) => {
6537
+ const refused = packedIndexRefusal(entityType, packedFields, name, def);
6538
+ if (refused !== undefined)
6539
+ throw new Error(refused);
6225
6540
  allIndexes = { ...allIndexes, [name]: def };
6226
6541
  },
6227
6542
  get _schema() {
@@ -6636,12 +6951,27 @@ export const itemSchema = (entity) => entity.schemas.itemSchema;
6636
6951
  /**
6637
6952
  * Decode a marshalled DynamoDB item (AttributeValue format) into a typed Entity.Record.
6638
6953
  *
6639
- * Unmarshalls the AttributeValue map to plain JS, then decodes via the entity's item schema.
6640
- * 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).
6641
6968
  *
6642
- * 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.
6643
6971
  */
6644
6972
  export const decodeMarshalledItem = (entity, marshalledItem) => {
6973
+ if (entity._decodeImage !== undefined)
6974
+ return entity._decodeImage(marshalledItem);
6645
6975
  const corruption = entity._versionCorruption?.(marshalledItem, "decodeMarshalledItem");
6646
6976
  if (corruption !== undefined)
6647
6977
  return Effect.fail(corruption);