@effect-dynamodb/schema 1.21.0 → 1.23.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.
@@ -5,7 +5,8 @@
5
5
  */
6
6
  import { DateTime, Effect, Redacted, Schema, SchemaAST, SchemaGetter, SchemaIssue } from "effect";
7
7
  import { getEncoding, isConfiguredModel, isHidden, } from "../DynamoModel.js";
8
- import { firstTypeParameterAst, getSchemaFields, isSchemaLike, transformWireKind as transformWireKindImpl, } from "./SchemaAccessors.js";
8
+ import { firstTypeParameterAst, getSchemaFields, isSchemaLike, numericTypeWithStringEncoding, transformWireKind as transformWireKindImpl, } from "./SchemaAccessors.js";
9
+ import { parseZonedIso } from "./ZonedIso.js";
9
10
  /**
10
11
  * Resolve the effective DynamoEncoding for a model-declared field, applying
11
12
  * ConfiguredModel storage overrides on top of schema-level annotations.
@@ -282,6 +283,54 @@ export const isSelfDateSchema = (schema) => {
282
283
  // type constructor matches one of the supported date domains.
283
284
  return inferDefaultEncoding(schema) !== undefined;
284
285
  };
286
+ /**
287
+ * Whether a value of this schema can hold something, AT ANY DEPTH, whose stored
288
+ * form differs from its domain form (#133):
289
+ *
290
+ * - any node carrying an encoding transformation — a leaf transform
291
+ * (`DateTimeUtcFromString`, `BigIntFromString`, …), a `Schema.Class`, a
292
+ * `withDecodingDefault` chain;
293
+ * - a self date (`Schema.DateTimeUtc` / `DateTimeZoned` / `Date`), whose domain
294
+ * value marshals to a map rather than its wire primitive;
295
+ * - a `Redacted` declaration.
296
+ *
297
+ * Walks through containers (`Arrays`, `Objects`, `Union` — which covers
298
+ * `Schema.optional` and `NullOr` — and `Suspend`); an `optionalKey` wrapper is
299
+ * the inner AST with an optional context, so it needs no case of its own. The
300
+ * tolerant `substituteSchemaDeep` walks the same containers (see
301
+ * `walkedContainer`), except `Suspend`: a recursive schema keeps its own
302
+ * codec, so the aggregate encodes it through its own `encode`.
303
+ *
304
+ * `hasEncodingTransformation` asks the same question of the TOP-LEVEL node
305
+ * only, and an `Arrays` / `Objects` node never carries an encoding itself — so a
306
+ * `Schema.Array(DateTimeUtcFromString)` attribute was treated as identity and
307
+ * its `DateTime`s were marshalled as maps.
308
+ */
309
+ export const containsWireTransform = (schema) => astContainsWireTransform(schema.ast, new Set());
310
+ const astContainsWireTransform = (ast, seen) => {
311
+ if (seen.has(ast))
312
+ return false;
313
+ seen.add(ast);
314
+ if (ast.encoding !== undefined)
315
+ return true;
316
+ if (SchemaAST.isDeclaration(ast)) {
317
+ const resolved = SchemaAST.resolve(ast);
318
+ const rep = resolved?.representation;
319
+ return matchDateRepresentation(resolved) !== undefined || rep?.id === "effect/schema/Redacted";
320
+ }
321
+ const walk = (child) => astContainsWireTransform(child, seen);
322
+ if (SchemaAST.isArrays(ast))
323
+ return ast.elements.some(walk) || ast.rest.some(walk);
324
+ if (SchemaAST.isObjects(ast)) {
325
+ return (ast.propertySignatures.some((ps) => walk(ps.type)) ||
326
+ ast.indexSignatures.some((is) => walk(is.type)));
327
+ }
328
+ if (SchemaAST.isUnion(ast))
329
+ return ast.types.some(walk);
330
+ if (SchemaAST.isSuspend(ast))
331
+ return walk(ast.thunk());
332
+ return false;
333
+ };
285
334
  /**
286
335
  * Returns true when the schema is a transform schema with a date typeConstructor
287
336
  * (e.g. `Schema.DateTimeUtcFromString`, `DynamoModel.DateEpochSeconds`).
@@ -295,6 +344,69 @@ export const isDateTransform = (schema) => {
295
344
  // ---------------------------------------------------------------------------
296
345
  // Self-date schema substitution
297
346
  // ---------------------------------------------------------------------------
347
+ /** A plain record — what unmarshalling produces, as opposed to a class instance. */
348
+ const isPlainObject = (value) => {
349
+ if (value === null || typeof value !== "object")
350
+ return false;
351
+ const proto = Object.getPrototypeOf(value);
352
+ return proto === Object.prototype || proto === null;
353
+ };
354
+ /**
355
+ * A real `DateTime` instance. `DateTime.isDateTime` checks for the type-id
356
+ * property only, so a plain object read back from a marshalled instance passes
357
+ * it too (#133) — and is then neither `Equal` to the instant nor usable with the
358
+ * `DateTime` API that relies on the prototype.
359
+ */
360
+ const isGenuineDateTime = (value) => DateTime.isDateTime(value) && !isPlainObject(value);
361
+ const reviveTimeZone = (zone) => {
362
+ if (!isPlainObject(zone))
363
+ return undefined;
364
+ try {
365
+ if (zone._tag === "Named" && typeof zone.id === "string") {
366
+ return DateTime.zoneMakeNamedUnsafe(zone.id);
367
+ }
368
+ if (zone._tag === "Offset" && typeof zone.offset === "number") {
369
+ return DateTime.zoneMakeOffset(zone.offset);
370
+ }
371
+ }
372
+ catch {
373
+ return undefined;
374
+ }
375
+ return undefined;
376
+ };
377
+ /**
378
+ * Rebuild a `DateTime` from a MARSHALLED instance (#133).
379
+ *
380
+ * Before #133 an aggregate stored some `DateTime`s by marshalling the instance
381
+ * itself, which keeps its own enumerable properties:
382
+ *
383
+ * - `Utc`: `{ epochMilliseconds, <type-id>, _tag: "Utc" }`
384
+ * - `Zoned`: the same plus `zone`, itself `{ id, _tag: "Named" }` or
385
+ * `{ offset, _tag: "Offset" }` (a named zone's `Intl` formatter marshals to
386
+ * an empty map and is not needed).
387
+ *
388
+ * The type-id key is `~effect/time/DateTime` on effect 4.0.0-rc and
389
+ * `~effect/DateTime` on 4.0.0, so it is deliberately NOT inspected: the instant
390
+ * and the tag are what identify the value, and they are all a `DateTime` holds.
391
+ * A zoned value is rebuilt only when its zone is recoverable.
392
+ *
393
+ * Returns `undefined` for anything else — including a genuine `DateTime`, which
394
+ * needs no rebuilding.
395
+ */
396
+ export const reviveMarshalledDateTime = (value) => {
397
+ if (!isPlainObject(value))
398
+ return undefined;
399
+ const epochMs = value.epochMilliseconds;
400
+ if (typeof epochMs !== "number" || !Number.isFinite(epochMs))
401
+ return undefined;
402
+ if (value._tag === "Utc")
403
+ return DateTime.makeUnsafe(epochMs);
404
+ if (value._tag === "Zoned") {
405
+ const timeZone = reviveTimeZone(value.zone);
406
+ return timeZone === undefined ? undefined : DateTime.makeZonedUnsafe(epochMs, { timeZone });
407
+ }
408
+ return undefined;
409
+ };
298
410
  /**
299
411
  * Convert any input value (string / number / DateTime / Date) to the wire
300
412
  * primitive for a given encoding. Used by date substitutes' `encode` path.
@@ -305,7 +417,8 @@ export const isDateTransform = (schema) => {
305
417
  * - `epochMs` storage → integer milliseconds since the Unix epoch
306
418
  * - `epochSeconds` storage → integer seconds since the Unix epoch (TTL format)
307
419
  */
