effect-dynamodb 1.3.3 → 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,7 @@ 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
29
  // ---------------------------------------------------------------------------
30
30
  // Sparse-aware unique sentinel composition
31
31
  // ---------------------------------------------------------------------------
@@ -61,6 +61,40 @@ const composeUniqueSentinel = (schema, entityType, constraintName, constraintDef
61
61
  };
62
62
  };
63
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
+ }))))));
97
+ // ---------------------------------------------------------------------------
64
98
  // Transaction limit pre-check (100-item DynamoDB limit)
65
99
  // ---------------------------------------------------------------------------
66
100
  /**
@@ -306,7 +340,15 @@ const makeImpl = (config) => {
306
340
  }
307
341
  // Resolve identifier field name from model annotation
308
342
  const resolvedIdentifier = getIdentifierField(config.model)?.name;
309
- 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);
310
352
  // schema and tableTag are injected via _configure() when the entity is registered
311
353
  // on a Table and bound through DynamoClient.make(). They are captured by operation
312
354
  // closures and resolved at runtime (inside Effects), not at definition time.
@@ -315,37 +357,10 @@ const makeImpl = (config) => {
315
357
  let tableTag;
316
358
  const entityType = config.entityType;
317
359
  const entityVersion = 1;
318
- // ---------------------------------------------------------------------------
319
- // DynamoEncoding map: field name → encoding for date-annotated fields
320
- // Merges schema annotations with ConfiguredModel storage overrides.
321
- // ---------------------------------------------------------------------------
322
- const fieldEncodings = {};
323
- for (const [fieldName, fieldSchema] of Object.entries(modelFields)) {
324
- const encoding = getEncoding(fieldSchema);
325
- if (encoding) {
326
- fieldEncodings[fieldName] = encoding;
327
- }
328
- else {
329
- // Auto-detect standard Effect date schemas (Pattern B: pure model)
330
- const inferred = inferDefaultEncoding(fieldSchema);
331
- if (inferred)
332
- fieldEncodings[fieldName] = inferred;
333
- }
334
- }
335
- // Apply ConfiguredModel storage overrides (takes precedence over schema annotations)
336
- for (const [fieldName, attrConfig] of Object.entries(configuredAttributes)) {
337
- if (attrConfig.encoding) {
338
- const existing = fieldEncodings[fieldName];
339
- fieldEncodings[fieldName] = {
340
- storage: attrConfig.encoding.storage,
341
- domain: existing?.domain ?? attrConfig.encoding.domain,
342
- };
343
- }
344
- }
345
- // Note: System timestamp encodings are NOT added to fieldEncodings.
346
- // generateTimestamp() handles serialization directly for timestamps.
347
- // This avoids double-serialization in serializeDateFields.
348
- 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.
349
364
  // ---------------------------------------------------------------------------
350
365
  // Field renaming: domain name → DynamoDB attribute name (from ConfiguredModel)
351
366
  // ---------------------------------------------------------------------------
@@ -385,32 +400,6 @@ const makeImpl = (config) => {
385
400
  };
386
401
  /** Resolve a domain field name to its DynamoDB attribute name. */
387
402
  const resolveDbName = (domainName) => fieldRenames[domainName] ?? domainName;
388
- /**
389
- * Convert domain date values to storage primitives for DynamoEncoding-annotated fields.
390
- * Called before toAttributeMap (put path).
391
- */
392
- const serializeDateFields = (item) => {
393
- if (!hasDateFields)
394
- return;
395
- for (const [field, encoding] of Object.entries(fieldEncodings)) {
396
- if (field in item && item[field] != null) {
397
- item[field] = serializeDateForDynamo(item[field], encoding);
398
- }
399
- }
400
- };
401
- /**
402
- * Convert storage primitives back to domain values for DynamoEncoding-annotated fields.
403
- * Called after fromAttributeMap (get path).
404
- */
405
- const deserializeDateFields = (raw) => {
406
- if (!hasDateFields)
407
- return;
408
- for (const [field, encoding] of Object.entries(fieldEncodings)) {
409
- if (field in raw && raw[field] != null) {
410
- raw[field] = deserializeDateFromDynamo(raw[field], encoding);
411
- }
412
- }
413
- };
414
403
  // ---------------------------------------------------------------------------
