effect-dynamodb 1.3.2 → 1.4.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.
package/dist/Entity.js CHANGED
@@ -6,9 +6,9 @@
6
6
  * carries derived schemas for 7 type extractors (Model, Record, Input, Update,
7
7
  * Key, Item, Marshalled).
8
8
  */
9
- import { DateTime, Duration, Effect, Schema, Stream } from "effect";
9
+ import { Duration, Effect, Schema, Stream } from "effect";
10
10
  import { DynamoClient } from "./DynamoClient.js";
11
- import { getEncoding, getIdentifierField, isConfiguredModel, isHidden, isRef, isRefField, } from "./DynamoModel.js";
11
+ import { getIdentifierField, isConfiguredModel, isHidden, isRef, isRefField, } from "./DynamoModel.js";
12
12
  import * as DynamoSchema from "./DynamoSchema.js";
13
13
  import { CascadePartialFailure, ConditionalCheckFailed, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled, OptimisticLockError, RefNotFound, TransactionOverflow, UniqueConstraintViolation, ValidationError, } from "./Errors.js";
14
14
  import { makeBoundDelete, makeBoundPut, makeBoundUpdate } from "./internal/BoundCrud.js";
@@ -17,7 +17,7 @@ import { compileExpr, createConditionOps, isExpr, parseShorthand, parseSimpleSho
17
17
  import { compilePath, createPathBuilder } from "./internal/PathBuilder.js";
18
18
  import * as KeyComposer from "./KeyComposer.js";
19
19
  import { normalizeGsiConfig } from "./KeyComposer.js";
20
- import { deserializeDateFromDynamo, fromAttributeMap, serializeDateForDynamo, toAttributeMap, toAttributeValue, } from "./Marshaller.js";
20
+ import { fromAttributeMap, toAttributeMap, toAttributeValue } from "./Marshaller.js";
21
21
  import * as Query from "./Query.js";
22
22
  import { filterExpr, selectPaths } from "./Query.js";
23
23
  export { EntityDeleteImpl, EntityDeleteTypeId, EntityGetImpl, EntityOpTypeId, EntityPutImpl, EntityUpdateImpl, EntityUpdateTypeId, emptyUpdateState, returnValuesMap, } from "./internal/EntityOps.js";
@@ -25,7 +25,75 @@ import { EntityDeleteImpl, EntityDeleteTypeId, EntityGetImpl, EntityOpTypeId, En
25
25
  import * as Projection from "./Projection.js";
26
26
  export { add, append, asItem, asModel, asNative, asRecord, cascade, consistentRead, deleteFromSet, expectedVersion, pathAdd, pathAppend, pathDelete, pathIfNotExists, pathPrepend, pathRemove, pathSet, pathSubtract, project, remove, returnValues, set, subtract, } from "./internal/EntityCombinators.js";
27
27
  import { asModel, condition as conditionCombinator, expectedVersion, set, } from "./internal/EntityCombinators.js";
28
- import { allCompositeAttributes, allKeyFieldNames, buildDerivedSchemas, getFields, inferDefaultEncoding, primaryKeyComposites, resolveSystemFields, resolveUniqueFields, } from "./internal/EntitySchemas.js";
28
+ import { allCompositeAttributes, allKeyFieldNames, buildDerivedSchemas, buildFieldEncodings, getFields, primaryKeyComposites, resolveSystemFields, resolveUniqueFields, validateNoTransformOverride, } from "./internal/EntitySchemas.js";
29
+ // ---------------------------------------------------------------------------
30
+ // Sparse-aware unique sentinel composition
31
+ // ---------------------------------------------------------------------------
32
+ /**
33
+ * Compose a unique-constraint sentinel key, returning `undefined` when any
34
+ * composing field is missing (`undefined` or `null`) on the source record.
35
+ *
36
+ * Mirrors GSI sparse semantics (`tryComposeIndexKeys`): a sentinel is only
37
+ * written/deleted/checked when every composing field is present, so multiple
38
+ * records can coexist with the field unset and an entity write doesn't synthesize
39
+ * a literal `"undefined"` collision key.
40
+ *
41
+ * `lookup` overrides the field-value reader. The update path passes a lookup that
42
+ * falls back from the domain field name to the renamed DB column name, since
43
+ * `currentRaw` (read from DynamoDB) carries DB names rather than domain names.
44
+ */
45
+ const composeUniqueSentinel = (schema, entityType, constraintName, constraintDef, source, lookup) => {
46
+ const fields = resolveUniqueFields(constraintDef);
47
+ const get = lookup ?? ((s, f) => s[f]);
48
+ const serialized = [];
49
+ const fieldsRecord = {};
50
+ for (const f of fields) {
51
+ const raw = get(source, f);
52
+ if (raw === undefined || raw === null)
53
+ return undefined;
54
+ const s = KeyComposer.serializeValue(raw);
55
+ serialized.push(s);
56
+ fieldsRecord[f] = s;
57
+ }
58
+ return {
59
+ key: DynamoSchema.composeUniqueKey(schema, entityType, constraintName, serialized),
60
+ fieldsRecord,
61
+ };
62
+ };
63
+ // ---------------------------------------------------------------------------
64
+ // Encode-or-decode-encode helper
65
+ // ---------------------------------------------------------------------------
66
+ /**
67
+ * Validate user input and produce wire-form output, accepting both Type and
68
+ * Encoded shapes. Strategy:
69
+ *
70
+ * 1. Try `Schema.encode(input)`. This is the canonical Type → Encoded
71
+ * conversion and works when the user passes Type values for transforms
72
+ * (e.g. a `DateTime.Utc` for `Schema.DateTimeUtcFromString`, a `number`
73
+ * for `Schema.NumberFromString`, a `Redacted<string>` for our
74
+ * RedactedFromValue substitute).
75
+ *
76
+ * 2. On encode failure, fall back to `Schema.decode → Schema.encode`. This
77
+ * lifts plain inputs to the Type shape first (`Schema.Class` decode is
78
+ * forgiving — it constructs a class instance from a plain object), then
79
+ * re-encodes back to wire form. Required for nested `Schema.Class`
80
+ * fields whose Type is a class instance but whose Encoded is a plain
81
+ * object — users typically construct the plain object directly.
82
+ *
83
+ * Returns the Encoded shape (a plain object whose values are wire
84
+ * primitives), ready for `toAttributeMap`.
85
+ *
86
+ * @internal
87
+ */
88
+ const encodeOrDecodeEncode = (schema, input, entityType, operation) => Schema.encodeUnknownEffect(schema)(input).pipe(Effect.catch((primaryCause) => Schema.decodeUnknownEffect(schema)(input).pipe(Effect.flatMap((decoded) => Schema.encodeUnknownEffect(schema)(decoded)),
89
+ // On fallback failure, surface the original encode error — its
90
+ // message is keyed on the caller's input shape, which is what the
91
+ // user expects to see.
92
+ Effect.catch(() => Effect.fail(new ValidationError({
93
+ entityType,
94
+ operation: `${operation}.decode`,
95
+ cause: primaryCause,
96
+ }))))));
29
97
  // ---------------------------------------------------------------------------
30
98
  // Transaction limit pre-check (100-item DynamoDB limit)
31
99
  // ---------------------------------------------------------------------------
@@ -272,7 +340,15 @@ const makeImpl = (config) => {
272
340
  }
273
341
  // Resolve identifier field name from model annotation
274
342
  const resolvedIdentifier = getIdentifierField(config.model)?.name;
275
- const schemas = buildDerivedSchemas(modelFields, allIndexes, systemFields, resolvedRefs, immutableFields, resolvedIdentifier, config.timeSeries);
343
+ // Validate: no model field combines a transform schema with a ConfiguredModel
344
+ // storage override. The two configuration paths are mutually exclusive.
345
+ validateNoTransformOverride(modelFields, configuredAttributes);
346
+ // Resolve effective field encodings (annotation + ConfiguredModel override).
347
+ // Used by buildDerivedSchemas to substitute self date schemas with bidirectional
348
+ // transforms, and (below) by system-field generation to produce the right
349
+ // wire primitive when the field is library-managed.
350
+ const fieldEncodings = buildFieldEncodings(modelFields, configuredAttributes);
351
+ const schemas = buildDerivedSchemas(modelFields, allIndexes, systemFields, resolvedRefs, immutableFields, resolvedIdentifier, config.timeSeries, fieldEncodings);
276
352
  // schema and tableTag are injected via _configure() when the entity is registered
277
353
  // on a Table and bound through DynamoClient.make(). They are captured by operation
278
354
  // closures and resolved at runtime (inside Effects), not at definition time.
@@ -281,37 +357,10 @@ const makeImpl = (config) => {
281
357
  let tableTag;
282
358
  const entityType = config.entityType;
283
359
  const entityVersion = 1;
284
- // ---------------------------------------------------------------------------
285
- // DynamoEncoding map: field name → encoding for date-annotated fields
286
- // Merges schema annotations with ConfiguredModel storage overrides.
287
- // ---------------------------------------------------------------------------
288
- const fieldEncodings = {};
289
- for (const [fieldName, fieldSchema] of Object.entries(modelFields)) {
290
- const encoding = getEncoding(fieldSchema);
291
- if (encoding) {
292
- fieldEncodings[fieldName] = encoding;
293
- }
294
- else {
295
- // Auto-detect standard Effect date schemas (Pattern B: pure model)
296
- const inferred = inferDefaultEncoding(fieldSchema);
297
- if (inferred)
298
- fieldEncodings[fieldName] = inferred;
299
- }
300
- }
301
- // Apply ConfiguredModel storage overrides (takes precedence over schema annotations)
302
- for (const [fieldName, attrConfig] of Object.entries(configuredAttributes)) {
303
- if (attrConfig.encoding) {
304
- const existing = fieldEncodings[fieldName];
305
- fieldEncodings[fieldName] = {
306
- storage: attrConfig.encoding.storage,
307
- domain: existing?.domain ?? attrConfig.encoding.domain,
308
- };
309
- }
310
- }
311
- // Note: System timestamp encodings are NOT added to fieldEncodings.
312
- // generateTimestamp() handles serialization directly for timestamps.
313
- // This avoids double-serialization in serializeDateFields.
314
- const hasDateFields = Object.keys(fieldEncodings).length > 0;
360
+ // Note: `fieldEncodings` is resolved earlier (above buildDerivedSchemas) and
361
+ // used to substitute self date schemas in derived schemas. System timestamp
362
+ // encodings are NOT in `fieldEncodings`; `generateTimestamp` produces wire
363
+ // primitives directly for non-colliding system fields.
315
364
  // ---------------------------------------------------------------------------
316
365
  // Field renaming: domain name → DynamoDB attribute name (from ConfiguredModel)
317
366
  // ---------------------------------------------------------------------------
@@ -351,32 +400,6 @@ const makeImpl = (config) => {
351
400
  };
352
401
  /** Resolve a domain field name to its DynamoDB attribute name. */
353
402
  const resolveDbName = (domainName) => fieldRenames[domainName] ?? domainName;
354
- /**
355
- * Convert domain date values to storage primitives for DynamoEncoding-annotated fields.
356
- * Called before toAttributeMap (put path).
357
- */
358
- const serializeDateFields = (item) => {
359
- if (!hasDateFields)
360
- return;
361
- for (const [field, encoding] of Object.entries(fieldEncodings)) {
362
- if (field in item && item[field] != null) {
363
- item[field] = serializeDateForDynamo(item[field], encoding);
364
- }
365
- }
366
- };
367
- /**
368
- * Convert storage primitives back to domain values for DynamoEncoding-annotated fields.
369
- * Called after fromAttributeMap (get path).
370
- */
371
- const deserializeDateFields = (raw) => {
372
- if (!hasDateFields)
373
- return;
374
- for (const [field, encoding] of Object.entries(fieldEncodings)) {
375
- if (field in raw && raw[field] != null) {
376
- raw[field] = deserializeDateFromDynamo(raw[field], encoding);
377
- }
378
- }
379
- };
380
403
  // ---------------------------------------------------------------------------
381
404
  // Helpers
382
405
  // ---------------------------------------------------------------------------
@@ -392,7 +415,6 @@ const makeImpl = (config) => {
392
415
  const attachPrototype = (decoded) => isSchemaClass ? Object.assign(Object.create(rawModel.prototype), decoded) : decoded;
393
416
  const decodeRecord = (raw) => {
394
417
  renameFromDynamo(raw);
395
- deserializeDateFields(raw);
396
418
  return Schema.decodeUnknownEffect(schemas.recordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
397
419
  entityType,
398
420
  operation: "decode",
@@ -403,15 +425,11 @@ const makeImpl = (config) => {
403
425
  const decodeAs = (raw, marshalled, mode) => {
404
426
  if (mode === "native")
405
427
  return Effect.succeed(marshalled);
406
- // Rename DynamoDB attributes back to domain field names
428
+ // Rename DynamoDB attributes back to domain field names. Substituted
429
+ // schemas handle wire→domain conversion (date primitives, Redacted, etc.).
407
430
  renameFromDynamo(raw);
408
- // Convert date storage primitives to domain values before Schema decode.
409
- // recordSchema/itemSchema use fromSelf variants for annotated fields,
410
- // so they accept domain values directly. modelSchema also uses fromSelf
411
- // for the decode path after deserialization.
412
- deserializeDateFields(raw);
413
431
  const targetSchema = mode === "model"
414
- ? schemas.modelDecodeSchema
432
+ ? schemas.modelSchema
415
433
  : mode === "record"
416
434
  ? schemas.recordSchema
417
435
  : schemas.itemSchema;
@@ -427,33 +445,26 @@ const makeImpl = (config) => {
427
445
  };
428
446
  const nowIso = () => new Date().toISOString();
429
447
  /**
430
- * Generate a timestamp value appropriate for the field's encoding.
448
+ * Generate a timestamp value as a wire primitive ready for marshalling.
431
449
  * Default (no encoding): ISO string. Custom encoding: serialized primitive.
432
450
  *
433
- * Used for non-colliding system fields (field not declared in model) — the
434
- * value is written straight into the DynamoDB item and is NOT routed through
435
- * `serializeDateFields` (because the field is absent from `fieldEncodings`).
451
+ * Used for system fields whether colliding or not — the value is always a
452
+ * wire primitive (string or number) ready for `toAttributeMap`.
436
453
  */
437
454
  const generateTimestamp = (encoding) => {
438
455
  if (!encoding)
439
456
  return nowIso();
440
- const now = new Date();
441
- return serializeDateForDynamo(now, encoding);
442
- };
443
- /**
444
- * Generate a timestamp as a domain value (DateTime.Utc / DateTime.Zoned / Date),
445
- * matching the field's declared domain. Used for system fields that COLLIDE
446
- * with a model-declared field — the value flows through `serializeDateFields`
447
- * (which requires a domain value) alongside any user-supplied override.
448
- */
449
- const generateDomainTimestamp = (encoding) => {
450
- switch (encoding.domain) {
451
- case "DateTime.Utc":
452
- return DateTime.makeUnsafe(new Date());
453
- case "DateTime.Zoned":
454
- return DateTime.makeZonedUnsafe(new Date(), { timeZone: "UTC" });
455
- case "Date":
456
- return new Date();
457
+ const now = Date.now();
458
+ switch (encoding.storage) {
459
+ case "string":
460
+ // For DateTime.Zoned this would need the extended ISO format, but
461
+ // system timestamps generated here are UTC — DateTime.Zoned-typed
462
+ // collision fields are rare and not previously special-cased either.
463
+ return new Date(now).toISOString();
464
+ case "epochMs":
465
+ return now;
466
+ case "epochSeconds":
467
+ return Math.floor(now / 1000);
457
468
  }
458
469
  };
459
470
  // ---------------------------------------------------------------------------
@@ -743,41 +754,41 @@ const makeImpl = (config) => {
743
754
  const put = (input) => new EntityPutImpl((mode, opts) => Effect.gen(function* () {
744
755
  const client = yield* DynamoClient;
745
756
  const { name: tableName } = yield* tableTag;
746
- // Decode input
747
- const decodedInput = yield* Schema.decodeUnknownEffect(schemas.inputDecodeSchema)(input).pipe(Effect.mapError((cause) => new ValidationError({
748
- entityType,
749
- operation: "put.decode",
750
- cause,
751
- })));
752
- // Compose all keys BEFORE ref hydration (ID fields are still present)
753
- const keys = composeAllKeys(decodedInput);
757
+ // Encode user input → wire form. Users typically construct domain
758
+ // values for transforms (DateTime, Redacted, Number) and plain
759
+ // objects for Schema.Class fields. To handle both:
760
+ // 1. Try `encode` directly — the canonical Type → Encoded
761
+ // conversion. Works when the input is fully Type-shaped
762
+ // (which is the case for the transforms that issue #29
763
+ // surfaced: RedactedFromValue, NumberFromString, etc.).
764
+ // 2. On failure, fall back to `decode → encode` — `decode` is
765
+ // forgiving for Schema.Class (lifts plain objects to
766
+ // instances) and the substituted date/Redacted transforms
767
+ // tolerate either form. The subsequent `encode` produces
768
+ // the canonical wire shape.
769
+ const encodedInput = yield* encodeOrDecodeEncode(schemas.inputSchema, input, entityType, "put");
770
+ // Compose all keys from the encoded input (ID fields still present
771
+ // and key composites are wire-format strings/numbers).
772
+ const keys = composeAllKeys(encodedInput);
754
773
  // Hydrate refs: replace ID fields with full entity domain data
755
- const decoded = hasRefs
756
- ? yield* hydrateRefs(decodedInput)
757
- : decodedInput;
758
- // Build the DynamoDB item
759
- const item = { ...decoded };
774
+ const encoded = hasRefs
775
+ ? yield* hydrateRefs(encodedInput)
776
+ : encodedInput;
777
+ // Build the DynamoDB item — already in wire-format after encode
778
+ const item = { ...encoded };
760
779
  // Add system fields. When a timestamp field collides with a
761
780
  // model-declared field, the user may have supplied their own value
762
- // (see `inputDecodeSchema` — optional on collision). Respect their
763
- // value if present; otherwise generate. In the collision path the
764
- // value is a DOMAIN value (DateTime.Utc / Date / ...) so it flows
765
- // through `serializeDateFields` downstream. Non-colliding fields
766
- // get a storage primitive directly (no `serializeDateFields` pass).
781
+ // (optional on collision in `inputSchema`). Respect their value if
782
+ // present (already encoded to wire); otherwise generate a wire
783
+ // primitive directly.
767
784
  if (systemFields.createdAt) {
768
785
  if (item[systemFields.createdAt] === undefined) {
769
- item[systemFields.createdAt] =
770
- systemFields.createdAtCollision && systemFields.createdAtEncoding
771
- ? generateDomainTimestamp(systemFields.createdAtEncoding)
772
- : generateTimestamp(systemFields.createdAtEncoding);
786
+ item[systemFields.createdAt] = generateTimestamp(systemFields.createdAtEncoding);
773
787
  }
774
788
  }
775
789
  if (systemFields.updatedAt) {
776
790
  if (item[systemFields.updatedAt] === undefined) {
777
- item[systemFields.updatedAt] =
778
- systemFields.updatedAtCollision && systemFields.updatedAtEncoding
779
- ? generateDomainTimestamp(systemFields.updatedAtEncoding)
780
- : generateTimestamp(systemFields.updatedAtEncoding);
791
+ item[systemFields.updatedAt] = generateTimestamp(systemFields.updatedAtEncoding);
781
792
  }
782
793
  }
783
794
  if (systemFields.version)
@@ -786,8 +797,6 @@ const makeImpl = (config) => {
786
797
  item.__edd_e__ = entityType;
787
798
  // Apply composed keys
788
799
  Object.assign(item, keys);
789
- // Convert date fields from domain values to storage primitives
790
- serializeDateFields(item);
791
800
  // Rename domain fields to DynamoDB attribute names
792
801
  renameToDynamo(item);
793
802
  const marshalledItem = toAttributeMap(item);
@@ -813,18 +822,20 @@ const makeImpl = (config) => {
813
822
  }
814
823
  }
815
824
  transactItems.push({ Put: mainPut });
816
- // Unique constraint sentinels
825
+ // Unique constraint sentinels (sparse — skip when any composing field is missing)
826
+ const sentinelConstraints = [];
817
827
  if (hasUniqueConstraints) {
818
828
  for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
819
- const fields = resolveUniqueFields(constraintDef);
820
- const fieldValues = fields.map((f) => KeyComposer.serializeValue(decoded[f]));
821
- const uniqueKey = DynamoSchema.composeUniqueKey(schema, entityType, constraintName, fieldValues);
829
+ const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, encoded);
830
+ if (!sentinel)
831
+ continue;
832
+ sentinelConstraints.push(constraintName);
822
833
  transactItems.push({
823
834
  Put: {
824
835
  TableName: tableName,
825
836
  Item: toAttributeMap({
826
- [config.indexes.primary.pk.field]: uniqueKey.pk,
827
- [config.indexes.primary.sk.field]: uniqueKey.sk,
837
+ [config.indexes.primary.pk.field]: sentinel.key.pk,
838
+ [config.indexes.primary.sk.field]: sentinel.key.sk,
828
839
  __edd_e__: `${entityType}._unique.${constraintName}`,
829
840
  _entity_pk: keys[config.indexes.primary.pk.field],
830
841
  _entity_sk: keys[config.indexes.primary.sk.field],
@@ -858,21 +869,24 @@ const makeImpl = (config) => {
858
869
  if (opts.condition && reasons[0]?.Code === "ConditionalCheckFailed") {
859
870
  return new ConditionalCheckFailed({
860
871
  entityType,
861
- key: decoded,
872
+ key: encoded,
862
873
  });
863
874
  }
864
- // Sentinel items start at index 1
875
+ // Sentinel items start at index 1, in the order recorded in
876
+ // `sentinelConstraints` (skipped sparse entries are absent)
865
877
  for (let i = 1; i < reasons.length; i++) {
866
878
  if (reasons[i]?.Code === "ConditionalCheckFailed") {
867
- const constraintNames = Object.keys(config.unique);
868
- const constraintName = constraintNames[i - 1] ?? "unknown";
869
- const constraintDef = config.unique[constraintName];
879
+ const constraintName = sentinelConstraints[i - 1] ?? "unknown";
880
+ const constraintDef = config.unique?.[constraintName];
870
881
  const uniqueFields = constraintDef
871
882
  ? resolveUniqueFields(constraintDef)
872
883
  : [];
873
884
  const fieldsRecord = {};
874
885
  for (const f of uniqueFields) {
875
- fieldsRecord[f] = KeyComposer.serializeValue(decoded[f]);
886
+ const v = encoded[f];
887
+ if (v !== undefined && v !== null) {
888
+ fieldsRecord[f] = KeyComposer.serializeValue(v);
889
+ }
876
890
  }
877
891
  return new UniqueConstraintViolation({
878
892
  entityType,
@@ -901,7 +915,10 @@ const makeImpl = (config) => {
901
915
  }
902
916
  yield* client.putItem(putInput).pipe(Effect.mapError((err) => {
903
917
  if (opts.condition && isAwsConditionalCheckFailed(err.cause)) {
904
- return new ConditionalCheckFailed({ entityType, key: decoded });
918
+ return new ConditionalCheckFailed({
919
+ entityType,
920
+ key: encoded,
921
+ });
905
922
  }
906
923
  return err;
907
924
  }));
@@ -986,17 +1003,13 @@ const makeImpl = (config) => {
986
1003
  operation: "update.decodeKey",
987
1004
  cause,
988
1005
  })));
989
- // Decode updates
990
- const decodedUpdates = yield* Schema.decodeUnknownEffect(schemas.updateDecodeSchema)(updates ?? {}).pipe(Effect.mapError((cause) => new ValidationError({
991
- entityType,
992
- operation: "update.decodeUpdates",
993
- cause,
994
- })));
1006
+ // Encode update payload → wire form (see `put` for the strategy).
1007
+ const encodedUpdates = yield* encodeOrDecodeEncode(schemas.updateSchema, updates ?? {}, entityType, "update");
995
1008
  // Hydrate refs in updates: for any ${field}Id present, fetch and embed
996
- let hydratedUpdates = decodedUpdates;
1009
+ let hydratedUpdates = encodedUpdates;
997
1010
  if (hasRefs) {
998
- const refsToHydrate = resolvedRefs.filter((ref) => ref.idFieldName in decodedUpdates &&
999
- decodedUpdates[ref.idFieldName] !==
1011
+ const refsToHydrate = resolvedRefs.filter((ref) => ref.idFieldName in encodedUpdates &&
1012
+ encodedUpdates[ref.idFieldName] !==
1000
1013
  undefined);
1001
1014
  if (refsToHydrate.length > 0) {
1002
1015
  hydratedUpdates = yield* hydrateRefs(hydratedUpdates, refsToHydrate);
@@ -1044,24 +1057,17 @@ const makeImpl = (config) => {
1044
1057
  });
1045
1058
  }
1046
1059
  // Build new item: merge current + updates + rich ops + recompose keys.
1047
- //
1048
- // Pre-serialize domain date values from `hydratedUpdates` (decoded
1049
- // via `fromSelf`, so DateTime.Utc / Date instances) into storage
1050
- // primitives BEFORE merging into `newItem`. `currentRaw` is already
1051
- // storage-primitive (it came straight from `fromAttributeMap`), so
1052
- // we need both halves in the same shape — otherwise the final
1053
- // `toAttributeMap(newItem)` marshals the DateTime class instances
1054
- // as DynamoDB Maps (via AWS SDK's `convertClassInstanceToMap`),
1055
- // which corrupts the write and makes subsequent reads fail to
1056
- // decode.
1057
- const serializedRetainUpdates = {
1058
- ...hydratedUpdates,
1059
- };
1060
- serializeDateFields(serializedRetainUpdates);
1061
- const newItem = {
1060
+ // `currentRaw` uses DynamoDB attribute names (e.g. `dn`, `hd`);
1061
+ // `hydratedUpdates` uses domain names. To avoid the merge leaving
1062
+ // both names (which would later have the rename clobber the user's
1063
+ // value), translate the current item's keys to domain names first
1064
+ // so the spread + assign overlay cleanly.
1065
+ const currentDomainItem = {
1062
1066
  ...currentRaw,
1063
1067
  };
1064
- for (const [attr, val] of Object.entries(serializedRetainUpdates)) {
1068
+ renameFromDynamo(currentDomainItem);
1069
+ const newItem = { ...currentDomainItem };
1070
+ for (const [attr, val] of Object.entries(hydratedUpdates)) {
1065
1071
  if (val !== undefined)
1066
1072
  newItem[attr] = val;
1067
1073
  }
@@ -1099,45 +1105,42 @@ const makeImpl = (config) => {
1099
1105
  }
1100
1106
  // Increment version and update timestamp. If the caller supplied a
1101
1107
  // value for `updatedAt` (allowed when the field collides with a
1102
- // model-declared field), respect it; else generate. Read from the
1103
- // pre-serialized map so both branches produce a storage primitive
1104
- // (ISO string / epoch number) — not a domain DateTime instance.
1108
+ // model-declared field), respect it (already wire-form via encode);
1109
+ // else generate a fresh wire primitive.
1105
1110
  const newVersion = currentVersion + 1;
1106
1111
  if (systemFields.version)
1107
1112
  newItem[systemFields.version] = newVersion;
1108
1113
  if (systemFields.updatedAt) {
1109
- const userSupplied = serializedRetainUpdates[systemFields.updatedAt];
1114
+ const userSupplied = hydratedUpdates[systemFields.updatedAt];
1110
1115
  newItem[systemFields.updatedAt] =
1111
1116
  userSupplied !== undefined
1112
1117
  ? userSupplied
1113
1118
  : generateTimestamp(systemFields.updatedAtEncoding);
1114
1119
  }
1115
- // Convert DynamoDB attribute names to domain names for key composition
1116
- // and sentinel value comparison (newItem was built from currentRaw which
1117
- // uses DynamoDB names; composites and unique fields use domain names)
1118
- renameFromDynamo(newItem);
1119
- // Recompose all keys with updated attributes
1120
+ // `newItem` is already in domain names (the merge built it from
1121
+ // `currentDomainItem` + domain-keyed updates) — no rename needed
1122
+ // here. Recompose all keys with the updated attributes.
1120
1123
  const newKeys = composeAllKeys(newItem);
1121
1124
  Object.assign(newItem, newKeys);
1122
1125
  const sentinelRotations = [];
1123
1126
  if (touchesUniqueFields) {
1124
1127
  const currentRawRecord = currentRaw;
1125
1128
  for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
1126
- const fields = resolveUniqueFields(constraintDef);
1127
- const oldFieldValues = fields.map((f) => KeyComposer.serializeValue(currentRawRecord[f] ?? currentRawRecord[resolveDbName(f)]));
1128
- const newFieldValues = fields.map((f) => KeyComposer.serializeValue(newItem[f]));
1129
- const changed = oldFieldValues.some((v, i) => v !== newFieldValues[i]);
1130
- if (!changed)
1129
+ const oldSentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, currentRawRecord, (s, f) => s[f] ?? s[resolveDbName(f)]);
1130
+ const newSentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, newItem);
1131
+ if (!oldSentinel && !newSentinel)
1132
+ continue;
1133
+ if (oldSentinel &&
1134
+ newSentinel &&
1135
+ oldSentinel.key.pk === newSentinel.key.pk &&
1136
+ oldSentinel.key.sk === newSentinel.key.sk) {
1131
1137
  continue;
1132
- const newFieldsRecord = {};
1133
- for (const f of fields) {
1134
- newFieldsRecord[f] = KeyComposer.serializeValue(newItem[f]);
1135
1138
  }
1136
1139
  sentinelRotations.push({
1137
1140
  constraintName,
1138
- oldUniqueKey: DynamoSchema.composeUniqueKey(schema, entityType, constraintName, oldFieldValues),
1139
- newUniqueKey: DynamoSchema.composeUniqueKey(schema, entityType, constraintName, newFieldValues),
1140
- newFieldsRecord,
1141
+ oldUniqueKey: oldSentinel?.key,
1142
+ newUniqueKey: newSentinel?.key,
1143
+ newFieldsRecord: newSentinel?.fieldsRecord,
1141
1144
  });
1142
1145
  }
1143
1146
  }
@@ -1185,38 +1188,42 @@ const makeImpl = (config) => {
1185
1188
  },
1186
1189
  });
1187
1190
  }
1188
- // Apply sentinel rotations computed earlier
1191
+ // Apply sentinel rotations computed earlier. Delete and Put are emitted
1192
+ // independently — a sparse field that becomes set emits Put only; a sparse
1193
+ // field that becomes unset emits Delete only.
1189
1194
  const sentinelPutIndices = [];
1190
1195
  for (const rotation of sentinelRotations) {
1191
- // Delete old sentinel
1192
- transactItems.push({
1193
- Delete: {
1194
- TableName: tableName,
1195
- Key: toAttributeMap({
1196
- [config.indexes.primary.pk.field]: rotation.oldUniqueKey.pk,
1197
- [config.indexes.primary.sk.field]: rotation.oldUniqueKey.sk,
1198
- }),
1199
- },
1200
- });
1201
- // Put new sentinel with uniqueness check
1202
- sentinelPutIndices.push({
1203
- index: transactItems.length,
1204
- constraintName: rotation.constraintName,
1205
- newFieldsRecord: rotation.newFieldsRecord,
1206
- });
1207
- transactItems.push({
1208
- Put: {
1209
- TableName: tableName,
1210
- Item: toAttributeMap({
1211
- [config.indexes.primary.pk.field]: rotation.newUniqueKey.pk,
1212
- [config.indexes.primary.sk.field]: rotation.newUniqueKey.sk,
1213
- __edd_e__: `${entityType}._unique.${rotation.constraintName}`,
1214
- _entity_pk: primaryKey[config.indexes.primary.pk.field],
1215
- _entity_sk: primaryKey[config.indexes.primary.sk.field],
1216
- }),
1217
- ConditionExpression: "attribute_not_exists(pk)",
1218
- },
1219
- });
1196
+ if (rotation.oldUniqueKey) {
1197
+ transactItems.push({
1198
+ Delete: {
1199
+ TableName: tableName,
1200
+ Key: toAttributeMap({
1201
+ [config.indexes.primary.pk.field]: rotation.oldUniqueKey.pk,
1202
+ [config.indexes.primary.sk.field]: rotation.oldUniqueKey.sk,
1203
+ }),
1204
+ },
1205
+ });
1206
+ }
1207
+ if (rotation.newUniqueKey && rotation.newFieldsRecord) {
1208
+ sentinelPutIndices.push({
1209
+ index: transactItems.length,
1210
+ constraintName: rotation.constraintName,
1211
+ newFieldsRecord: rotation.newFieldsRecord,
1212
+ });
1213
+ transactItems.push({
1214
+ Put: {
1215
+ TableName: tableName,
1216
+ Item: toAttributeMap({
1217
+ [config.indexes.primary.pk.field]: rotation.newUniqueKey.pk,
1218
+ [config.indexes.primary.sk.field]: rotation.newUniqueKey.sk,
1219
+ __edd_e__: `${entityType}._unique.${rotation.constraintName}`,
1220
+ _entity_pk: primaryKey[config.indexes.primary.pk.field],
1221
+ _entity_sk: primaryKey[config.indexes.primary.sk.field],
1222
+ }),
1223
+ ConditionExpression: "attribute_not_exists(pk)",
1224
+ },
1225
+ });
1226
+ }
1220
1227
  }
1221
1228
  yield* checkTransactionLimit(entityType, "update", transactItems);
1222
1229
  yield* client
@@ -1277,18 +1284,17 @@ const makeImpl = (config) => {
1277
1284
  const names = {};
1278
1285
  const values = {};
1279
1286
  let counter = 0;
1280
- // Serialize any domain-value date fields in updates to storage primitives.
1281
- // Without this, a `.set({ occurredAt: DateTime.Utc })` would marshal as a
1282
- // class-instance map.
1283
- const serializedUpdates = { ...hydratedUpdates };
1284
- serializeDateFields(serializedUpdates);
1287
+ // `hydratedUpdates` was produced by `Schema.encode(updateSchema)` and
1288
+ // is already in wire-format (ISO string / epoch number / etc.). Use
1289
+ // it directly — no `serializeDateFields` pass required.
1290
+ const encodedUpdatesMap = hydratedUpdates;
1285
1291
  // System-colliding updatedAt is handled by the system-field block below.
1286
- // createdAt and version are already excluded by `updateDecodeSchema`.
1292
+ // createdAt and version are already excluded by `updateSchema`.
1287
1293
  const updateSystemColliders = new Set();
1288
1294
  if (systemFields.updatedAtCollision && systemFields.updatedAt)
1289
1295
  updateSystemColliders.add(systemFields.updatedAt);
1290
1296
  // Add user-provided updates
1291
- for (const [attr, val] of Object.entries(serializedUpdates)) {
1297
+ for (const [attr, val] of Object.entries(encodedUpdatesMap)) {
1292
1298
  if (val === undefined)
1293
1299
  continue;
1294
1300
  if (updateSystemColliders.has(attr))
@@ -1300,10 +1306,11 @@ const makeImpl = (config) => {
1300
1306
  setClauses.push(`${nameKey} = ${valKey}`);
1301
1307
  counter++;
1302
1308
  }
1303
- // Add updatedAt timestamp. User-supplied value wins (pre-serialized
1304
- // above); else fall back to a freshly generated storage primitive.
1309
+ // Add updatedAt timestamp. User-supplied value wins (already in wire
1310
+ // form via encode); else fall back to a freshly generated wire
1311
+ // primitive.
1305
1312
  if (systemFields.updatedAt) {
1306
- const userSupplied = serializedUpdates[systemFields.updatedAt];
1313
+ const userSupplied = encodedUpdatesMap[systemFields.updatedAt];
1307
1314
  const nameKey = `#u${counter}`;
1308
1315
  const valKey = `:u${counter}`;
1309
1316
  names[nameKey] = systemFields.updatedAt;
@@ -1630,18 +1637,19 @@ const makeImpl = (config) => {
1630
1637
  },
1631
1638
  });
1632
1639
  }
1633
- // Delete sentinels if not preserving unique
1640
+ // Delete sentinels if not preserving unique. Sparse — fields that were
1641
+ // unset on the live item never had a sentinel, so nothing to delete.
1634
1642
  if (hasUniqueConstraints && !preserveUnique()) {
1635
1643
  for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
1636
- const fields = resolveUniqueFields(constraintDef);
1637
- const fieldValues = fields.map((f) => KeyComposer.serializeValue(raw[f]));
1638
- const uniqueKey = DynamoSchema.composeUniqueKey(schema, entityType, constraintName, fieldValues);
1644
+ const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, raw);
1645
+ if (!sentinel)
1646
+ continue;
1639
1647
  transactItems.push({
1640
1648
  Delete: {
1641
1649
  TableName: tableName,
1642
1650
  Key: toAttributeMap({
1643
- [primary.pk.field]: uniqueKey.pk,
1644
- [primary.sk.field]: uniqueKey.sk,
1651
+ [primary.pk.field]: sentinel.key.pk,
1652
+ [primary.sk.field]: sentinel.key.sk,
1645
1653
  }),
1646
1654
  },
1647
1655
  });
@@ -1668,17 +1676,18 @@ const makeImpl = (config) => {
1668
1676
  transactItems.push({
1669
1677
  Delete: { TableName: tableName, Key: marshalledKey },
1670
1678
  });
1671
- // Delete sentinels
1679
+ // Delete sentinels (sparse — skip constraints whose fields were unset
1680
+ // on the live item; no sentinel was ever written for those)
1672
1681
  for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
1673
- const fields = resolveUniqueFields(constraintDef);
1674
- const fieldValues = fields.map((f) => KeyComposer.serializeValue(raw[f]));
1675
- const uniqueKey = DynamoSchema.composeUniqueKey(schema, entityType, constraintName, fieldValues);
1682
+ const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, raw);
1683
+ if (!sentinel)
1684
+ continue;
1676
1685
  transactItems.push({
1677
1686
  Delete: {
1678
1687
  TableName: tableName,
1679
1688
  Key: toAttributeMap({
1680
- [primary.pk.field]: uniqueKey.pk,
1681
- [primary.sk.field]: uniqueKey.sk,
1689
+ [primary.pk.field]: sentinel.key.pk,
1690
+ [primary.sk.field]: sentinel.key.sk,
1682
1691
  }),
1683
1692
  },
1684
1693
  });
@@ -1729,17 +1738,13 @@ const makeImpl = (config) => {
1729
1738
  const upsert = (input) => new EntityPutImpl((mode, opts) => Effect.gen(function* () {
1730
1739
  const client = yield* DynamoClient;
1731
1740
  const { name: tableName } = yield* tableTag;
1732
- // Decode input
1733
- const decodedInput = yield* Schema.decodeUnknownEffect(schemas.inputDecodeSchema)(input).pipe(Effect.mapError((cause) => new ValidationError({
1734
- entityType,
1735
- operation: "upsert.decode",
1736
- cause,
1737
- })));
1741
+ // Encode user input → wire form (see `put` for strategy).
1742
+ const encodedInput = yield* encodeOrDecodeEncode(schemas.inputSchema, input, entityType, "upsert");
1738
1743
  // Hydrate refs: replace ID fields with full entity domain data
1739
- const decoded = hasRefs
1740
- ? yield* hydrateRefs(decodedInput)
1741
- : decodedInput;
1742
- const item = decoded;
1744
+ const encoded = hasRefs
1745
+ ? yield* hydrateRefs(encodedInput)
1746
+ : encodedInput;
1747
+ const item = encoded;
1743
1748
  // Compose primary key
1744
1749
  const primaryKey = composePrimaryKey(item);
1745
1750
  const marshalledKey = toAttributeMap(primaryKey);
@@ -1759,10 +1764,7 @@ const makeImpl = (config) => {
1759
1764
  systemColliders.add(systemFields.updatedAt);
1760
1765
  if (systemFields.versionCollision && systemFields.version)
1761
1766
  systemColliders.add(systemFields.version);
1762
- // Serialize date fields on the decoded item
1763
- if (hasDateFields) {
1764
- serializeDateFields(item);
1765
- }
1767
+ // `item` is already in wire-form via `Schema.encode(inputSchema)`.
1766
1768
  // All model fields (excluding PK composites, which are in the Key)
1767
1769
  for (const [attr, val] of Object.entries(item)) {
1768
1770
  if (pkComposites.has(attr))
@@ -1903,26 +1905,19 @@ const makeImpl = (config) => {
1903
1905
  const orderByField = timeSeriesConfig.orderBy;
1904
1906
  const ttlDuration = timeSeriesConfig.ttl;
1905
1907
  const appendInputSchema = schemas.appendInputSchema;
1906
- // Decode input via appendInputSchema
1907
- const decodedInput = yield* Schema.decodeUnknownEffect(appendInputSchema)(input).pipe(Effect.mapError((cause) => new ValidationError({
1908
- entityType,
1909
- operation: "append.decode",
1910
- cause,
1911
- })));
1912
- const decoded = decodedInput;
1913
- // Compose current-item primary key (pk + sk derived from PK/SK composites in decoded)
1908
+ // Encode user input via appendInputSchema (see `put` for strategy).
1909
+ const encodedInput = yield* encodeOrDecodeEncode(appendInputSchema, input, entityType, "append");
1910
+ const encoded = encodedInput;
1911
+ // Compose current-item primary key (pk + sk derived from PK/SK composites)
1914
1912
  const primary = config.indexes.primary;
1915
- const pkValue = KeyComposer.composePk(schema, entityType, primary, decoded);
1916
- const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary, decoded);
1913
+ const pkValue = KeyComposer.composePk(schema, entityType, primary, encoded);
1914
+ const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary, encoded);
1917
1915
  const marshalledKey = toAttributeMap({
1918
1916
  [primary.pk.field]: pkValue,
1919
1917
  [primary.sk.field]: currentSk,
1920
1918
  });
1921
- // Prepare domain-serialised values for the item build.
1922
- // Build a shallow copy and apply date serialization (per fieldEncodings)
1923
- // before marshalling. This mirrors the put path.
1924
- const serialisedInput = { ...decoded };
1925
- serializeDateFields(serialisedInput);
1919
+ // `encoded` is already in wire-form via Schema.encode — used directly.
1920
+ const serialisedInput = { ...encoded };
1926
1921
  // ---------- Build UpdateItem (scoped SET + CAS) ----------
1927
1922
  const setClauses = [];
1928
1923
  const names = {};
@@ -1951,7 +1946,7 @@ const makeImpl = (config) => {
1951
1946
  // never change during an append. Non-PK composites in appendInput that
1952
1947
  // overlap a GSI's composites trigger per-GSI resolution via indexPolicy.
1953
1948
  const nonPkAppendFields = {};
1954
- for (const [attr, val] of Object.entries(decoded)) {
1949
+ for (const [attr, val] of Object.entries(encoded)) {
1955
1950
  if (!pkCompositeSet.has(attr)) {
1956
1951
  nonPkAppendFields[attr] = val;
1957
1952
  }
@@ -1986,7 +1981,7 @@ const makeImpl = (config) => {
1986
1981
  },
1987
1982
  };
1988
1983
  }
1989
- const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, policyAwareIndexes, nonPkAppendFields, decoded);
1984
+ const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, policyAwareIndexes, nonPkAppendFields, encoded);
1990
1985
  for (const [field, value] of Object.entries(gsiUpdate.sets)) {
1991
1986
  const nameKey = `#a${counter}`;
1992
1987
  const valKey = `:a${counter}`;
@@ -2046,9 +2041,9 @@ const makeImpl = (config) => {
2046
2041
  const updateExpression = exprParts.join(" ");
2047
2042
  // ---------- Build Put of event item ----------
2048
2043
  const eventSk = KeyComposer.composeEventSk(currentSk, newObValue, schema.casing);
2049
- const eventItem = { ...decoded };
2050
- // Serialise date fields to storage format for the Put
2051
- serializeDateFields(eventItem);
2044
+ // `encoded` is already in wire-form via Schema.encode; no extra
2045
+ // serialisation step is required for the Put.
2046
+ const eventItem = { ...encoded };
2052
2047
  // Rename domain → DB names for the Put (matches put path)
2053
2048
  renameToDynamo(eventItem);
2054
2049
  // pk + sk (event)
@@ -2142,7 +2137,6 @@ const makeImpl = (config) => {
2142
2137
  const prefix = KeyComposer.composeEventSkPrefix(currentSk, schema.casing);
2143
2138
  const decodeHistory = (raw) => {
2144
2139
  renameFromDynamo(raw);
2145
- deserializeDateFields(raw);
2146
2140
  return Schema.decodeUnknownEffect(schemas.historyRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
2147
2141
  entityType,
2148
2142
  operation: "history.decode",
@@ -2393,18 +2387,21 @@ const makeImpl = (config) => {
2393
2387
  },
2394
2388
  });
2395
2389
  }
2396
- // Re-establish unique constraint sentinels
2390
+ // Re-establish unique constraint sentinels (sparse — fields that are
2391
+ // unset on the restored item don't get a sentinel; matches put semantics)
2392
+ const sentinelConstraints = [];
2397
2393
  if (config.unique && Object.keys(config.unique).length > 0) {
2398
2394
  for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
2399
- const fields = resolveUniqueFields(constraintDef);
2400
- const fieldValues = fields.map((f) => KeyComposer.serializeValue(restoredItem[f]));
2401
- const uniqueKey = DynamoSchema.composeUniqueKey(schema, entityType, constraintName, fieldValues);
2395
+ const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, restoredItem);
2396
+ if (!sentinel)
2397
+ continue;
2398
+ sentinelConstraints.push(constraintName);
2402
2399
  transactItems.push({
2403
2400
  Put: {
2404
2401
  TableName: tableName,
2405
2402
  Item: toAttributeMap({
2406
- [primary.pk.field]: uniqueKey.pk,
2407
- [primary.sk.field]: uniqueKey.sk,
2403
+ [primary.pk.field]: sentinel.key.pk,
2404
+ [primary.sk.field]: sentinel.key.sk,
2408
2405
  __edd_e__: `${entityType}._unique.${constraintName}`,
2409
2406
  _entity_pk: restoredKeys[primary.pk.field],
2410
2407
  _entity_sk: restoredKeys[primary.sk.field],
@@ -2424,17 +2421,20 @@ const makeImpl = (config) => {
2424
2421
  if (isAwsTransactionCancelled(err.cause)) {
2425
2422
  const reasons = err.cause.CancellationReasons;
2426
2423
  if (reasons && config.unique) {
2427
- // Sentinel items are after Delete + Put + optional snapshot
2424
+ // Sentinel Puts come after Delete + Put + optional snapshot, in the
2425
+ // order recorded in `sentinelConstraints` (sparse-skipped entries absent)
2428
2426
  const sentinelStart = isRetainEnabled() ? 3 : 2;
2429
- const constraintNames = Object.keys(config.unique);
2430
2427
  for (let i = sentinelStart; i < reasons.length; i++) {
2431
2428
  if (reasons[i]?.Code === "ConditionalCheckFailed") {
2432
- const constraintName = constraintNames[i - sentinelStart] ?? "unknown";
2429
+ const constraintName = sentinelConstraints[i - sentinelStart] ?? "unknown";
2433
2430
  const constraintDef = config.unique[constraintName];
2434
2431
  const uniqueFields = constraintDef ? resolveUniqueFields(constraintDef) : [];
2435
2432
  const fieldsRecord = {};
2436
2433
  for (const f of uniqueFields) {
2437
- fieldsRecord[f] = KeyComposer.serializeValue(restoredItem[f]);
2434
+ const v = restoredItem[f];
2435
+ if (v !== undefined && v !== null) {
2436
+ fieldsRecord[f] = KeyComposer.serializeValue(v);
2437
+ }
2438
2438
  }
2439
2439
  return new UniqueConstraintViolation({
2440
2440
  entityType,
@@ -2518,14 +2518,15 @@ const makeImpl = (config) => {
2518
2518
  }
2519
2519
  }
2520
2520
  if (entityItem) {
2521
- // Add sentinel keys to delete list
2521
+ // Add sentinel keys to delete list (sparse — fields that were unset
2522
+ // never had a sentinel written, so nothing to enqueue)
2522
2523
  for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
2523
- const fields = resolveUniqueFields(constraintDef);
2524
- const fieldValues = fields.map((f) => KeyComposer.serializeValue(entityItem[f]));
2525
- const uniqueKey = DynamoSchema.composeUniqueKey(schema, entityType, constraintName, fieldValues);
2524
+ const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, entityItem);
2525
+ if (!sentinel)
2526
+ continue;
2526
2527
  allItems.push(toAttributeMap({
2527
- [primary.pk.field]: uniqueKey.pk,
2528
- [primary.sk.field]: uniqueKey.sk,
2528
+ [primary.pk.field]: sentinel.key.pk,
2529
+ [primary.sk.field]: sentinel.key.sk,
2529
2530
  }));
2530
2531
  }
2531
2532
  }
@@ -2606,9 +2607,8 @@ const makeImpl = (config) => {
2606
2607
  identifier: resolvedIdentifier,
2607
2608
  timeSeries: config.timeSeries,
2608
2609
  _resolvedRefs: resolvedRefs,
2609
- /** @internal Full decode pipeline: rename + date deser + schema decode. Used by Batch/Aggregate. */
2610
+ /** @internal Full decode pipeline: rename + schema decode. Used by Batch/Aggregate. */
2610
2611
  _decodeRecord: decodeRecord,
2611
- _serializeDateFields: serializeDateFields,
2612
2612
  _attachPrototype: attachPrototype,
2613
2613
  _configure: (injectedSchema, injectedTableTag) => {
2614
2614
  schema = injectedSchema;