308
- const toWirePrimitive = (value, encoding) => {
420
+ const toWirePrimitive = (input, encoding) => {
421
+ const value = reviveMarshalledDateTime(input) ?? input;
309
422
  let epochMs;
310
423
  let zoned;
311
424
  if (DateTime.isDateTime(value)) {
@@ -356,7 +469,9 @@ const toWirePrimitive = (value, encoding) => {
356
469
  *
357
470
  * @internal
358
471
  */
359
- export const buildDateTransform = (encoding) => {
472
+ export const buildDateTransform = (encoding, options) => {
473
+ const strict = options?.strictWireKind === true;
474
+ const canonicalOnly = options?.canonicalOnly === true;
360
475
  const targetSchema = (() => {
361
476
  switch (encoding.domain) {
362
477
  case "DateTime.Utc":
@@ -367,9 +482,59 @@ export const buildDateTransform = (encoding) => {
367
482
  return Schema.Date;
368
483
  }
369
484
  })();
370
- const liftToDomain = (value) => {
371
- if (DateTime.isDateTime(value))
485
+ const fromDateTime = (value) => {
486
+ switch (encoding.domain) {
487
+ case "DateTime.Utc":
488
+ return DateTime.toUtc(value);
489
+ case "DateTime.Zoned":
490
+ return DateTime.isZoned(value)
491
+ ? value
492
+ : DateTime.makeZonedUnsafe(DateTime.toEpochMillis(value), { timeZone: "UTC" });
493
+ case "Date":
494
+ return DateTime.toDateUtc(value);
495
+ }
496
+ };
497
+ /** Under `strictWireKind`: whether a DateTime is of this encoding's own domain. */
498
+ const ownDomain = (value) => encoding.domain === "DateTime.Utc"
499
+ ? DateTime.isUtc(value)
500
+ : encoding.domain === "DateTime.Zoned" && DateTime.isZoned(value);
501
+ const liftStrict = (value) => {
502
+ const revived = reviveMarshalledDateTime(value);
503
+ if (revived !== undefined) {
504
+ if (ownDomain(revived))
505
+ return revived;
506
+ throw new Error("[effect-dynamodb] marshalled DateTime of another domain");
507
+ }
508
+ if (isGenuineDateTime(value) && ownDomain(value))
372
509
  return value;
510
+ if (value instanceof Date && encoding.domain === "Date" && !Number.isNaN(value.getTime())) {
511
+ return value;
512
+ }
513
+ if (typeof value === (encoding.storage === "string" ? "string" : "number")) {
514
+ const lifted = liftToDomain(value);
515
+ if (lifted instanceof Date && Number.isNaN(lifted.getTime())) {
516
+ throw new Error("[effect-dynamodb] invalid date");
517
+ }
518
+ if (canonicalOnly && toWirePrimitive(lifted, encoding) !== value) {
519
+ throw new Error("[effect-dynamodb] not this date member's canonical wire form");
520
+ }
521
+ return lifted;
522
+ }
523
+ throw new Error("[effect-dynamodb] not this date member's wire or domain form");
524
+ };
525
+ const liftToDomain = (value) => {
526
+ // A marshalled `DateTime` instance (#133) — rebuilt, whatever type-id key
527
+ // the Effect version that wrote it used.
528
+ const revived = reviveMarshalledDateTime(value);
529
+ if (revived !== undefined)
530
+ return fromDateTime(revived);
531
+ if (DateTime.isDateTime(value)) {
532
+ if (isGenuineDateTime(value))
533
+ return value;
534
+ // Duck-types as a DateTime but carries no recoverable instant: refuse it
535
+ // rather than hand a plain object to the domain.
536
+ throw new Error("[effect-dynamodb] unrecoverable marshalled DateTime");
537
+ }
373
538
  if (value instanceof Date) {
374
539
  switch (encoding.domain) {
375
540
  case "DateTime.Utc":
@@ -384,15 +549,9 @@ export const buildDateTransform = (encoding) => {
384
549
  switch (encoding.domain) {
385
550
  case "DateTime.Utc":
386
551
  return DateTime.makeUnsafe(value);
387
- case "DateTime.Zoned": {
388
- const match = value.match(/^(.+)\[(.+)\]$/);
389
- if (match) {
390
- const utc = DateTime.makeUnsafe(match[1]);
391
- return DateTime.makeZonedUnsafe(utc, { timeZone: match[2] });
392
- }
393
- const utc = DateTime.makeUnsafe(value);
394
- return DateTime.makeZonedUnsafe(utc, { timeZone: "UTC" });
395
- }
552
+ case "DateTime.Zoned":
553
+ // Named and offset zones are both rebuilt as written (#133).
554
+ return parseZonedIso(value);
396
555
  case "Date":
397
556
  return new Date(value);
398
557
  }
@@ -413,7 +572,7 @@ export const buildDateTransform = (encoding) => {
413
572
  return Schema.Any.pipe(Schema.decodeTo(targetSchema, {
414
573
  decode: SchemaGetter.transformEffect((value) => {
415
574
  try {
416
- return Effect.succeed(liftToDomain(value));
575
+ return Effect.succeed(strict ? liftStrict(value) : liftToDomain(value));
417
576
  }
418
577
  catch {
419
578
  return Effect.fail(new SchemaIssue.InvalidType(Schema.Any.ast, value));
@@ -510,14 +669,31 @@ const buildTolerantTransform = (schema) => {
510
669
  const decodeWire = Schema.decodeUnknownOption(codec);
511
670
  const validateDomain = Schema.decodeUnknownOption(typeSide);
512
671
  const encodeDomain = Schema.encodeUnknownOption(codec);
672
+ const bigintDomain = SchemaAST.isBigInt(SchemaAST.toType(schema.ast));
513
673
  return Schema.Any.pipe(Schema.decodeTo(typeSide, {
514
674
  decode: SchemaGetter.transformEffect((value) => {
515
675
  const fromWire = decodeWire(value);
516
676
  if (fromWire._tag === "Some")
517
677
  return Effect.succeed(fromWire.value);
518
- const alreadyDomain = validateDomain(value);
678
+ // A marshalled `DateTime` (#133) is rebuilt before it is validated: a
679
+ // 4.0.0-era map would otherwise pass a `DateTime` check by duck typing
680
+ // and come back as a plain object.
681
+ const revived = reviveMarshalledDateTime(value);
682
+ const alreadyDomain = validateDomain(revived ?? value);
519
683
  if (alreadyDomain._tag === "Some")
520
684
  return Effect.succeed(alreadyDomain.value);
685
+ // A value stored in its DOMAIN form, as a transform nested in a container
686
+ // (or under `optional`) was before #133, comes back changed by the
687
+ // unmarshaller: a `bigint` is written as `N` and read back as a JS
688
+ // `number` (or a `bigint` only beyond the safe-integer range). Lift an
689
+ // integer back to the `bigint` it was. Every other primitive domain form
690
+ // (`number`, `string`, `boolean`, `Uint8Array`) reads back as itself and
691
+ // is already accepted above.
692
+ if (bigintDomain && typeof value === "number" && Number.isSafeInteger(value)) {
693
+ const lifted = validateDomain(BigInt(value));
694
+ if (lifted._tag === "Some")
695
+ return Effect.succeed(lifted.value);
696
+ }
521
697
  return Effect.fail(new SchemaIssue.InvalidType(schema.ast, value));
522
698
  }),
523
699
  encode: SchemaGetter.transformEffect((value) => {
@@ -535,6 +711,155 @@ const buildTolerantTransform = (schema) => {
535
711
  }),
536
712
  }));
537
713
  };
714
+ /** A plain `Schema.BigInt` (no transformation): stored as `N`, read back as a JS number. */
715
+ const isSelfBigInt = (schema) => schema.ast.encoding === undefined && SchemaAST.isBigInt(schema.ast);
716
+ /** A safe integer, or a bigint the unmarshaller returned for an out-of-range `N`. */
717
+ const legacyBigInt = (value) => typeof value === "bigint"
718
+ ? value
719
+ : typeof value === "number" && Number.isSafeInteger(value)
720
+ ? BigInt(value)
721
+ : undefined;
722
+ /**
723
+ * A plain `Schema.BigInt` that also reads the form DynamoDB hands back (#133).
724
+ * A `bigint` is marshalled as `N` and unmarshalled as a JS `number` (or as a
725
+ * `bigint` beyond the safe-integer range), which `Schema.BigInt` rejects — so a
726
+ * plain bigint field could be written but never read. Decode lifts a safe
727
+ * integer back to the `bigint` it was; encode is the schema's own (identity).
728
+ */
729
+ const buildBigIntLift = (schema) => {
730
+ const validate = Schema.decodeUnknownOption(schema);
731
+ const encode = Schema.encodeUnknownOption(schema);
732
+ return Schema.Any.pipe(Schema.decodeTo(schema, {
733
+ decode: SchemaGetter.transformEffect((value) => {
734
+ const lifted = legacyBigInt(value);
735
+ const valid = lifted === undefined ? validate(value) : validate(lifted);
736
+ return valid._tag === "Some"
737
+ ? Effect.succeed(valid.value)
738
+ : Effect.fail(new SchemaIssue.InvalidType(schema.ast, value));
739
+ }),
740
+ encode: SchemaGetter.transformEffect((value) => {
741
+ const wire = encode(value);
742
+ return wire._tag === "Some"
743
+ ? Effect.succeed(wire.value)
744
+ : Effect.fail(new SchemaIssue.InvalidType(schema.ast, value));
745
+ }),
746
+ }));
747
+ };
748
+ /**
749
+ * An ENTITY transform field that also reads the domain-form values earlier
750
+ * releases left behind (#133) — but only the forms no other wire value can be
751
+ * mistaken for:
752
+ *
753
+ * - a `number` for a number-from-string transform (`NumberFromString`);
754
+ * - a safe-integer `number` (or unmarshalled `bigint`) for a bigint-from-string;
755
+ * - a marshalled `DateTime` map for a date transform (`DateTimeUtcFromString`).
756
+ *
757
+ * Path-based updates marshalled their value as given, so a `5` set on a
758
+ * `NumberFromString` was stored as `N` and every later read failed with
759
+ * "Expected string". A string is never accepted here other than through the
760
+ * transform's own decode, and the encode is the transform's own with no
761
+ * fallback, so what the transform writes is unchanged. Read schemas only.
762
+ */
763
+ const buildLegacyReadTransform = (schema) => {
764
+ const typeAst = SchemaAST.toType(schema.ast);
765
+ const typeSide = Schema.make(typeAst);
766
+ const codec = schema;
767
+ const decodeWire = Schema.decodeUnknownOption(codec);
768
+ const validateDomain = Schema.decodeUnknownOption(typeSide);
769
+ const encodeWire = Schema.encodeUnknownOption(codec);
770
+ const isDate = isDateTransform(schema);
771
+ const numberDomain = SchemaAST.isNumber(typeAst);
772
+ const bigintDomain = SchemaAST.isBigInt(typeAst);
773
+ const legacy = (value) => {
774
+ if (isDate)
775
+ return reviveMarshalledDateTime(value);
776
+ if (numberDomain && typeof value === "number")
777
+ return value;
778
+ if (bigintDomain)
779
+ return legacyBigInt(value);
780
+ return undefined;
781
+ };
782
+ return Schema.Any.pipe(Schema.decodeTo(typeSide, {
783
+ decode: SchemaGetter.transformEffect((value) => {
784
+ const fromWire = decodeWire(value);
785
+ if (fromWire._tag === "Some")
786
+ return Effect.succeed(fromWire.value);
787
+ const domain = legacy(value);
788
+ if (domain !== undefined) {
789
+ const valid = validateDomain(domain);
790
+ if (valid._tag === "Some")
791
+ return Effect.succeed(valid.value);
792
+ }
793
+ return Effect.fail(new SchemaIssue.InvalidType(schema.ast, value));
794
+ }),
795
+ encode: SchemaGetter.transformEffect((value) => {
796
+ const wire = encodeWire(value);
797
+ return wire._tag === "Some"
798
+ ? Effect.succeed(wire.value)
799
+ : Effect.fail(new SchemaIssue.InvalidType(schema.ast, value));
800
+ }),
801
+ }));
802
+ };
803
+ /**
804
+ * The primitive kinds a union member can be STORED as (#133). A self date is
805
+ * stored as its configured storage kind; a plain `bigint` as `N`; anything
806
+ * else as its encoded side's string / number forms. Objects, arrays, booleans
807
+ * and null never collide with a date's primitive and contribute nothing.
808
+ */
809
+ const memberWireKinds = (member, options) => {
810
+ const kinds = new Set();
811
+ const inner = optionalField(member)?.inner ?? member;
812
+ if (isSelfDateSchema(inner)) {
813
+ const encoding = getEncoding(inner) ?? inferDefaultEncoding(inner);
814
+ kinds.add(encoding?.storage === "string" || encoding === undefined ? "string" : "number");
815
+ return kinds;
816
+ }
817
+ const visit = (ast, seen) => {
818
+ if (seen.has(ast))
819
+ return;
820
+ seen.add(ast);
821
+ if (SchemaAST.isDeclaration(ast)) {
822
+ // A self date nested in this member — `NullOr(DateTimeUtc)` inside an
823
+ // outer union — is stored as its storage kind too.
824
+ const nested = Schema.make(ast);
825
+ if (isSelfDateSchema(nested)) {
826
+ const encoding = getEncoding(nested) ?? inferDefaultEncoding(nested);
827
+ kinds.add(encoding === undefined || encoding.storage === "string" ? "string" : "number");
828
+ }
829
+ }
830
+ else if (SchemaAST.isAny(ast) || SchemaAST.isUnknown(ast)) {
831
+ kinds.add("string");
832
+ kinds.add("number");
833
+ }
834
+ else if (SchemaAST.isString(ast) || SchemaAST.isTemplateLiteral(ast)) {
835
+ kinds.add("string");
836
+ }
837
+ else if (SchemaAST.isNumber(ast) || SchemaAST.isBigInt(ast)) {
838
+ kinds.add("number");
839
+ }
840
+ else if (SchemaAST.isLiteral(ast)) {
841
+ if (typeof ast.literal === "string")
842
+ kinds.add("string");
843
+ if (typeof ast.literal === "number" || typeof ast.literal === "bigint")
844
+ kinds.add("number");
845
+ }
846
+ else if (SchemaAST.isEnum(ast)) {
847
+ for (const [, value] of ast.enums)
848
+ kinds.add(typeof value === "number" ? "number" : "string");
849
+ }
850
+ else if (SchemaAST.isUnion(ast)) {
851
+ for (const type of ast.types)
852
+ visit(type, seen);
853
+ }
854
+ else if (SchemaAST.isSuspend(ast)) {
855
+ visit(ast.thunk(), seen);
856
+ }
857
+ };
858
+ visit(SchemaAST.toEncoded(inner.ast), new Set());
859
+ if (options?.domainSide)
860
+ visit(SchemaAST.toType(inner.ast), new Set());
861
+ return kinds;
862
+ };
538
863
  /**
539
864
  * True when the schema is a Schema.Class (a constructable function carrying
540
865
  * fields whose AST is a `Declaration`). The AST check matters: since Effect
@@ -543,6 +868,12 @@ const buildTolerantTransform = (schema) => {
543
868
  const isClassSchema = (schema) => typeof schema === "function" &&
544
869
  schemaFieldsOf(schema) !== undefined &&
545
870
  schema.ast._tag === "Declaration";
871
+ /** The Struct AST a class decodes from — the last link of its encoding chain. */
872
+ const classStructAst = (schema) => {
873
+ const encoding = schema.ast.encoding;
874
+ const last = encoding === undefined ? undefined : encoding[encoding.length - 1];
875
+ return last !== undefined && SchemaAST.isObjects(last.to) ? last.to : undefined;
876
+ };
546
877
  /** True when the schema is `Schema.Array(...)` (AST tag `Arrays`). */
547
878
  const isArraySchema = (schema) => schema.ast._tag === "Arrays";
548
879
  /**
@@ -568,8 +899,10 @@ const arrayElementOf = (schema) => {
568
899
  * accessors (which return real schemas with `.fields` / `.value` / constructor
569
900
  * intact), NOT AST reconstruction (which loses those). Returns `undefined` for
570
901
  * non-optional fields.
902
+ *
903
+ * @internal Also used by the aggregate runtime to see through a field's optionality.
571
904
  */
572
- const optionalField = (field) => {
905
+ export const optionalField = (field) => {
573
906
  const ast = field.ast;
574
907
  if (ast.context?.isOptional !== true)
575
908
  return undefined;
@@ -594,6 +927,281 @@ const optionalField = (field) => {
594
927
  rewrap: (s) => Schema.optionalKey(s),
595
928
  };
596
929
  };
930
+ /** The options a nested schema inherits: everything but the per-position ones. */
931
+ const descend = (opts) => opts?.tolerantTransforms ||
932
+ opts?.resolveRef ||
933
+ opts?.strictWireKind ||
934
+ opts?.legacyReads ||
935
+ opts?.path ||
936
+ opts?.enforceChecks
937
+ ? {
938
+ ...(opts?.tolerantTransforms ? { tolerantTransforms: true } : {}),
939
+ ...(opts?.resolveRef ? { resolveRef: opts.resolveRef } : {}),
940
+ ...(opts?.strictWireKind ? { strictWireKind: true } : {}),
941
+ ...(opts?.legacyReads ? { legacyReads: true } : {}),
942
+ ...(opts?.path ? { path: opts.path } : {}),
943
+ ...(opts?.enforceChecks ? { enforceChecks: true } : {}),
944
+ }
945
+ : undefined;
946
+ /** Options for a struct / class field: the inherited ones, with the field on the path. */
947
+ const atField = (deeper, opts, name) => ({ ...deeper, path: [...(opts?.path ?? []), name] });
948
+ /**
949
+ * The containers besides Struct / Class / Array that a value can nest a
950
+ * transformed leaf in: `Union` (incl. `NullOr` and a non-field `UndefinedOr`),
951
+ * `Record`, `Tuple`, `TupleWithRest` and `StructWithRest`. Each is rebuilt
952
+ * through its own runtime accessors so its kind, union options and checks are
953
+ * kept. `containsWireTransform` walks the same shapes.
954
+ *
955
+ * Walked in every mode. Without `tolerantTransforms` (entity derivation) the
956
+ * walk substitutes only self-date and `Redacted` leaves, so an entity's
957
+ * `NullOr(Schema.DateTimeUtc)` stores its wire form instead of a marshalled
958
+ * `DateTime` (#133), while a Pattern B transform inside keeps owning its wire.
959
+ */
960
+ const walkedContainer = (schema) => {
961
+ const shape = containerShape(schema);
962
+ if (shape === undefined)
963
+ return undefined;
964
+ return { ...shape, rebuild: (children) => withMetadataOf(schema, shape.rebuild(children)) };
965
+ };
966
+ /**
967
+ * Give a rebuilt container the original's node-level metadata — its
968
+ * annotations, `.check()` refinements and context. Rebuilding through a
969
+ * constructor (`Schema.Record(k, v)`, `Schema.TupleWithRest(…)`) produces a
970
+ * fresh node without them, so a `.check(Schema.isMaxProperties(1))` on the
971
+ * original was silently dropped and an invalid value accepted (#133).
972
+ */
973
+ const withMetadataOf = (original, rebuilt) => withAstMetadataOf(original.ast, rebuilt);
974
+ /** `withMetadataOf`, taking the metadata from an AST node. */
975
+ const withAstMetadataOf = (source, rebuilt) => {
976
+ const from = source;
977
+ const to = rebuilt.ast;
978
+ if (to.annotations === from.annotations &&
979
+ to.checks === from.checks &&
980
+ to.context === from.context &&
981
+ to.encodingChecks === from.encodingChecks) {
982
+ return rebuilt;
983
+ }
984
+ // AST nodes are immutable value objects: a shallow copy with the metadata
985
+ // fields replaced is the same node shape the constructors produce.
986
+ const ast = Object.assign(Object.create(Object.getPrototypeOf(to)), to, {
987
+ annotations: from.annotations,
988
+ checks: from.checks,
989
+ context: from.context,
990
+ encodingChecks: from.encodingChecks,
991
+ });
992
+ return Schema.make(ast);
993
+ };
994
+ const containerShape = (schema) => {
995
+ const s = schema;
996
+ const ast = schema.ast;
997
+ // A container piped through its own transformation (`Record(…).pipe(decodeTo(…))`)
998
+ // has an encoding chain tied to its ORIGINAL children; rebuilding it would
999
+ // detach that chain, so it is left exactly as declared.
1000
+ if (ast.encoding !== undefined)
1001
+ return undefined;
1002
+ const schemas = (value) => Array.isArray(value) && value.every(isSchemaLike)
1003
+ ? value
1004
+ : undefined;
1005
+ if (SchemaAST.isUnion(ast)) {
1006
+ const members = schemas(s.members);
1007
+ if (members === undefined || typeof s.mapMembers !== "function")
1008
+ return undefined;
1009
+ const union = schema;
1010
+ return {
1011
+ children: members,
1012
+ rebuild: (subs) => union.mapMembers(() => subs, { unsafePreserveChecks: true }),
1013
+ isUnion: true,
1014
+ };
1015
+ }
1016
+ if (SchemaAST.isArrays(ast)) {
1017
+ const elements = schemas(s.elements);
1018
+ if (elements !== undefined && typeof s.mapElements === "function") {
1019
+ const tuple = schema;
1020
+ return {
1021
+ children: elements,
1022
+ rebuild: (subs) => tuple.mapElements(() => subs, { unsafePreserveChecks: true }),
1023
+ isUnion: false,
1024
+ };
1025
+ }
1026
+ const rest = schemas(s.rest);
1027
+ if (isSchemaLike(s.schema) && rest !== undefined) {
1028
+ return {
1029
+ children: [s.schema, ...rest],
1030
+ rebuild: ([head, ...tail]) => Schema.TupleWithRest(head, tail),
1031
+ isUnion: false,
1032
+ };
1033
+ }
1034
+ return undefined;
1035
+ }
1036
+ if (SchemaAST.isObjects(ast)) {
1037
+ if (isSchemaLike(s.key) && isSchemaLike(s.value)) {
1038
+ const key = s.key;
1039
+ return {
1040
+ children: [s.value],
1041
+ rebuild: ([value]) => Schema.Record(key, value),
1042
+ isUnion: false,
1043
+ };
1044
+ }
1045
+ const records = schemas(s.records);
1046
+ if (isSchemaLike(s.schema) && records !== undefined) {
1047
+ return {
1048
+ children: [s.schema, ...records],
1049
+ rebuild: ([head, ...tail]) => Schema.StructWithRest(head, tail),
1050
+ isUnion: false,
1051
+ };
1052
+ }
1053
+ }
1054
+ return undefined;
1055
+ };
1056
+ /** Options for a tolerant container's children: a Union's members decode strictly by kind. */
1057
+ const childOptions = (container, deeper, index, opts) => {
1058
+ if (!container.isUnion)
1059
+ return deeper;
1060
+ // What the other members are stored as — plus, for a union nested inside
1061
+ // another (`Union([NullOr(DateTimeUtc), String])`), what the OUTER union's
1062
+ // other members are stored as: they compete for the same stored value.
1063
+ const collidingKinds = new Set(opts?.collidingKinds ?? []);
1064
+ container.children.forEach((member, i) => {
1065
+ if (i !== index) {
1066
+ const kinds = memberWireKinds(member, { domainSide: opts?.tolerantTransforms === true });
1067
+ for (const kind of kinds)
1068
+ collidingKinds.add(kind);
1069
+ }
1070
+ });
1071
+ return {
1072
+ ...deeper,
1073
+ strictWireKind: true,
1074
+ collidingKinds,
1075
+ ...(opts?.unionDateOverride ? { dateEncodingOverride: opts.unionDateOverride } : {}),
1076
+ };
1077
+ };
1078
+ /** The options an optional wrapper hands its inner schema: it is transparent. */
1079
+ /**
1080
+ * A field whose LAST encoding step makes the encoded key optional while the
1081
+ * type side stays required — `Schema.withDecodingDefault(…)` /
1082
+ * `withDecodingDefaultKey(…)`, or any `optional(…).pipe(decodeTo(…))`. Its
1083
+ * inner schema (the field without that step) is what substitution works on;
1084
+ * `rewrap` re-applies the same step on top of the substitute, so the default
1085
+ * survives (#133). Without it a self-date / transform leaf was replaced
1086
+ * wholesale and the read schema lost the default — a stored item without the
1087
+ * key failed to decode.
1088
+ */
1089
+ const decodingDefaultField = (field) => {
1090
+ const ast = field.ast;
1091
+ const encoding = ast.encoding;
1092
+ if (encoding === undefined || encoding.length === 0)
1093
+ return undefined;
1094
+ if (ast.context?.isOptional === true) {
1095
+ return undefined;
1096
+ }
1097
+ const last = encoding[encoding.length - 1];
1098
+ const lastTo = last.to;
1099
+ if (lastTo.context?.isOptional !== true)
1100
+ return undefined;
1101
+ const transformation = last.transformation;
1102
+ if (transformation._tag !== "Transformation")
1103
+ return undefined;
1104
+ // The same node without its last encoding step (what Effect's internal
1105
+ // `replaceEncoding` does: a shallow copy carrying the shorter chain).
1106
+ const innerAst = Object.assign(Object.create(Object.getPrototypeOf(ast)), ast, {
1107
+ encoding: encoding.length === 1 ? undefined : encoding.slice(0, -1),
1108
+ });
1109
+ const inner = Schema.make(innerAst);
1110
+ const withUndefined = SchemaAST.isUnion(lastTo) && lastTo.types.some((t) => SchemaAST.isUndefined(t));
1111
+ return {
1112
+ inner,
1113
+ rewrap: (substitute) => {
1114
+ const encodedSide = Schema.toEncoded(substitute);
1115
+ const optionalEncoded = withUndefined
1116
+ ? Schema.optional(encodedSide)
1117
+ : Schema.optionalKey(encodedSide);
1118
+ return optionalEncoded.pipe(Schema.decodeTo(substitute, transformation));
1119
+ },
1120
+ };
1121
+ };
1122
+ const throughOptional = (opts) => {
1123
+ const deeper = descend(opts);
1124
+ return opts?.unionDateOverride || opts?.collidingKinds || opts?.dateEncodingOverride
1125
+ ? {
1126
+ ...deeper,
1127
+ ...(opts.unionDateOverride ? { unionDateOverride: opts.unionDateOverride } : {}),
1128
+ ...(opts.collidingKinds ? { collidingKinds: opts.collidingKinds } : {}),
1129
+ ...(opts.dateEncodingOverride ? { dateEncodingOverride: opts.dateEncodingOverride } : {}),
1130
+ }
1131
+ : deeper;
1132
+ };
1133
+ /**
1134
+ * The substitute for a self-date leaf: its own (or the configured) storage,
1135
+ * adjusted for a union whose other members collide with it (#133).
1136
+ */
1137
+ const selfDatePlan = (schema, opts) => {
1138
+ const own = getEncoding(schema) ?? inferDefaultEncoding(schema);
1139
+ if (!own)
1140
+ return undefined;
1141
+ const encoding = opts?.dateEncodingOverride
1142
+ ? { storage: opts.dateEncodingOverride.storage, domain: own.domain }
1143
+ : own;
1144
+ const colliding = opts?.collidingKinds;
1145
+ let canonicalOnly = false;
1146
+ if (colliding !== undefined) {
1147
+ // An epoch number is indistinguishable from a number member's value — any
1148
+ // integer is a canonical epoch — so no stored number could be read back
1149
+ // reliably. Refused at make() time (EDD-9058) rather than guessed.
1150
+ if (encoding.storage !== "string" && colliding.has("number")) {
1151
+ const field = opts?.path?.length ? opts.path.join(".") : "(unnamed)";
1152
+ const reason = opts?.tolerantTransforms
1153
+ ? `next to a member that is stored as a number or decodes to one (aggregates ` +
1154
+ `re-decode domain values on update, so a NumberFromString / BigIntFromString ` +
1155
+ `member collides too). A number could belong to either member`
1156
+ : `next to a member that is also stored as a number. A stored number could ` +
1157
+ `belong to either member`;
1158
+ throw new Error(`[EDD-9058] Field "${field}" is a union whose date member is stored as an epoch ` +
1159
+ `number (${encoding.storage}), ${reason}, so it cannot be read back reliably. ` +
1160
+ `Store the date as a string (DynamoModel.DateString — the default for a self ` +
1161
+ `date), or remove the numeric member from the union.`);
1162
+ }
1163
+ canonicalOnly = colliding.has(encoding.storage === "string" ? "string" : "number");
1164
+ }
1165
+ return { encoding, canonicalOnly };
1166
+ };
1167
+ const selfDateSubstitute = (schema, opts) => {
1168
+ const plan = selfDatePlan(schema, opts);
1169
+ if (plan === undefined)
1170
+ return schema;
1171
+ return buildDateTransform(plan.encoding, {
1172
+ strictWireKind: opts?.strictWireKind,
1173
+ canonicalOnly: plan.canonicalOnly,
1174
+ });
1175
+ };
1176
+ /**
1177
+ * Whether a union member is a self date that claims only its canonical form
1178
+ * (see `selfDatePlan`). Such a member is decoded FIRST: its canonical spelling
1179
+ * is the one value it and a colliding member could both take, and that value
1180
+ * is what the library writes for a date — so it is read as the date whatever
1181
+ * the declared member order. Encoding is unaffected: a date and a string /
1182
+ * number are told apart by their Type side, which no reordering changes.
1183
+ */
1184
+ const isCanonicalDateMember = (member, opts) => {
1185
+ const inner = optionalField(member)?.inner ?? member;
1186
+ if (isSelfDateSchema(inner))
1187
+ return selfDatePlan(inner, opts)?.canonicalOnly === true;
1188
+ // A nested union holding such a member (`NullOr(DateTimeUtc)` next to a
1189
+ // `String`) is decoded first for the same reason.
1190
+ const container = walkedContainer(inner);
1191
+ if (container === undefined || !container.isUnion)
1192
+ return false;
1193
+ return container.children.some((child, index) => isCanonicalDateMember(child, childOptions(container, descend(opts), index, opts)));
1194
+ };
1195
+ /** Whether a leaf gets a read-leniency substitute (see `legacyReads`). */
1196
+ const needsLegacyRead = (schema, opts) => opts?.legacyReads === true &&
1197
+ opts.strictWireKind !== true &&
1198
+ schemaFieldsOf(schema) === undefined &&
1199
+ !isArraySchema(schema) &&
1200
+ (isDateTransform(schema) || numericTypeWithStringEncoding(schema));
1201
+ /** Whether a plain `Schema.BigInt` leaf gets the read lift (aggregate decode, entity reads). */
1202
+ const needsBigIntLift = (schema, opts) => (opts?.tolerantTransforms === true || opts?.legacyReads === true) &&
1203
+ opts.strictWireKind !== true &&
1204
+ isSelfBigInt(schema);
597
1205
  /**
598
1206
  * Recursively determine whether a schema contains — at any depth reachable
599
1207
  * through nested `Schema.Struct` / `Schema.Class` / `Schema.Array` — a leaf that
@@ -601,10 +1209,9 @@ const optionalField = (field) => {
601
1209
  * `RedactedFromValue`). Returns false for schemas that don't, so
602
1210
  * {@link substituteSchemaDeep} can return them unchanged (zero structural churn).
603
1211
  *
604
- * Optional / union members are intentionally NOT traversed — reconstructing a
605
- * `Schema.optional(Class)` while preserving the nested class is not reliably
606
- * supported, so such fields are left exactly as the caller declared them (same
607
- * as the pre-existing behavior).
1212
+ * Optional wrappers are unwrapped to their real inner schema, and the walk
1213
+ * enters `Union` members, `Record` values and `Tuple` elements too (see
1214
+ * `walkedContainer`).
608
1215
  */
609
1216
  const needsDeepSubstitution = (schema, opts) => {
610
1217
  if (schema == null || schema.ast == null)
@@ -617,12 +1224,7 @@ const needsDeepSubstitution = (schema, opts) => {
617
1224
  // so the walker cannot see through it and the target's own transformed fields
618
1225
  // keep their strict schemas. Only callers that pass `resolveRef` are affected
619
1226
  // — the entity derivation passes no options at all (#116).
620
- const deeper = opts?.tolerantTransforms || opts?.resolveRef
621
- ? {
622
- ...(opts?.tolerantTransforms ? { tolerantTransforms: true } : {}),
623
- ...(opts?.resolveRef ? { resolveRef: opts.resolveRef } : {}),
624
- }
625
- : undefined;
1227
+ const deeper = descend(opts);
626
1228
  // Optional wrapper FIRST — unwrap to the REAL inner before the leaf / array /
627
1229
  // struct checks. `optionalKey(X)` is a bare AST (Declaration/Arrays) with an
628
1230
  // `isOptional` context, so the leaf detectors (which resolve through to the
@@ -631,7 +1233,10 @@ const needsDeepSubstitution = (schema, opts) => {
631
1233
  // has the `Arrays` AST but no runtime `.value`.
632
1234
  const opt = optionalField(schema);
633
1235
  if (opt !== undefined)
634
- return needsDeepSubstitution(opt.inner, deeper);
1236
+ return needsDeepSubstitution(opt.inner, throughOptional(opts));
1237
+ const defaulted = decodingDefaultField(schema);
1238
+ if (defaulted !== undefined)
1239
+ return needsDeepSubstitution(defaulted.inner, opts);
635
1240
  if (isSelfDateSchema(schema))
636
1241
  return true;
637
1242
  if (opts?.tolerantTransforms && isDateTransform(schema))
@@ -642,6 +1247,12 @@ const needsDeepSubstitution = (schema, opts) => {
642
1247
  // case: after a mutation the field may hold either form (#116).
643
1248
  if (opts?.tolerantTransforms && isLeafEncodingTransform(schema))
644
1249
  return true;
1250
+ if (needsLegacyRead(schema, opts) || needsBigIntLift(schema, opts))
1251
+ return true;
1252
+ const container = walkedContainer(schema);
1253
+ if (container !== undefined) {
1254
+ return container.children.some((child, index) => needsDeepSubstitution(child, childOptions(container, deeper, index, opts)));
1255
+ }
645
1256
  if (isArraySchema(schema)) {
646
1257
  const element = arrayElementOf(schema);
647
1258
  return element !== undefined && needsDeepSubstitution(element, deeper);
@@ -677,20 +1288,16 @@ const needsDeepSubstitution = (schema, opts) => {
677
1288
  export const substituteSchemaDeep = (schema, opts) => {
678
1289
  if (!needsDeepSubstitution(schema, opts))
679
1290
  return schema;
680
- // `tolerantTransforms` AND `resolveRef` propagate through the recursion;
681
- // `skipTopLevel` names top-level fields only, so it is dropped below.
1291
+ // `tolerantTransforms`, `resolveRef`, `strictWireKind` and `legacyReads`
1292
+ // propagate through the recursion (`descend`); `skipTopLevel` and the
1293
+ // per-position union options do not.
682
1294
  //
683
1295
  // `resolveRef` has to descend: a `DynamoModel.ref` field nested INSIDE an edge
684
1296
  // model is annotated, and `Schema.annotate` drops a `Schema.Class`'s `.fields`,
685
1297
  // so the walker cannot see through it and the target's own transformed fields
686
1298
  // keep their strict schemas. Only callers that pass `resolveRef` are affected
687
1299
  // — the entity derivation passes no options at all (#116).
688
- const deeper = opts?.tolerantTransforms || opts?.resolveRef
689
- ? {
690
- ...(opts?.tolerantTransforms ? { tolerantTransforms: true } : {}),
691
- ...(opts?.resolveRef ? { resolveRef: opts.resolveRef } : {}),
692
- }
693
- : undefined;
1300
+ const deeper = descend(opts);
694
1301
  // Optional wrapper FIRST — unwrap to the REAL inner, substitute it, then
695
1302
  // re-apply the same optionality. Handled BEFORE the leaf / array / struct
696
1303
  // checks: the leaf detectors resolve through an `optionalKey` wrapper and would
@@ -698,16 +1305,26 @@ export const substituteSchemaDeep = (schema, opts) => {
698
1305
  // the optional key), and an optional array's `.value` lives only on the inner.
699
1306
  const opt = optionalField(schema);
700
1307
  if (opt !== undefined) {
701
- return opt.rewrap(substituteSchemaDeep(opt.inner, deeper));
1308
+ return opt.rewrap(substituteSchemaDeep(opt.inner, throughOptional(opts)));
702
1309
  }
703
- // Leaf: self-date schema → tolerant bidirectional date transform.
704
- if (isSelfDateSchema(schema)) {
705
- const encoding = getEncoding(schema) ?? inferDefaultEncoding(schema);
706
- return encoding ? buildDateTransform(encoding) : schema;
1310
+ // A decoding default (or any optional-encoded step) on top: substitute the
1311
+ // field beneath it and keep the step, so a missing key still defaults (#133).
1312
+ const defaulted = decodingDefaultField(schema);
1313
+ if (defaulted !== undefined) {
1314
+ return defaulted.rewrap(substituteSchemaDeep(defaulted.inner, opts));
707
1315
  }
1316
+ // Leaf: self-date schema → tolerant bidirectional date transform.
1317
+ if (isSelfDateSchema(schema))
1318
+ return selfDateSubstitute(schema, opts);
708
1319
  // Leaf: Pattern B transform date schema (only under `tolerantTransforms`) →
709
1320
  // tolerant date transform whose decode also accepts the already-domain value.
1321
+ // Inside a union it keeps the transform's OWN decode instead: the union picks
1322
+ // a member by what each one accepts, and the generic date transform accepts
1323
+ // more (any epoch number, any parseable string) than the user's transform —
1324
+ // enough to claim a value that belongs to a later member (#133).
710
1325
  if (opts?.tolerantTransforms && isDateTransform(schema)) {
1326
+ if (opts.strictWireKind)
1327
+ return buildTolerantTransform(schema);
711
1328
  const encoding = getEncoding(schema) ?? inferDefaultEncoding(schema);
712
1329
  if (encoding)
713
1330
  return buildDateTransform(encoding);
@@ -724,12 +1341,38 @@ export const substituteSchemaDeep = (schema, opts) => {
724
1341
  if (opts?.tolerantTransforms && isLeafEncodingTransform(schema)) {
725
1342
  return buildTolerantTransform(schema);
726
1343
  }
1344
+ // Leaf: read leniency for legacy domain-form values (entity read schemas) and
1345
+ // the plain-bigint lift (#133).
1346
+ if (needsLegacyRead(schema, opts))
1347
+ return buildLegacyReadTransform(schema);
1348
+ if (needsBigIntLift(schema, opts))
1349
+ return buildBigIntLift(schema);
1350
+ // Union / Record / Tuple / *WithRest: substitute every child and rebuild the
1351
+ // same container kind, metadata included (#133). Without `tolerantTransforms`
1352
+ // (entity derivation) only self-date / Redacted leaves inside are
1353
+ // substituted, exactly as for a Struct or an Array. A union's members each
1354
+ // learn which primitive kinds the others are stored as (`childOptions`).
1355
+ const container = walkedContainer(schema);
1356
+ if (container !== undefined) {
1357
+ const children = container.children.map((child, index) => {
1358
+ const childOpts = childOptions(container, deeper, index, opts);
1359
+ return {
1360
+ substituted: substituteSchemaDeep(child, childOpts),
1361
+ first: container.isUnion && isCanonicalDateMember(child, childOpts),
1362
+ };
1363
+ });
1364
+ return container.rebuild([
1365
+ ...children.filter((c) => c.first).map((c) => c.substituted),
1366
+ ...children.filter((c) => !c.first).map((c) => c.substituted),
1367
+ ]);
1368
+ }
727
1369
  // Array: substitute the element schema.
728
1370
  if (isArraySchema(schema)) {
729
1371
  const element = arrayElementOf(schema);
730
1372
  if (element === undefined)
731
1373
  return schema;
732
- return Schema.Array(substituteSchemaDeep(element, deeper));
1374
+ const rebuilt = Schema.Array(substituteSchemaDeep(element, deeper));
1375
+ return opts?.enforceChecks ? withMetadataOf(schema, rebuilt) : rebuilt;
733
1376
  }
734
1377
  // Struct / Class: substitute each field, recursing into nested structures.
735
1378
  // Ref edge fields are re-pointed via `resolveRef` (re-applying any optionality);
@@ -747,16 +1390,23 @@ export const substituteSchemaDeep = (schema, opts) => {
747
1390
  const refTarget = opts?.resolveRef?.(name, field);
748
1391
  if (refTarget !== undefined) {
749
1392
  const fieldOpt = optionalField(field);
750
- const sub = substituteSchemaDeep(refTarget, deeper);
1393
+ const sub = substituteSchemaDeep(refTarget, atField(deeper, opts, name));
751
1394
  subFields[name] = fieldOpt ? fieldOpt.rewrap(sub) : sub;
752
1395
  }
753
1396
  else {
754
- subFields[name] = substituteSchemaDeep(field, deeper);
1397
+ subFields[name] = substituteSchemaDeep(field, atField(deeper, opts, name));
755
1398
  }
756
1399
  }
757
- const subStruct = Schema.Struct(subFields);
758
- if (!isClassSchema(schema))
759
- return subStruct;
1400
+ const plainStruct = Schema.Struct(subFields);
1401
+ if (!isClassSchema(schema)) {
1402
+ return opts?.enforceChecks ? withMetadataOf(schema, plainStruct) : plainStruct;
1403
+ }
1404
+ // A class built over a checked Struct (`Schema.Class("C")(Struct(…).check(f))`)
1405
+ // keeps that check on the Struct its encoding leads to.
1406
+ const classStruct = classStructAst(schema);
1407
+ const subStruct = (opts?.enforceChecks && classStruct !== undefined
1408
+ ? withAstMetadataOf(classStruct, plainStruct)
1409
+ : plainStruct);
760
1410
  // Preserve the class instance: decode the substituted struct to the original
761
1411
  // class via prototype attach (no constructor re-validation, no field re-decode).
762
1412
  const ctor = schema;
@@ -792,8 +1442,12 @@ export const substituteSchemaDeep = (schema, opts) => {
792
1442
  *
793
1443
  * @internal
794
1444
  */
795
- export const substituteSchemas = (modelFields, fieldEncodings) => {
1445
+ export const substituteSchemas = (modelFields, fieldEncodings, options) => {
796
1446
  const out = {};
1447
+ const deep = {
1448
+ ...(options?.legacyReads ? { legacyReads: true } : {}),
1449
+ ...(options?.enforceChecks ? { enforceChecks: true } : {}),
1450
+ };
797
1451
  for (const [name, schema] of Object.entries(modelFields)) {
798
1452
  // 1. Self-date substitution. Pattern A: user declared `Schema.DateTimeUtc`
799
1453
  // and chose a wire format via annotation. We substitute with the
@@ -806,6 +1460,15 @@ export const substituteSchemas = (modelFields, fieldEncodings) => {
806
1460
  continue;
807
1461
  }
808
1462
  }
1463
+ // …also beneath a decoding default, which is kept on top (#133).
1464
+ const defaulted = decodingDefaultField(schema);
1465
+ if (defaulted !== undefined && isSelfDateSchema(defaulted.inner)) {
1466
+ const enc = fieldEncodings[name];
1467
+ if (enc) {
1468
+ out[name] = defaulted.rewrap(buildDateTransform(enc));
1469
+ continue;
1470
+ }
1471
+ }
809
1472
  // 2. RedactedFromValue substitution. Effect v4's `RedactedFromValue`
810
1473
  // forbids encoding by design; substitute with a tolerant Redacted
811
1474
  // transform so the round-trip works for storage.
@@ -821,10 +1484,44 @@ export const substituteSchemas = (modelFields, fieldEncodings) => {
821
1484
  // `DynamoModel.ref` target) may itself carry a self-date or Redacted leaf
822
1485
  // — `substituteSchemaDeep` substitutes those while preserving class
823
1486
  // identity, and returns the schema unchanged when nothing nested needs it.
824
- out[name] = substituteSchemaDeep(schema);
1487
+ // A configured storage override on a UNION field (`NullOr(DateTimeUtc)`)
1488
+ // is applied to the union's self-date member (#133).
1489
+ const unionOverride = unionDateOverride(name, schema, fieldEncodings[name]);
1490
+ out[name] = substituteSchemaDeep(schema, {
1491
+ ...deep,
1492
+ path: [name],
1493
+ ...(unionOverride === undefined ? {} : { unionDateOverride: unionOverride }),
1494
+ });
825
1495
  }
826
1496
  return out;
827
1497
  };
1498
+ /**
1499
+ * The storage a `DynamoModel.configure(..., { field: { storedAs } })` override
1500
+ * gives a top-level UNION field's date member, or `undefined` when the field is
1501
+ * not a union with a self-date member (#133).
1502
+ *
1503
+ * The override names one storage for the field; a union holding several self
1504
+ * dates gives it no single member to apply to, so that is refused at
1505
+ * `Entity.make()` rather than guessed (EDD-9057).
1506
+ */
1507
+ const unionDateOverride = (name, schema, encoding) => {
1508
+ if (encoding === undefined)
1509
+ return undefined;
1510
+ const inner = optionalField(schema)?.inner ?? schema;
1511
+ if (!SchemaAST.isUnion(inner.ast))
1512
+ return undefined;
1513
+ const members = inner.members;
1514
+ const dateMembers = (members ?? []).filter((member) => isSelfDateSchema(optionalField(member)?.inner ?? member));
1515
+ if (dateMembers.length === 0)
1516
+ return undefined;
1517
+ if (dateMembers.length > 1) {
1518
+ throw new Error(`[EDD-9057] Field "${name}" is a union with ${dateMembers.length} date members, and its ` +
1519
+ `configured storage override (DynamoModel.configure … storedAs) cannot say which one it ` +
1520
+ `applies to. Annotate the intended member directly with ` +
1521
+ `\`.pipe(DynamoModel.storedAs(...))\` and drop the field-level override.`);
1522
+ }
1523
+ return encoding;
1524
+ };
828
1525
  /**
829
1526
  * Validate that no model field combines a transform schema with a storage
830
1527
  * override. The two configuration paths are mutually exclusive: either declare
@@ -896,7 +1593,14 @@ export const buildDerivedSchemas = (modelFields, indexes, systemFields, resolved
896
1593
  // fields are replaced with a custom bidirectional transform so encoding
897
1594
  // round-trips. All other transform schemas are passed through — the user's
898
1595
  // transform IS the wire format and we never override it.
899
- const fields = substituteSchemas(modelFields, fieldEncodings);
1596
+ // WRITE schemas (input / create / update / key) enforce the container checks
1597
+ // the substitution rebuilds; the READ schemas below do not (#133).
1598
+ const fields = substituteSchemas(modelFields, fieldEncodings, { enforceChecks: true });
1599
+ // READ schemas (model / record / item / deleted / history decode) also take
1600
+ // the domain-form values earlier releases left on transform fields (#133).
1601
+ // Their encode is the strict one, and keys are composed from `inputSchema`,
1602
+ // so neither writes nor keys see a difference.
1603
+ const readFields = substituteSchemas(modelFields, fieldEncodings, { legacyReads: true });
900
1604
  // Ref fields carry the `DynamoModel.ref` annotation (an opaque Declaration that
901
1605
  // hides the target's fields), so `substituteSchemaDeep` above can't see into
902
1606
  // them. Re-point each denormalized ref field at its target model, recursively
@@ -910,10 +1614,11 @@ export const buildDerivedSchemas = (modelFields, indexes, systemFields, resolved
910
1614
  const resolved = isConfiguredModel(targetModel)
911
1615
  ? targetModel.model
912
1616
  : targetModel;
913
- fields[ref.fieldName] = substituteSchemaDeep(resolved);
1617
+ fields[ref.fieldName] = substituteSchemaDeep(resolved, { enforceChecks: true });
1618
+ readFields[ref.fieldName] = substituteSchemaDeep(resolved, { legacyReads: true });
914
1619
  }
915
- // --- Model Schema: pure model fields (for input decode/encode) ---
916
- const modelSchema = Schema.Struct(fields);
1620
+ // --- Model Schema: pure model fields (decode of stored items in "model" mode) ---
1621
+ const modelSchema = Schema.Struct(readFields);
917
1622
  // --- Record Schema: model + system fields ---
918
1623
  // System timestamp fields use Schema.DateTimeUtcFromString as the canonical
919
1624
  // wire ↔ domain transform when they're library-managed. When the field
@@ -931,10 +1636,12 @@ export const buildDerivedSchemas = (modelFields, indexes, systemFields, resolved
931
1636
  : Schema.DateTimeUtcFromString;
932
1637
  }
933
1638
  if (systemFields.version && !systemFields.versionCollision) {
934
- systemSchemaFields[systemFields.version] = Schema.Number;
1639
+ // An item written before the entity was `versioned` has no version: it
1640
+ // reads as version 0 — real versions start at 1 (#133).
1641
+ systemSchemaFields[systemFields.version] = Schema.Number.pipe(Schema.withDecodingDefaultKey(Effect.succeed(0)));
935
1642
  }
936
1643
  const recordSchema = Schema.Struct({
937
- ...fields,
1644
+ ...readFields,
938
1645
  ...systemSchemaFields,
939
1646
  });
940
1647
  // --- System-collision handling for input/update schemas ---
@@ -1067,14 +1774,14 @@ export const buildDerivedSchemas = (modelFields, indexes, systemFields, resolved
1067
1774
  : Schema.optional(Schema.String);
1068
1775
  }
1069
1776
  const itemSchema = Schema.Struct({
1070
- ...fields,
1777
+ ...readFields,
1071
1778
  ...systemSchemaFields,
1072
1779
  ...keyAttrFields,
1073
1780
  __edd_e__: Schema.String,
1074
1781
  });
1075
1782
  // --- Deleted Record Schema: record + deletedAt ---
1076
1783
  const deletedRecordSchema = Schema.Struct({
1077
- ...fields,
1784
+ ...readFields,
1078
1785
  ...systemSchemaFields,
1079
1786
  deletedAt: Schema.String,
1080
1787
  });
@@ -1088,11 +1795,11 @@ export const buildDerivedSchemas = (modelFields, indexes, systemFields, resolved
1088
1795
  }
1089
1796
  const hasHiddenFields = hiddenFieldNames.size > 0;
1090
1797
  const modelVisibleSchema = hasHiddenFields
1091
- ? Schema.Struct(Object.fromEntries(Object.entries(fields).filter(([k]) => !hiddenFieldNames.has(k))))
1798
+ ? Schema.Struct(Object.fromEntries(Object.entries(readFields).filter(([k]) => !hiddenFieldNames.has(k))))
1092
1799
  : modelSchema;
1093
1800
  const recordVisibleSchema = hasHiddenFields
1094
1801
  ? Schema.Struct(Object.fromEntries(Object.entries({
1095
- ...fields,
1802
+ ...readFields,
1096
1803
  ...systemSchemaFields,
1097
1804
  }).filter(([k]) => !hiddenFieldNames.has(k))))
1098
1805
  : recordSchema;
@@ -1113,7 +1820,7 @@ export const buildDerivedSchemas = (modelFields, indexes, systemFields, resolved
1113
1820
  // Compute encodings for the appendInput fields (annotation +
1114
1821
  // inferred from typeConstructor for self schemas).
1115
1822
  const appendEncodings = buildFieldEncodings(userFields, {});
1116
- const subbedFields = substituteSchemas(userFields, appendEncodings);
1823
+ const subbedFields = substituteSchemas(userFields, appendEncodings, { enforceChecks: true });
1117
1824
  return Schema.Struct(subbedFields);
1118
1825
  })()
1119
1826
  : null;
@@ -1122,11 +1829,12 @@ export const buildDerivedSchemas = (modelFields, indexes, systemFields, resolved
1122
1829
  const hiddenFilter = (record) => hasHiddenFields
1123
1830
  ? Object.fromEntries(Object.entries(record).filter(([k]) => !hiddenFieldNames.has(k)))
1124
1831
  : record;
1125
- return Schema.Struct(hiddenFilter({ ...fields }));
1832
+ return Schema.Struct(hiddenFilter({ ...readFields }));
1126
1833
  })()
1127
1834
  : null;
1128
1835
  return {
1129
1836
  modelSchema: modelVisibleSchema,
1837
+ writeModelSchema: Schema.Struct(fields),
1130
1838
  recordSchema: recordVisibleSchema,
1131
1839
  inputSchema: inputSchema,
1132
1840
  createSchema: createSchema,
@@ -1142,4 +1850,367 @@ export const buildDerivedSchemas = (modelFields, indexes, systemFields, resolved
1142
1850
  // Unique constraint field resolver
1143
1851
  // ---------------------------------------------------------------------------
1144
1852
  export const resolveUniqueFields = (def) => Array.isArray(def) ? def : def.fields;
1853
+ // ---------------------------------------------------------------------------
1854
+ // Path-addressed values (#133)
1855
+ // ---------------------------------------------------------------------------
1856
+ /** The schema one path segment addresses inside `schema`, or `undefined`. */
1857
+ const childAtSegment = (schema, segment) => {
1858
+ const inner = optionalField(schema)?.inner ?? schema;
1859
+ const s = inner;
1860
+ const fields = schemaFieldsOf(inner);
1861
+ if (fields !== undefined)
1862
+ return typeof segment === "string" ? fields[segment] : undefined;
1863
+ // An OPAQUE class — `X.check(…)`, `X.annotate(…)`, a `DynamoModel.ref` nested
1864
+ // in a ref target — is deliberately NOT followed. Its `.fields` are gone, so
1865
+ // the derived read schema keeps it unsubstituted and decodes its leaves
1866
+ // exactly as `put` stores them; encoding a value under it into wire form
1867
+ // would leave the item unreadable (#133). A path into it is passed through
1868
+ // as given. A top-level `DynamoModel.ref` field is followed through its
1869
+ // target model instead (`makePathValueEncoder`'s `refTargets`), whose read
1870
+ // schema IS substituted.
1871
+ if (SchemaAST.isUnion(inner.ast)) {
1872
+ // `NullOr(Stamp)` and friends: the first member the segment resolves in.
1873
+ const members = Array.isArray(s.members) ? s.members : [];
1874
+ for (const member of members) {
1875
+ const child = childAtSegment(member, segment);
1876
+ if (child !== undefined)
1877
+ return child;
1878
+ }
1879
+ return undefined;
1880
+ }
1881
+ if (SchemaAST.isArrays(inner.ast)) {
1882
+ if (typeof segment !== "number")
1883
+ return undefined;
1884
+ if (isSchemaLike(s.value))
1885
+ return s.value;
1886
+ if (Array.isArray(s.elements))
1887
+ return s.elements[segment];
1888
+ if (isSchemaLike(s.schema) && Array.isArray(s.rest)) {
1889
+ const head = s.schema
1890
+ .elements;
1891
+ if (head !== undefined && segment < head.length)
1892
+ return head[segment];
1893
+ return s.rest[0];
1894
+ }
1895
+ return undefined;
1896
+ }
1897
+ if (SchemaAST.isObjects(inner.ast) && typeof segment === "string") {
1898
+ if (isSchemaLike(s.value) && isSchemaLike(s.key))
1899
+ return s.value;
1900
+ if (isSchemaLike(s.schema) && Array.isArray(s.records)) {
1901
+ const declared = schemaFieldsOf(s.schema)?.[segment];
1902
+ if (declared !== undefined)
1903
+ return declared;
1904
+ const record = s.records[0];
1905
+ return record?.value;
1906
+ }
1907
+ }
1908
+ return undefined;
1909
+ };
1910
+ /** The element schema of the list a path addresses (for append / prepend). */
1911
+ const listElementOf = (schema) => {
1912
+ const inner = optionalField(schema)?.inner ?? schema;
1913
+ if (!SchemaAST.isArrays(inner.ast))
1914
+ return undefined;
1915
+ const value = inner.value;
1916
+ if (isSchemaLike(value))
1917
+ return value;
1918
+ // A rebuilt array with its checks restored has no runtime `.value`; a plain
1919
+ // list (no fixed elements) still has its element in the AST's rest.
1920
+ return inner.ast.elements.length === 0 ? arrayElementOf(inner) : undefined;
1921
+ };
1922
+ /** Structural equality over plain values (primitives, arrays, plain objects). */
1923
+ const sameValue = (a, b) => {
1924
+ if (Object.is(a, b))
1925
+ return true;
1926
+ if (Array.isArray(a) && Array.isArray(b)) {
1927
+ return a.length === b.length && a.every((entry, i) => sameValue(entry, b[i]));
1928
+ }
1929
+ if (isPlainObject(a) && isPlainObject(b)) {
1930
+ const keys = Object.keys(a);
1931
+ return keys.length === Object.keys(b).length && keys.every((k) => sameValue(a[k], b[k]));
1932
+ }
1933
+ return false;
1934
+ };
1935
+ /**
1936
+ * Whether a path value is ALREADY in the wire form of `original`, in a way
1937
+ * encoding cannot tell apart from a domain value: it decodes as wire, it
1938
+ * validates as the Type side, and encoding it would change it. `StringFromBase64`
1939
+ * given `"aGk="`, or `fromJsonString(Unknown)` given `'{"a":2}'`, are both
1940
+ * valid domain strings and valid wire strings; encoding would double-encode.
1941
+ * Before path values were encoded, a wire value was the only way to round-trip
1942
+ * such a field, so it is passed through as given (#133).
1943
+ *
1944
+ * Only a LEAF transform with a primitive wire form qualifies. A container — a
1945
+ * class, struct, array, record, tuple or union — is always encoded: encoding a
1946
+ * `Schema.Class` yields a plain object, so it would always look "changed", and
1947
+ * passing it through whole would marshal its `Redacted` / `Date` / `DateTime`
1948
+ * leaves as maps and lose them. A self date never qualifies either: its domain
1949
+ * `DateTime` is "its own encoded form" to Effect, but the library stores it as
1950
+ * a primitive.
1951
+ */
1952
+ const makeAmbiguityCheck = (original) => {
1953
+ if (!isLeafEncodingTransform(original))
1954
+ return () => false;
1955
+ // "Already wire" means the value DECODES as wire — a plain string that is not
1956
+ // valid base64 is a domain value for `StringFromBase64`, and is encoded.
1957
+ const decodesAsWire = Schema.decodeUnknownOption(original);
1958
+ const isType = Schema.is(Schema.make(SchemaAST.toType(original.ast)));
1959
+ const encode = Schema.encodeUnknownOption(original);
1960
+ return (value) => {
1961
+ if (decodesAsWire(value)._tag === "None" || !isType(value))
1962
+ return false;
1963
+ const encoded = encode(value);
1964
+ return encoded._tag === "Some" && !sameValue(encoded.value, value);
1965
+ };
1966
+ };
1967
+ /**
1968
+ * A path value as DynamoDB will store it: the marshaller drops `undefined`
1969
+ * object entries (`removeUndefinedValues`), so `{ opt: undefined }` is stored as
1970
+ * `{}` and must be validated as such — an `optionalKey` rejects a present
1971
+ * `undefined` that never reaches the table.
1972
+ */
1973
+ const asStored = (value) => {
1974
+ if (Array.isArray(value))
1975
+ return value.map(asStored);
1976
+ if (!isPlainObject(value))
1977
+ return value;
1978
+ const out = {};
1979
+ for (const [key, entry] of Object.entries(value)) {
1980
+ if (entry !== undefined)
1981
+ out[key] = asStored(entry);
1982
+ }
1983
+ return out;
1984
+ };
1985
+ /** A plain object or class instance whose fields a path value can be walked by. */
1986
+ const isWalkableObject = (value) => value !== null &&
1987
+ typeof value === "object" &&
1988
+ !Array.isArray(value) &&
1989
+ !(value instanceof Date) &&
1990
+ !DateTime.isDateTime(value) &&
1991
+ !Redacted.isRedacted(value);
1992
+ /**
1993
+ * Encoders for the VALUES of path-based updates (`pathSet`, `pathAppend`,
1994
+ * `pathPrepend`, `pathIfNotExists`) and record-based `append` (#133).
1995
+ *
1996
+ * Those values were marshalled as given, never encoded, so a `DateTime` set at
1997
+ * `["days", 0]` — or appended to a list of dates — was stored as a marshalled
1998
+ * `{ epochMilliseconds, … }` map, and a `NumberFromString` as a number. The
1999
+ * schema the path addresses is resolved from the model and substituted exactly
2000
+ * as the record schema substitutes it (a top-level field uses the record
2001
+ * schema's own, so a configured `storedAs` applies), and the value is encoded
2002
+ * through it.
2003
+ *
2004
+ * Passed through unchanged instead:
2005
+ * - a value already in an ambiguous wire form (see `makeAmbiguityCheck`);
2006
+ * - a value that does not encode — already wire-shaped, or a path the schema
2007
+ * cannot follow (an opaque `DynamoModel.ref`, a dynamic key).
2008
+ *
2009
+ * @internal
2010
+ */
2011
+ export const makePathValueEncoder = (modelFields, writeFields,
2012
+ /**
2013
+ * The model each `DynamoModel.ref` field is denormalised from, keyed by
2014
+ * field name. A ref field's own schema is opaque (the annotation drops its
2015
+ * `.fields`), so a path under it is followed through the target model.
2016
+ */
2017
+ refTargets = {}) => {
2018
+ const recordFields = writeFields;
2019
+ const cache = new Map();
2020
+ const encoderFor = (key, resolve) => {
2021
+ const cached = cache.get(key);
2022
+ if (cached !== undefined)
2023
+ return cached;
2024
+ const resolved = resolve();
2025
+ const fn = resolved === undefined ? null : leafAwareEncoder(resolved.original, resolved.stored);
2026
+ cache.set(key, fn);
2027
+ return fn;
2028
+ };
2029
+ /**
2030
+ * Encode through `stored`, passing an ambiguous wire value of a LEAF transform
2031
+ * through. An array of such leaves (`Array(StringFromBase64)`) is the one
2032
+ * container handled element by element, so a list of wire strings is not
2033
+ * double-encoded either; every other container is encoded whole.
2034
+ */
2035
+ const leafAwareEncoder = (originalSchema, stored) => {
2036
+ const original = optionalField(originalSchema)?.inner ?? originalSchema;
2037
+ const originalElement = listElementOf(original);
2038
+ const storedElement = listElementOf(stored);
2039
+ if (originalElement !== undefined &&
2040
+ storedElement !== undefined &&
2041
+ isLeafEncodingTransform(optionalField(originalElement)?.inner ?? originalElement)) {
2042
+ const element = leafAwareEncoder(originalElement, storedElement);
2043
+ return (value) => (Array.isArray(value) ? value.map((entry) => element(entry)) : value);
2044
+ }
2045
+ const encode = Schema.encodeUnknownOption(stored);
2046
+ const decode = Schema.decodeUnknownOption(stored);
2047
+ const ambiguous = makeAmbiguityCheck(original);
2048
+ return (value) => {
2049
+ if (ambiguous(value))
2050
+ return value;
2051
+ // A container holding an ambiguous wire leaf (`{ b64: "aGk=", at }`) is
2052
+ // encoded part by part, so that leaf follows the leaf rule instead of
2053
+ // being encoded a second time by the whole-value encode.
2054
+ if (holdsAmbiguousLeaf(original, value))
2055
+ return encodeByParts(original, value);
2056
+ const encoded = encode(value);
2057
+ if (encoded._tag === "Some")
2058
+ return encoded.value;
2059
+ // `decode -> encode`, as `.set()` / `put` do: a plain object given for a
2060
+ // class-typed field, or a value already in wire form, round-trips to its
2061
+ // wire form instead of being marshalled as given.
2062
+ const decoded = decode(value);
2063
+ if (decoded._tag === "Some") {
2064
+ const reencoded = encode(decoded.value);
2065
+ if (reencoded._tag === "Some")
2066
+ return reencoded.value;
2067
+ }
2068
+ return encodeByParts(original, value);
2069
+ };
2070
+ };
2071
+ const ambiguityChecks = new WeakMap();
2072
+ const isAmbiguousAt = (schema, value) => {
2073
+ let check = ambiguityChecks.get(schema);
2074
+ if (check === undefined) {
2075
+ check = makeAmbiguityCheck(optionalField(schema)?.inner ?? schema);
2076
+ ambiguityChecks.set(schema, check);
2077
+ }
2078
+ return check(value);
2079
+ };
2080
+ /**
2081
+ * Whether a container value holds, at any depth, a leaf in ambiguous wire
2082
+ * form. Only arrays and PLAIN objects are looked into: a class instance was
2083
+ * built from domain values by its constructor, so its leaves are domain and
2084
+ * the whole instance is encoded.
2085
+ */
2086
+ const holdsAmbiguousLeaf = (schema, value) => {
2087
+ const parts = Array.isArray(value)
2088
+ ? value.map((entry, index) => [index, entry])
2089
+ : isPlainObject(value)
2090
+ ? Object.entries(value)
2091
+ : [];
2092
+ return parts.some(([segment, entry]) => {
2093
+ const child = childAtSegment(schema, segment);
2094
+ return (child !== undefined && (isAmbiguousAt(child, entry) || holdsAmbiguousLeaf(child, entry)));
2095
+ });
2096
+ };
2097
+ /** `leafAwareEncoder` for a schema nested in a path value, memoised per schema. */
2098
+ const partEncoders = new WeakMap();
2099
+ const partEncoder = (schema) => {
2100
+ const cached = partEncoders.get(schema);
2101
+ if (cached !== undefined)
2102
+ return cached;
2103
+ const fn = leafAwareEncoder(schema, substituteSchemaDeep(schema, { enforceChecks: true }));
2104
+ partEncoders.set(schema, fn);
2105
+ return fn;
2106
+ };
2107
+ /**
2108
+ * The last resort for a container value that neither encodes nor
2109
+ * decode→encodes as a whole — one that MIXES wire and domain leaves, such as
2110
+ * `{ b64: "hi", at: DateTime, n: "5" }` (`n` is already wire, `b64` is not
2111
+ * valid base64). Each part is put into its stored form on its own, by the
2112
+ * schema at its position, with the same leaf rules; storing the value raw
2113
+ * would marshal its `DateTime` / `Date` / `Redacted` leaves as maps (#133).
2114
+ *
2115
+ * Arrays and tuples go element by element, structs, classes and records key
2116
+ * by key. A part the schema has no position for, and a value that is neither
2117
+ * an array nor a plain object or class instance, are kept as given.
2118
+ */
2119
+ const encodeByParts = (schema, value) => {
2120
+ if (Array.isArray(value)) {
2121
+ return value.map((entry, index) => {
2122
+ const child = childAtSegment(schema, index);
2123
+ return child === undefined ? entry : partEncoder(child)(entry);
2124
+ });
2125
+ }
2126
+ if (!isWalkableObject(value))
2127
+ return value;
2128
+ const out = {};
2129
+ for (const [key, entry] of Object.entries(value)) {
2130
+ const child = childAtSegment(schema, key);
2131
+ out[key] = child === undefined ? entry : partEncoder(child)(entry);
2132
+ }
2133
+ return out;
2134
+ };
2135
+ /** The model's own schema at a path, before any substitution. */
2136
+ const originalAt = (segments) => {
2137
+ const [head, ...rest] = segments;
2138
+ if (typeof head !== "string")
2139
+ return undefined;
2140
+ let current = refTargets[head] ?? modelFields[head];
2141
+ for (const segment of rest) {
2142
+ if (current === undefined)
2143
+ return undefined;
2144
+ current = childAtSegment(current, segment);
2145
+ }
2146
+ return current;
2147
+ };
2148
+ const schemaAt = (segments) => {
2149
+ const original = originalAt(segments);
2150
+ if (original === undefined)
2151
+ return undefined;
2152
+ const head = segments[0];
2153
+ const stored = segments.length === 1
2154
+ ? recordFields[head]
2155
+ : substituteSchemaDeep(original, { enforceChecks: true });
2156
+ return stored === undefined ? undefined : { original, stored };
2157
+ };
2158
+ const validators = new Map();
2159
+ const validatorFor = (key, resolve) => {
2160
+ const cached = validators.get(key);
2161
+ if (cached !== undefined)
2162
+ return cached;
2163
+ const schema = resolve();
2164
+ const fn = schema === undefined
2165
+ ? null
2166
+ : (() => {
2167
+ const decode = Schema.decodeUnknownExit(schema);
2168
+ return (encoded) => {
2169
+ const exit = decode(asStored(encoded));
2170
+ return exit._tag === "Success" ? undefined : exit.cause;
2171
+ };
2172
+ })();
2173
+ validators.set(key, fn);
2174
+ return fn;
2175
+ };
2176
+ return {
2177
+ validate: (segments, encoded) => {
2178
+ const fn = validatorFor(`v:${JSON.stringify(segments)}`, () => schemaAt(segments)?.stored);
2179
+ return fn === null ? undefined : fn(encoded);
2180
+ },
2181
+ validateElements: (segments, encoded) => {
2182
+ if (!Array.isArray(encoded))
2183
+ return undefined;
2184
+ const fn = validatorFor(`e:${JSON.stringify(segments)}`, () => {
2185
+ const list = schemaAt(segments);
2186
+ return list === undefined ? undefined : listElementOf(list.stored);
2187
+ });
2188
+ if (fn === null)
2189
+ return undefined;
2190
+ for (const entry of encoded) {
2191
+ const issue = fn(entry);
2192
+ if (issue !== undefined)
2193
+ return issue;
2194
+ }
2195
+ return undefined;
2196
+ },
2197
+ value: (segments, value) => {
2198
+ const fn = encoderFor(`v:${JSON.stringify(segments)}`, () => schemaAt(segments));
2199
+ return fn === null ? value : fn(value);
2200
+ },
2201
+ elements: (segments, value) => {
2202
+ if (!Array.isArray(value))
2203
+ return value;
2204
+ const fn = encoderFor(`e:${JSON.stringify(segments)}`, () => {
2205
+ const list = schemaAt(segments);
2206
+ if (list === undefined)
2207
+ return undefined;
2208
+ const original = listElementOf(list.original);
2209
+ const stored = listElementOf(list.stored);
2210
+ return original === undefined || stored === undefined ? undefined : { original, stored };
2211
+ });
2212
+ return fn === null ? value : value.map((entry) => fn(entry));
2213
+ },
2214
+ };
2215
+ };
1145
2216
  //# sourceMappingURL=EntitySchemas.js.map