415
404
  // Helpers
416
405
  // ---------------------------------------------------------------------------
@@ -426,7 +415,6 @@ const makeImpl = (config) => {
426
415
  const attachPrototype = (decoded) => isSchemaClass ? Object.assign(Object.create(rawModel.prototype), decoded) : decoded;
427
416
  const decodeRecord = (raw) => {
428
417
  renameFromDynamo(raw);
429
- deserializeDateFields(raw);
430
418
  return Schema.decodeUnknownEffect(schemas.recordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
431
419
  entityType,
432
420
  operation: "decode",
@@ -437,15 +425,11 @@ const makeImpl = (config) => {
437
425
  const decodeAs = (raw, marshalled, mode) => {
438
426
  if (mode === "native")
439
427
  return Effect.succeed(marshalled);
440
- // 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.).
441
430
  renameFromDynamo(raw);
442
- // Convert date storage primitives to domain values before Schema decode.
443
- // recordSchema/itemSchema use fromSelf variants for annotated fields,
444
- // so they accept domain values directly. modelSchema also uses fromSelf
445
- // for the decode path after deserialization.
446
- deserializeDateFields(raw);
447
431
  const targetSchema = mode === "model"
448
- ? schemas.modelDecodeSchema
432
+ ? schemas.modelSchema
449
433
  : mode === "record"
450
434
  ? schemas.recordSchema
451
435
  : schemas.itemSchema;
@@ -461,33 +445,26 @@ const makeImpl = (config) => {
461
445
  };
462
446
  const nowIso = () => new Date().toISOString();
463
447
  /**
464
- * Generate a timestamp value appropriate for the field's encoding.
448
+ * Generate a timestamp value as a wire primitive ready for marshalling.
465
449
  * Default (no encoding): ISO string. Custom encoding: serialized primitive.
466
450
  *
467
- * Used for non-colliding system fields (field not declared in model) — the
468
- * value is written straight into the DynamoDB item and is NOT routed through
469
- * `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`.
470
453
  */
471
454
  const generateTimestamp = (encoding) => {
472
455
  if (!encoding)
473
456
  return nowIso();
474
- const now = new Date();
475
- return serializeDateForDynamo(now, encoding);
476
- };
477
- /**
478
- * Generate a timestamp as a domain value (DateTime.Utc / DateTime.Zoned / Date),
479
- * matching the field's declared domain. Used for system fields that COLLIDE
480
- * with a model-declared field — the value flows through `serializeDateFields`
481
- * (which requires a domain value) alongside any user-supplied override.
482
- */
483
- const generateDomainTimestamp = (encoding) => {
484
- switch (encoding.domain) {
485
- case "DateTime.Utc":
486
- return DateTime.makeUnsafe(new Date());
487
- case "DateTime.Zoned":
488
- return DateTime.makeZonedUnsafe(new Date(), { timeZone: "UTC" });
489
- case "Date":
490
- 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);
491
468
  }
492
469
  };
493
470
  // ---------------------------------------------------------------------------
@@ -777,41 +754,41 @@ const makeImpl = (config) => {
777
754
  const put = (input) => new EntityPutImpl((mode, opts) => Effect.gen(function* () {
778
755
  const client = yield* DynamoClient;
779
756
  const { name: tableName } = yield* tableTag;
780
- // Decode input
781
- const decodedInput = yield* Schema.decodeUnknownEffect(schemas.inputDecodeSchema)(input).pipe(Effect.mapError((cause) => new ValidationError({
782
- entityType,
783
- operation: "put.decode",
784
- cause,
785
- })));
786
- // Compose all keys BEFORE ref hydration (ID fields are still present)
787
- 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);
788
773
  // Hydrate refs: replace ID fields with full entity domain data
789
- const decoded = hasRefs
790
- ? yield* hydrateRefs(decodedInput)
791
- : decodedInput;
792
- // Build the DynamoDB item
793
- 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 };
794
779
  // Add system fields. When a timestamp field collides with a
795
780
  // model-declared field, the user may have supplied their own value
796
- // (see `inputDecodeSchema` — optional on collision). Respect their
797
- // value if present; otherwise generate. In the collision path the
798
- // value is a DOMAIN value (DateTime.Utc / Date / ...) so it flows
799
- // through `serializeDateFields` downstream. Non-colliding fields
800
- // 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.
801
784
  if (systemFields.createdAt) {
802
785
  if (item[systemFields.createdAt] === undefined) {
803
- item[systemFields.createdAt] =
804
- systemFields.createdAtCollision && systemFields.createdAtEncoding
805
- ? generateDomainTimestamp(systemFields.createdAtEncoding)
806
- : generateTimestamp(systemFields.createdAtEncoding);
786
+ item[systemFields.createdAt] = generateTimestamp(systemFields.createdAtEncoding);
807
787
  }
808
788
  }
809
789
  if (systemFields.updatedAt) {
810
790
  if (item[systemFields.updatedAt] === undefined) {
811
- item[systemFields.updatedAt] =
812
- systemFields.updatedAtCollision && systemFields.updatedAtEncoding
813
- ? generateDomainTimestamp(systemFields.updatedAtEncoding)
814
- : generateTimestamp(systemFields.updatedAtEncoding);
791
+ item[systemFields.updatedAt] = generateTimestamp(systemFields.updatedAtEncoding);
815
792
  }
816
793
  }
817
794
  if (systemFields.version)
@@ -820,8 +797,6 @@ const makeImpl = (config) => {
820
797
  item.__edd_e__ = entityType;
821
798
  // Apply composed keys
822
799
  Object.assign(item, keys);
823
- // Convert date fields from domain values to storage primitives
824
- serializeDateFields(item);
825
800
  // Rename domain fields to DynamoDB attribute names
826
801
  renameToDynamo(item);
827
802
  const marshalledItem = toAttributeMap(item);
@@ -851,7 +826,7 @@ const makeImpl = (config) => {
851
826
  const sentinelConstraints = [];
852
827
  if (hasUniqueConstraints) {
853
828
  for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
854
- const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, decoded);
829
+ const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, encoded);
855
830
  if (!sentinel)
856
831
  continue;
857
832
  sentinelConstraints.push(constraintName);
@@ -894,7 +869,7 @@ const makeImpl = (config) => {
894
869
  if (opts.condition && reasons[0]?.Code === "ConditionalCheckFailed") {
895
870
  return new ConditionalCheckFailed({
896
871
  entityType,
897
- key: decoded,
872
+ key: encoded,
898
873
  });
899
874
  }
900
875
  // Sentinel items start at index 1, in the order recorded in
@@ -908,7 +883,7 @@ const makeImpl = (config) => {
908
883
  : [];
909
884
  const fieldsRecord = {};
910
885
  for (const f of uniqueFields) {
911
- const v = decoded[f];
886
+ const v = encoded[f];
912
887
  if (v !== undefined && v !== null) {
913
888
  fieldsRecord[f] = KeyComposer.serializeValue(v);
914
889
  }
@@ -940,7 +915,10 @@ const makeImpl = (config) => {
940
915
  }
941
916
  yield* client.putItem(putInput).pipe(Effect.mapError((err) => {
942
917
  if (opts.condition && isAwsConditionalCheckFailed(err.cause)) {
943
- return new ConditionalCheckFailed({ entityType, key: decoded });
918
+ return new ConditionalCheckFailed({
919
+ entityType,
920
+ key: encoded,
921
+ });
944
922
  }
945
923
  return err;
946
924
  }));
@@ -1025,17 +1003,13 @@ const makeImpl = (config) => {
1025
1003
  operation: "update.decodeKey",
1026
1004
  cause,
1027
1005
  })));
1028
- // Decode updates
1029
- const decodedUpdates = yield* Schema.decodeUnknownEffect(schemas.updateDecodeSchema)(updates ?? {}).pipe(Effect.mapError((cause) => new ValidationError({
1030
- entityType,
1031
- operation: "update.decodeUpdates",
1032
- cause,
1033
- })));
1006
+ // Encode update payload → wire form (see `put` for the strategy).
1007
+ const encodedUpdates = yield* encodeOrDecodeEncode(schemas.updateSchema, updates ?? {}, entityType, "update");
1034
1008
  // Hydrate refs in updates: for any ${field}Id present, fetch and embed
1035
- let hydratedUpdates = decodedUpdates;
1009
+ let hydratedUpdates = encodedUpdates;
1036
1010
  if (hasRefs) {
1037
- const refsToHydrate = resolvedRefs.filter((ref) => ref.idFieldName in decodedUpdates &&
1038
- decodedUpdates[ref.idFieldName] !==
1011
+ const refsToHydrate = resolvedRefs.filter((ref) => ref.idFieldName in encodedUpdates &&
1012
+ encodedUpdates[ref.idFieldName] !==
1039
1013
  undefined);
1040
1014
  if (refsToHydrate.length > 0) {
1041
1015
  hydratedUpdates = yield* hydrateRefs(hydratedUpdates, refsToHydrate);
@@ -1083,24 +1057,17 @@ const makeImpl = (config) => {
1083
1057
  });
1084
1058
  }
1085
1059
  // Build new item: merge current + updates + rich ops + recompose keys.
1086
- //
1087
- // Pre-serialize domain date values from `hydratedUpdates` (decoded
1088
- // via `fromSelf`, so DateTime.Utc / Date instances) into storage
1089
- // primitives BEFORE merging into `newItem`. `currentRaw` is already
1090
- // storage-primitive (it came straight from `fromAttributeMap`), so
1091
- // we need both halves in the same shape — otherwise the final
1092
- // `toAttributeMap(newItem)` marshals the DateTime class instances
1093
- // as DynamoDB Maps (via AWS SDK's `convertClassInstanceToMap`),
1094
- // which corrupts the write and makes subsequent reads fail to
1095
- // decode.
1096
- const serializedRetainUpdates = {
1097
- ...hydratedUpdates,
1098
- };
1099
- serializeDateFields(serializedRetainUpdates);
1100
- 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 = {
1101
1066
  ...currentRaw,
1102
1067
  };
1103
- for (const [attr, val] of Object.entries(serializedRetainUpdates)) {
1068
+ renameFromDynamo(currentDomainItem);
1069
+ const newItem = { ...currentDomainItem };
1070
+ for (const [attr, val] of Object.entries(hydratedUpdates)) {
1104
1071
  if (val !== undefined)
1105
1072
  newItem[attr] = val;
1106
1073
  }
@@ -1138,24 +1105,21 @@ const makeImpl = (config) => {
1138
1105
  }
1139
1106
  // Increment version and update timestamp. If the caller supplied a
1140
1107
  // value for `updatedAt` (allowed when the field collides with a
1141
- // model-declared field), respect it; else generate. Read from the
1142
- // pre-serialized map so both branches produce a storage primitive
1143
- // (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.
1144
1110
  const newVersion = currentVersion + 1;
1145
1111
  if (systemFields.version)
1146
1112
  newItem[systemFields.version] = newVersion;
1147
1113
  if (systemFields.updatedAt) {
1148
- const userSupplied = serializedRetainUpdates[systemFields.updatedAt];
1114
+ const userSupplied = hydratedUpdates[systemFields.updatedAt];
1149
1115
  newItem[systemFields.updatedAt] =
1150
1116
  userSupplied !== undefined
1151
1117
  ? userSupplied
1152
1118
  : generateTimestamp(systemFields.updatedAtEncoding);
1153
1119
  }
1154
- // Convert DynamoDB attribute names to domain names for key composition
1155
- // and sentinel value comparison (newItem was built from currentRaw which
1156
- // uses DynamoDB names; composites and unique fields use domain names)
1157
- renameFromDynamo(newItem);
1158
- // 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.
1159
1123
  const newKeys = composeAllKeys(newItem);
1160
1124
  Object.assign(newItem, newKeys);
1161
1125
  const sentinelRotations = [];
@@ -1320,18 +1284,17 @@ const makeImpl = (config) => {
1320
1284
  const names = {};
1321
1285
  const values = {};
1322
1286
  let counter = 0;
1323
- // Serialize any domain-value date fields in updates to storage primitives.
1324
- // Without this, a `.set({ occurredAt: DateTime.Utc })` would marshal as a
1325
- // class-instance map.
1326
- const serializedUpdates = { ...hydratedUpdates };
1327
- 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;
1328
1291
  // System-colliding updatedAt is handled by the system-field block below.
1329
- // createdAt and version are already excluded by `updateDecodeSchema`.
1292
+ // createdAt and version are already excluded by `updateSchema`.
1330
1293
  const updateSystemColliders = new Set();
1331
1294
  if (systemFields.updatedAtCollision && systemFields.updatedAt)
1332
1295
  updateSystemColliders.add(systemFields.updatedAt);
1333
1296
  // Add user-provided updates
1334
- for (const [attr, val] of Object.entries(serializedUpdates)) {
1297
+ for (const [attr, val] of Object.entries(encodedUpdatesMap)) {
1335
1298
  if (val === undefined)
1336
1299
  continue;
1337
1300
  if (updateSystemColliders.has(attr))
@@ -1343,10 +1306,11 @@ const makeImpl = (config) => {
1343
1306
  setClauses.push(`${nameKey} = ${valKey}`);
1344
1307
  counter++;
1345
1308
  }
1346
- // Add updatedAt timestamp. User-supplied value wins (pre-serialized
1347
- // 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.
1348
1312
  if (systemFields.updatedAt) {
1349
- const userSupplied = serializedUpdates[systemFields.updatedAt];
1313
+ const userSupplied = encodedUpdatesMap[systemFields.updatedAt];
1350
1314
  const nameKey = `#u${counter}`;
1351
1315
  const valKey = `:u${counter}`;
1352
1316
  names[nameKey] = systemFields.updatedAt;
@@ -1774,17 +1738,13 @@ const makeImpl = (config) => {
1774
1738
  const upsert = (input) => new EntityPutImpl((mode, opts) => Effect.gen(function* () {
1775
1739
  const client = yield* DynamoClient;
1776
1740
  const { name: tableName } = yield* tableTag;
1777
- // Decode input
1778
- const decodedInput = yield* Schema.decodeUnknownEffect(schemas.inputDecodeSchema)(input).pipe(Effect.mapError((cause) => new ValidationError({
1779
- entityType,
1780
- operation: "upsert.decode",
1781
- cause,
1782
- })));
1741
+ // Encode user input → wire form (see `put` for strategy).
1742
+ const encodedInput = yield* encodeOrDecodeEncode(schemas.inputSchema, input, entityType, "upsert");
1783
1743
  // Hydrate refs: replace ID fields with full entity domain data
1784
- const decoded = hasRefs
1785
- ? yield* hydrateRefs(decodedInput)
1786
- : decodedInput;
1787
- const item = decoded;
1744
+ const encoded = hasRefs
1745
+ ? yield* hydrateRefs(encodedInput)
1746
+ : encodedInput;
1747
+ const item = encoded;
1788
1748
  // Compose primary key
1789
1749
  const primaryKey = composePrimaryKey(item);
1790
1750
  const marshalledKey = toAttributeMap(primaryKey);
@@ -1804,10 +1764,7 @@ const makeImpl = (config) => {
1804
1764
  systemColliders.add(systemFields.updatedAt);
1805
1765
  if (systemFields.versionCollision && systemFields.version)
1806
1766
  systemColliders.add(systemFields.version);
1807
- // Serialize date fields on the decoded item
1808
- if (hasDateFields) {
1809
- serializeDateFields(item);
1810
- }
1767
+ // `item` is already in wire-form via `Schema.encode(inputSchema)`.
1811
1768
  // All model fields (excluding PK composites, which are in the Key)
1812
1769
  for (const [attr, val] of Object.entries(item)) {
1813
1770
  if (pkComposites.has(attr))
@@ -1948,26 +1905,19 @@ const makeImpl = (config) => {
1948
1905
  const orderByField = timeSeriesConfig.orderBy;
1949
1906
  const ttlDuration = timeSeriesConfig.ttl;
1950
1907
  const appendInputSchema = schemas.appendInputSchema;
1951
- // Decode input via appendInputSchema
1952
- const decodedInput = yield* Schema.decodeUnknownEffect(appendInputSchema)(input).pipe(Effect.mapError((cause) => new ValidationError({
1953
- entityType,
1954
- operation: "append.decode",
1955
- cause,
1956
- })));
1957
- const decoded = decodedInput;
1958
- // 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)
1959
1912
  const primary = config.indexes.primary;
1960
- const pkValue = KeyComposer.composePk(schema, entityType, primary, decoded);
1961
- 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);
1962
1915
  const marshalledKey = toAttributeMap({
1963
1916
  [primary.pk.field]: pkValue,
1964
1917
  [primary.sk.field]: currentSk,
1965
1918
  });
1966
- // Prepare domain-serialised values for the item build.
1967
- // Build a shallow copy and apply date serialization (per fieldEncodings)
1968
- // before marshalling. This mirrors the put path.
1969
- const serialisedInput = { ...decoded };
1970
- serializeDateFields(serialisedInput);
1919
+ // `encoded` is already in wire-form via Schema.encode — used directly.
1920
+ const serialisedInput = { ...encoded };
1971
1921
  // ---------- Build UpdateItem (scoped SET + CAS) ----------
1972
1922
  const setClauses = [];
1973
1923
  const names = {};
@@ -1996,7 +1946,7 @@ const makeImpl = (config) => {
1996
1946
  // never change during an append. Non-PK composites in appendInput that
1997
1947
  // overlap a GSI's composites trigger per-GSI resolution via indexPolicy.
1998
1948
  const nonPkAppendFields = {};
1999
- for (const [attr, val] of Object.entries(decoded)) {
1949
+ for (const [attr, val] of Object.entries(encoded)) {
2000
1950
  if (!pkCompositeSet.has(attr)) {
2001
1951
  nonPkAppendFields[attr] = val;
2002
1952
  }
@@ -2031,7 +1981,7 @@ const makeImpl = (config) => {
2031
1981
  },
2032
1982
  };
2033
1983
  }
2034
- const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, policyAwareIndexes, nonPkAppendFields, decoded);
1984
+ const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, policyAwareIndexes, nonPkAppendFields, encoded);
2035
1985
  for (const [field, value] of Object.entries(gsiUpdate.sets)) {
2036
1986
  const nameKey = `#a${counter}`;
2037
1987
  const valKey = `:a${counter}`;
@@ -2091,9 +2041,9 @@ const makeImpl = (config) => {
2091
2041
  const updateExpression = exprParts.join(" ");
2092
2042
  // ---------- Build Put of event item ----------
2093
2043
  const eventSk = KeyComposer.composeEventSk(currentSk, newObValue, schema.casing);
2094
- const eventItem = { ...decoded };
2095
- // Serialise date fields to storage format for the Put
2096
- 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 };
2097
2047
  // Rename domain → DB names for the Put (matches put path)
2098
2048
  renameToDynamo(eventItem);
2099
2049
  // pk + sk (event)
@@ -2187,7 +2137,6 @@ const makeImpl = (config) => {
2187
2137
  const prefix = KeyComposer.composeEventSkPrefix(currentSk, schema.casing);
2188
2138
  const decodeHistory = (raw) => {
2189
2139
  renameFromDynamo(raw);
2190
- deserializeDateFields(raw);
2191
2140
  return Schema.decodeUnknownEffect(schemas.historyRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
2192
2141
  entityType,
2193
2142
  operation: "history.decode",
@@ -2658,9 +2607,8 @@ const makeImpl = (config) => {
2658
2607
  identifier: resolvedIdentifier,
2659
2608
  timeSeries: config.timeSeries,
2660
2609
  _resolvedRefs: resolvedRefs,
2661
- /** @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. */
2662
2611
  _decodeRecord: decodeRecord,
2663
- _serializeDateFields: serializeDateFields,
2664
2612
  _attachPrototype: attachPrototype,
2665
2613
  _configure: (injectedSchema, injectedTableTag) => {
2666
2614
  schema = injectedSchema;