effect-dynamodb 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Extracted from Entity.ts for decomposition. Not part of the public API.
5
5
  */
6
- import { Schema, SchemaAST } from "effect";
6
+ import { DateTime, Effect, Option, Redacted, Schema, SchemaAST, SchemaGetter, SchemaIssue, } from "effect";
7
7
  import { getEncoding, isHidden } from "../DynamoModel.js";
8
8
  /**
9
9
  * Resolve the effective DynamoEncoding for a model-declared field, applying
@@ -213,62 +213,382 @@ export const inferDefaultEncoding = (schema) => {
213
213
  return { storage: "string", domain: "Date" };
214
214
  return undefined;
215
215
  };
216
+ // ---------------------------------------------------------------------------
217
+ // Self vs. transform schema detection
218
+ // ---------------------------------------------------------------------------
219
+ /**
220
+ * Returns true when the schema is a "self" schema — i.e. it carries no
221
+ * encoding transformation. Self schemas have `ast.encoding === undefined`.
222
+ *
223
+ * - `Schema.DateTimeUtc` — self
224
+ * - `Schema.DateTimeUtcFromString` — transform (encodes to string)
225
+ * - `Schema.RedactedFromValue(...)` — transform
226
+ * - any `.pipe(Schema.decodeTo(...))` chain — transform
227
+ */
228
+ export const isSelfSchema = (schema) => schema.ast.encoding === undefined;
229
+ /**
230
+ * True if the schema is a self DateTime/Date schema that can have its storage
231
+ * configured via a DynamoEncoding annotation. Distinguished from a transform
232
+ * by `ast.encoding === undefined`.
233
+ */
234
+ export const isSelfDateSchema = (schema) => {
235
+ if (!isSelfSchema(schema))
236
+ return false;
237
+ // The self-schema check is necessary but not sufficient — also confirm the
238
+ // type constructor matches one of the supported date domains.
239
+ return inferDefaultEncoding(schema) !== undefined;
240
+ };
241
+ /**
242
+ * Returns true when the schema is a transform schema with a date typeConstructor
243
+ * (e.g. `Schema.DateTimeUtcFromString`, `DynamoModel.DateEpochSeconds`).
244
+ * Used to enforce the "transform owns the wire format" policy at Entity.make().
245
+ */
246
+ export const isDateTransform = (schema) => {
247
+ if (isSelfSchema(schema))
248
+ return false;
249
+ return inferDefaultEncoding(schema) !== undefined;
250
+ };
251
+ // ---------------------------------------------------------------------------
252
+ // Self-date schema substitution
253
+ // ---------------------------------------------------------------------------
216
254
  /**
217
- * Map a DynamoEncoding domain type to its "from self" Schema.
218
- * Used in record/item schemas so Schema.decode accepts domain values
219
- * directly (after deserializeDateFields has converted storage→domain).
255
+ * Convert any input value (string / number / DateTime / Date) to the wire
256
+ * primitive for a given encoding. Used by date substitutes' `encode` path.
257
+ *
258
+ * Wire format precisely matches the legacy `serializeDateForDynamo` output:
259
+ * - `string` storage → ISO 8601 string (UTC for DateTime.Utc / Date; extended
260
+ * format for DateTime.Zoned)
261
+ * - `epochMs` storage → integer milliseconds since the Unix epoch
262
+ * - `epochSeconds` storage → integer seconds since the Unix epoch (TTL format)
220
263
  */
221
- const dateFromSelfSchema = (encoding) => {
222
- switch (encoding.domain) {
223
- case "DateTime.Utc":
224
- return Schema.DateTimeUtc;
225
- case "DateTime.Zoned":
226
- return Schema.DateTimeZoned;
227
- case "Date":
228
- return Schema.DateValid;
264
+ const toWirePrimitive = (value, encoding) => {
265
+ let epochMs;
266
+ let zoned;
267
+ if (DateTime.isDateTime(value)) {
268
+ if (DateTime.isZoned(value))
269
+ zoned = value;
270
+ epochMs = DateTime.toEpochMillis(value);
271
+ }
272
+ else if (value instanceof Date) {
273
+ epochMs = value.getTime();
274
+ }
275
+ else if (typeof value === "string") {
276
+ epochMs = new Date(value).getTime();
277
+ }
278
+ else if (typeof value === "number") {
279
+ // Numeric inputs are interpreted in the encoding's storage scale already.
280
+ if (encoding.storage === "epochSeconds")
281
+ epochMs = value * 1000;
282
+ else
283
+ epochMs = value;
284
+ }
285
+ else {
286
+ throw new Error(`[effect-dynamodb] toWirePrimitive: unsupported input ${typeof value} for ${encoding.domain}/${encoding.storage}`);
287
+ }
288
+ switch (encoding.storage) {
289
+ case "string":
290
+ if (encoding.domain === "DateTime.Zoned" && zoned)
291
+ return DateTime.formatIsoZoned(zoned);
292
+ return new Date(epochMs).toISOString();
293
+ case "epochMs":
294
+ return epochMs;
295
+ case "epochSeconds":
296
+ return Math.floor(epochMs / 1000);
229
297
  }
230
298
  };
231
- export const buildDerivedSchemas = (modelFields, indexes, systemFields, resolvedRefs = [], immutableFields = new Set(), identifierField = undefined, timeSeries = undefined) => {
232
- // --- Build fromSelf field map for date fields ---
233
- // These replace transform schemas with their "from self" variants so
234
- // Schema.decode accepts domain values after storage→domain conversion.
235
- // Checks both explicit DynamoEncoding annotations AND inferred defaults
236
- // from standard Effect date schemas (Pattern B: pure model).
237
- const fromSelfFields = {};
238
- for (const [fieldName, fieldSchema] of Object.entries(modelFields)) {
239
- const encoding = getEncoding(fieldSchema) ?? inferDefaultEncoding(fieldSchema);
240
- if (encoding) {
241
- fromSelfFields[fieldName] = dateFromSelfSchema(encoding);
299
+ /**
300
+ * Build a bidirectional Schema for a given DynamoEncoding.
301
+ *
302
+ * The encoded side is `Schema.Any` so `decode` tolerates either:
303
+ * - the wire primitive (string / number) — the storage-shape input, OR
304
+ * - a domain value (`DateTime.Utc`, `Date`, etc.) — what users typically pass
305
+ * into `entity.put({...})` and `.set({...})`.
306
+ *
307
+ * The `decode` direction always lifts to the domain type (DateTime.Utc /
308
+ * DateTime.Zoned / Date). The `encode` direction always produces the wire
309
+ * primitive. This makes both `Schema.encode(substituted)` and
310
+ * `Schema.decode(substituted)` work end-to-end regardless of whether the
311
+ * caller passed wire-form or domain-form.
312
+ *
313
+ * @internal
314
+ */
315
+ export const buildDateTransform = (encoding) => {
316
+ const targetSchema = (() => {
317
+ switch (encoding.domain) {
318
+ case "DateTime.Utc":
319
+ return Schema.DateTimeUtc;
320
+ case "DateTime.Zoned":
321
+ return Schema.DateTimeZoned;
322
+ case "Date":
323
+ return Schema.DateValid;
324
+ }
325
+ })();
326
+ const liftToDomain = (value) => {
327
+ if (DateTime.isDateTime(value))
328
+ return value;
329
+ if (value instanceof Date) {
330
+ switch (encoding.domain) {
331
+ case "DateTime.Utc":
332
+ return DateTime.makeUnsafe(value);
333
+ case "DateTime.Zoned":
334
+ return DateTime.makeZonedUnsafe(value, { timeZone: "UTC" });
335
+ case "Date":
336
+ return value;
337
+ }
338
+ }
339
+ if (typeof value === "string") {
340
+ switch (encoding.domain) {
341
+ case "DateTime.Utc":
342
+ return DateTime.makeUnsafe(value);
343
+ case "DateTime.Zoned": {
344
+ const match = value.match(/^(.+)\[(.+)\]$/);
345
+ if (match) {
346
+ const utc = DateTime.makeUnsafe(match[1]);
347
+ return DateTime.makeZonedUnsafe(utc, { timeZone: match[2] });
348
+ }
349
+ const utc = DateTime.makeUnsafe(value);
350
+ return DateTime.makeZonedUnsafe(utc, { timeZone: "UTC" });
351
+ }
352
+ case "Date":
353
+ return new Date(value);
354
+ }
355
+ }
356
+ if (typeof value === "number") {
357
+ const ms = encoding.storage === "epochSeconds" ? value * 1000 : value;
358
+ switch (encoding.domain) {
359
+ case "DateTime.Utc":
360
+ return DateTime.makeUnsafe(ms);
361
+ case "DateTime.Zoned":
362
+ return DateTime.makeZonedUnsafe(new Date(ms), { timeZone: "UTC" });
363
+ case "Date":
364
+ return new Date(ms);
365
+ }
366
+ }
367
+ return value;
368
+ };
369
+ return Schema.Any.pipe(Schema.decodeTo(targetSchema, {
370
+ decode: SchemaGetter.transformOrFail((value) => {
371
+ try {
372
+ return Effect.succeed(liftToDomain(value));
373
+ }
374
+ catch {
375
+ return Effect.fail(new SchemaIssue.InvalidType(Schema.Any.ast, Option.some(value)));
376
+ }
377
+ }),
378
+ encode: SchemaGetter.transformOrFail((value) => {
379
+ try {
380
+ return Effect.succeed(toWirePrimitive(value, encoding));
381
+ }
382
+ catch {
383
+ return Effect.fail(new SchemaIssue.InvalidType(Schema.Any.ast, Option.some(value)));
384
+ }
385
+ }),
386
+ }));
387
+ };
388
+ /**
389
+ * Detects `Schema.RedactedFromValue(inner)` fields (and `Schema.Redacted`).
390
+ * The encoded form of these schemas is "Forbidden" by Effect v4 design —
391
+ * encoding bails out to prevent accidental leaks. To make the round-trip work
392
+ * for our use case (write to DynamoDB, read back with Redacted on the domain
393
+ * side), we substitute with a custom transform that explicitly extracts the
394
+ * inner value via `Redacted.value` on encode and rewraps via `Redacted.make`
395
+ * on decode.
396
+ *
397
+ * Returns the inner schema (the wire-format schema) when the field is a
398
+ * Redacted-typed schema, otherwise undefined.
399
+ */
400
+ const tryGetRedactedInner = (schema) => {
401
+ const resolved = SchemaAST.resolve(schema.ast);
402
+ if (resolved?.typeConstructor?._tag !== "effect/Redacted")
403
+ return undefined;
404
+ // The decoded type is `Redacted<T>` where the inner schema is at
405
+ // `ast.typeParameters[0]`. Walk the AST and rebuild the Schema via
406
+ // `Schema.make` so we get a fully-functional Schema (with `.pipe`).
407
+ const ast = schema.ast;
408
+ const params = ast.typeParameters;
409
+ if (!params || params.length === 0)
410
+ return undefined;
411
+ const innerAst = params[0];
412
+ return Schema.make(innerAst);
413
+ };
414
+ /**
415
+ * Build a substitute Schema for a `Schema.RedactedFromValue(inner)` field.
416
+ *
417
+ * The substitute round-trips: produces `Redacted<T>` on the domain side and
418
+ * the inner wire primitive on the encoded side. Both directions tolerate
419
+ * either form on input — a user passing a plain inner value is wrapped via
420
+ * `Redacted.make`; a user passing a `Redacted<T>` instance is unwrapped on
421
+ * encode via `Redacted.value`. This permissive shape lets the put/update
422
+ * paths handle the mixed wire/domain payloads users typically construct
423
+ * (e.g. `.put({ secret: Redacted.make(...) })`).
424
+ *
425
+ * The encoded side is `Schema.Any` so decode tolerates either form; the
426
+ * branch logic in `decode`/`encode` then steers the value to the right
427
+ * representation.
428
+ */
429
+ const buildRedactedSubstitute = (inner) => {
430
+ return Schema.Any.pipe(Schema.decodeTo(Schema.Redacted(inner), {
431
+ decode: SchemaGetter.transform((value) => Redacted.isRedacted(value) ? value : Redacted.make(value)),
432
+ encode: SchemaGetter.transform((r) => Redacted.isRedacted(r) ? Redacted.value(r) : r),
433
+ }));
434
+ };
435
+ /**
436
+ * Substitute model fields with bidirectional transforms where needed:
437
+ *
438
+ * - Self date schemas with an effective encoding (annotation or override)
439
+ * → tolerant date transform that accepts either wire form (string/number)
440
+ * or domain form (DateTime / Date) on decode, and always produces the
441
+ * configured wire primitive on encode.
442
+ * - `Schema.RedactedFromValue(inner)` fields → tolerant Redacted transform
443
+ * that accepts either form on decode and unwraps to wire primitive on
444
+ * encode (Effect v4's `RedactedFromValue` forbids encoding by default;
445
+ * our substitution makes the round-trip work for storage).
446
+ *
447
+ * Existing transform schemas (other than `RedactedFromValue`) are passed
448
+ * through unchanged — the user-declared transform IS the wire format.
449
+ *
450
+ * Schema.Class fields (and Schema.Class fields nested inside Schema.Array,
451
+ * Schema.optional, etc.) are NOT substituted here. Instead, callers run
452
+ * `decode → encode` against the substituted schema: `Schema.decode` is
453
+ * forgiving for Schema.Class (lifts plain objects to instances), and the
454
+ * tolerant date / Redacted substitutes make the decode pass accept both
455
+ * wire and domain values. The subsequent `Schema.encode` produces the
456
+ * canonical wire shape.
457
+ *
458
+ * @internal
459
+ */
460
+ export const substituteSchemas = (modelFields, fieldEncodings) => {
461
+ const out = {};
462
+ for (const [name, schema] of Object.entries(modelFields)) {
463
+ // 1. Self-date substitution. Pattern A: user declared `Schema.DateTimeUtc`
464
+ // and chose a wire format via annotation. We substitute with the
465
+ // tolerant date transform so the encode pipeline produces the right
466
+ // wire primitive.
467
+ if (isSelfDateSchema(schema)) {
468
+ const enc = fieldEncodings[name];
469
+ if (enc) {
470
+ out[name] = buildDateTransform(enc);
471
+ continue;
472
+ }
473
+ }
474
+ // 2. RedactedFromValue substitution. Effect v4's `RedactedFromValue`
475
+ // forbids encoding by design; substitute with a tolerant Redacted
476
+ // transform so the round-trip works for storage.
477
+ const redactedInner = tryGetRedactedInner(schema);
478
+ if (redactedInner !== undefined) {
479
+ out[name] = buildRedactedSubstitute(redactedInner);
480
+ continue;
242
481
  }
482
+ // Default: pass through unchanged. Pattern B transforms (DynamoModel.*,
483
+ // Schema.DateTimeUtcFromString, Schema.NumberFromString, …) own their
484
+ // wire format directly — `Schema.encode` handles the conversion.
485
+ out[name] = schema;
243
486
  }
244
- // --- Model Schema: pure model fields (accepts wire format for input decode) ---
245
- const modelSchema = Schema.Struct(modelFields);
246
- // --- Model Decode Schema: model fields with fromSelf for date-annotated fields ---
247
- // Used in decodeAs after deserializeDateFields has converted storage→domain.
248
- const modelDecodeSchema = Object.keys(fromSelfFields).length > 0
249
- ? Schema.Struct({ ...modelFields, ...fromSelfFields })
250
- : modelSchema;
487
+ return out;
488
+ };
489
+ /**
490
+ * Validate that no model field combines a transform schema with a storage
491
+ * override. The two configuration paths are mutually exclusive: either declare
492
+ * a self schema (`Schema.DateTimeUtc`) and let the annotation drive storage,
493
+ * OR declare a transform and own the wire format yourself — not both.
494
+ *
495
+ * Two override sources are checked:
496
+ *
497
+ * 1. `ConfiguredModel.storedAs` (configured attributes) — surfaces the
498
+ * override via `configuredAttributes[name].encoding`.
499
+ *
500
+ * 2. `DynamoModel.storedAs(...)` modifier piped onto the field schema —
501
+ * adds a `DynamoEncoding` annotation directly to the AST. We detect this
502
+ * by checking whether the field-level annotation disagrees with the
503
+ * transform's actual wire format (the `inferDefaultEncoding` of the
504
+ * transform). For an unmodified transform (e.g. `DynamoModel.DateString`
505
+ * with a matching ISO-string annotation), the two agree and the field is
506
+ * accepted.
507
+ *
508
+ * Throws at `Entity.make()` time if the conflict is detected.
509
+ *
510
+ * @internal
511
+ */
512
+ export const validateNoTransformOverride = (modelFields, configuredAttributes) => {
513
+ const conflictMessage = (name) => `[effect-dynamodb] Field "${name}": cannot apply DynamoEncoding storage override to a transform schema. ` +
514
+ `Either declare a self schema (Schema.DateTimeUtc) and let the annotation drive storage, OR declare a transform and own the wire format — not both.`;
515
+ for (const [name, schema] of Object.entries(modelFields)) {
516
+ if (!isDateTransform(schema))
517
+ continue;
518
+ // 1. ConfiguredModel.storedAs override?
519
+ const configOverride = configuredAttributes[name]?.encoding;
520
+ if (configOverride !== undefined) {
521
+ throw new Error(conflictMessage(name));
522
+ }
523
+ // 2. DynamoModel.storedAs() modifier on the schema itself? The modifier
524
+ // applies a `DynamoEncoding` annotation to the AST. Detect a conflict
525
+ // by comparing the annotation's storage with the transform's actual
526
+ // wire format (read from `ast.encoding[last].to._tag`). Granularity:
527
+ // we can distinguish "string" vs "number" wire forms; we cannot
528
+ // distinguish epochMs vs epochSeconds at this layer. The string-vs-
529
+ // number mismatch covers the most common breaking case.
530
+ const annotated = getEncoding(schema);
531
+ if (annotated === undefined)
532
+ continue;
533
+ const transformWire = transformWireKind(schema);
534
+ if (transformWire === undefined)
535
+ continue;
536
+ const annotatedKind = annotated.storage === "string" ? "string" : "number";
537
+ if (transformWire !== annotatedKind) {
538
+ throw new Error(conflictMessage(name));
539
+ }
540
+ }
541
+ };
542
+ /**
543
+ * Read the wire form ("string" or "number") that a transform schema actually
544
+ * encodes to, by walking the encoding chain. Returns undefined when the chain
545
+ * is missing or the leaf isn't a String / Number node.
546
+ */
547
+ const transformWireKind = (schema) => {
548
+ const enc = schema.ast.encoding;
549
+ if (!enc || enc.length === 0)
550
+ return undefined;
551
+ const leaf = enc[enc.length - 1];
552
+ if (!leaf)
553
+ return undefined;
554
+ const tag = leaf.to._tag;
555
+ if (tag === "String")
556
+ return "string";
557
+ if (tag === "Number")
558
+ return "number";
559
+ return undefined;
560
+ };
561
+ export const buildDerivedSchemas = (modelFields, indexes, systemFields, resolvedRefs = [], immutableFields = new Set(), identifierField = undefined, timeSeries = undefined, fieldEncodings = {}) => {
562
+ // --- Substitute model fields ---
563
+ // Self date schemas (with effective encoding) are replaced with bidirectional
564
+ // transforms that produce the configured wire primitive. RedactedFromValue
565
+ // fields are replaced with a custom bidirectional transform so encoding
566
+ // round-trips. All other transform schemas are passed through — the user's
567
+ // transform IS the wire format and we never override it.
568
+ const fields = substituteSchemas(modelFields, fieldEncodings);
569
+ // --- Model Schema: pure model fields (for input decode/encode) ---
570
+ const modelSchema = Schema.Struct(fields);
251
571
  // --- Record Schema: model + system fields ---
252
- // For date-annotated fields, use "from self" schemas (accept domain values directly)
572
+ // System timestamp fields use Schema.DateTimeUtcFromString as the canonical
573
+ // wire ↔ domain transform when they're library-managed. When the field
574
+ // collides with a model-declared field, the model's substituted schema is
575
+ // already in `fields` and we don't add a system-schema entry.
253
576
  const systemSchemaFields = {};
254
- if (systemFields.createdAt) {
255
- // Custom encoding → use fromSelf (deserializeDateFields handles storage→domain)
256
- // Default → use DateTimeUtcFromString (ISO string → DateTime.Utc)
577
+ if (systemFields.createdAt && !systemFields.createdAtCollision) {
257
578
  systemSchemaFields[systemFields.createdAt] = systemFields.createdAtEncoding
258
- ? Schema.DateTimeUtc
579
+ ? buildDateTransform(systemFields.createdAtEncoding)
259
580
  : Schema.DateTimeUtcFromString;
260
581
  }
261
- if (systemFields.updatedAt) {
582
+ if (systemFields.updatedAt && !systemFields.updatedAtCollision) {
262
583
  systemSchemaFields[systemFields.updatedAt] = systemFields.updatedAtEncoding
263
- ? Schema.DateTimeUtc
584
+ ? buildDateTransform(systemFields.updatedAtEncoding)
264
585
  : Schema.DateTimeUtcFromString;
265
586
  }
266
- if (systemFields.version) {
587
+ if (systemFields.version && !systemFields.versionCollision) {
267
588
  systemSchemaFields[systemFields.version] = Schema.Number;
268
589
  }
269
590
  const recordSchema = Schema.Struct({
270
- ...modelFields,
271
- ...fromSelfFields,
591
+ ...fields,
272
592
  ...systemSchemaFields,
273
593
  });
274
594
  // --- System-collision handling for input/update schemas ---
@@ -300,13 +620,12 @@ export const buildDerivedSchemas = (modelFields, indexes, systemFields, resolved
300
620
  };
301
621
  // --- Input Schema: ref-aware (swap ref fields for ID fields when refs present) ---
302
622
  const refFieldSet = new Set(resolvedRefs.map((r) => r.fieldName));
303
- const buildInputFieldsAcc = (dateVariant) => {
623
+ const buildInputFieldsAcc = () => {
304
624
  const acc = {};
305
- for (const [key, field] of Object.entries(modelFields)) {
625
+ for (const [key, field] of Object.entries(fields)) {
306
626
  if (refFieldSet.has(key))
307
627
  continue;
308
- const base = dateVariant === "fromSelf" && fromSelfFields[key] ? fromSelfFields[key] : field;
309
- const adjusted = applySystemCollisionAdjustments(key, base, "input");
628
+ const adjusted = applySystemCollisionAdjustments(key, field, "input");
310
629
  if (adjusted !== null)
311
630
  acc[key] = adjusted;
312
631
  }
@@ -315,13 +634,12 @@ export const buildDerivedSchemas = (modelFields, indexes, systemFields, resolved
315
634
  }
316
635
  return acc;
317
636
  };
318
- const inputSchema = Schema.Struct(buildInputFieldsAcc("transform"));
319
- const inputDecodeSchema = Schema.Struct(buildInputFieldsAcc("fromSelf"));
637
+ const inputSchema = Schema.Struct(buildInputFieldsAcc());
320
638
  // --- Create Schema: input fields minus identifier (or PK composites as fallback) ---
321
639
  const pkComposites = new Set(primaryKeyComposites(indexes));
322
640
  const createOmitFields = identifierField ? new Set([identifierField]) : pkComposites;
323
641
  const createFieldsAcc = {};
324
- for (const [key, field] of Object.entries(modelFields)) {
642
+ for (const [key, field] of Object.entries(fields)) {
325
643
  if (createOmitFields.has(key))
326
644
  continue; // skip identifier/PK composites (auto-generated)
327
645
  if (refFieldSet.has(key))
@@ -338,9 +656,9 @@ export const buildDerivedSchemas = (modelFields, indexes, systemFields, resolved
338
656
  }
339
657
  const createSchema = Schema.Struct(createFieldsAcc);
340
658
  // --- Update Schema: optional model fields minus key composites & immutable ---
341
- const buildUpdateFieldsAcc = (dateVariant) => {
659
+ const buildUpdateFieldsAcc = () => {
342
660
  const acc = {};
343
- for (const [key, field] of Object.entries(modelFields)) {
661
+ for (const [key, field] of Object.entries(fields)) {
344
662
  if (pkComposites.has(key))
345
663
  continue;
346
664
  if (immutableFields.has(key))
@@ -349,8 +667,7 @@ export const buildDerivedSchemas = (modelFields, indexes, systemFields, resolved
349
667
  continue;
350
668
  if (strippedFromUpdate.has(key))
351
669
  continue;
352
- const base = dateVariant === "fromSelf" && fromSelfFields[key] ? fromSelfFields[key] : field;
353
- acc[key] = Schema.optional(base);
670
+ acc[key] = Schema.optional(field);
354
671
  }
355
672
  for (const ref of resolvedRefs) {
356
673
  if (pkComposites.has(ref.fieldName))
@@ -361,13 +678,12 @@ export const buildDerivedSchemas = (modelFields, indexes, systemFields, resolved
361
678
  }
362
679
  return acc;
363
680
  };
364
- const updateSchema = Schema.Struct(buildUpdateFieldsAcc("transform"));
365
- const updateDecodeSchema = Schema.Struct(buildUpdateFieldsAcc("fromSelf"));
681
+ const updateSchema = Schema.Struct(buildUpdateFieldsAcc());
366
682
  // --- Key Schema: primary key composite attributes only ---
367
683
  const keyCompositeNames = primaryKeyComposites(indexes);
368
684
  const keyFields = {};
369
685
  for (const name of keyCompositeNames) {
370
- const field = modelFields[name];
686
+ const field = fields[name];
371
687
  if (field)
372
688
  keyFields[name] = field;
373
689
  }
@@ -391,16 +707,14 @@ export const buildDerivedSchemas = (modelFields, indexes, systemFields, resolved
391
707
  : Schema.optional(Schema.String);
392
708
  }
393
709
  const itemSchema = Schema.Struct({
394
- ...modelFields,
395
- ...fromSelfFields,
710
+ ...fields,
396
711
  ...systemSchemaFields,
397
712
  ...keyAttrFields,
398
713
  __edd_e__: Schema.String,
399
714
  });
400
715
  // --- Deleted Record Schema: record + deletedAt ---
401
716
  const deletedRecordSchema = Schema.Struct({
402
- ...modelFields,
403
- ...fromSelfFields,
717
+ ...fields,
404
718
  ...systemSchemaFields,
405
719
  deletedAt: Schema.String,
406
720
  });
@@ -413,47 +727,50 @@ export const buildDerivedSchemas = (modelFields, indexes, systemFields, resolved
413
727
  hiddenFieldNames.add(fieldName);
414
728
  }
415
729
  const hasHiddenFields = hiddenFieldNames.size > 0;
416
- const modelDecodeVisibleSchema = hasHiddenFields
417
- ? Schema.Struct(Object.fromEntries(Object.entries({ ...modelFields, ...fromSelfFields }).filter(([k]) => !hiddenFieldNames.has(k))))
418
- : modelDecodeSchema;
730
+ const modelVisibleSchema = hasHiddenFields
731
+ ? Schema.Struct(Object.fromEntries(Object.entries(fields).filter(([k]) => !hiddenFieldNames.has(k))))
732
+ : modelSchema;
419
733
  const recordVisibleSchema = hasHiddenFields
420
734
  ? Schema.Struct(Object.fromEntries(Object.entries({
421
- ...modelFields,
422
- ...fromSelfFields,
735
+ ...fields,
423
736
  ...systemSchemaFields,
424
737
  }).filter(([k]) => !hiddenFieldNames.has(k))))
425
738
  : recordSchema;
426
739
  // --- Time-series derived schemas ---
427
- // `appendInputSchema`: the user-supplied schema directly. `Entity.make()`
740
+ // `appendInputSchema`: the user-supplied schema, with the same substitution
741
+ // rules applied as the model fields (self date schemas → tolerant
742
+ // transforms, RedactedFromValue → tolerant Redacted). `Entity.make()`
428
743
  // enforces that every PK/SK composite + `orderBy` appears in its fields.
429
- // `historyRecordSchema`: event-item shape. Model fields (with fromSelf for
430
- // date-annotated) + createdAt (when configured, no updatedAt ever since
431
- // timeSeries auto-suppresses). No GSI key attrs — events strip GSI keys.
744
+ // `historyRecordSchema`: event-item shape. Substituted model fields + no
745
+ // system fields (events are point-in-time facts keyed by `orderBy`).
432
746
  const timeSeriesEnabled = timeSeries !== undefined && timeSeries !== null;
433
- const appendInputSchema = timeSeriesEnabled ? timeSeries.appendInput : null;
434
- // Events are point-in-time facts keyed by `orderBy`. They do NOT carry
435
- // `createdAt` or `updatedAt` (§4.5 — events already carry `orderBy` as the
436
- // temporal anchor). Schema is just model fields with fromSelf for dates.
747
+ const appendInputSchema = timeSeriesEnabled
748
+ ? (() => {
749
+ const userSchema = timeSeries.appendInput;
750
+ const userFields = userSchema.fields;
751
+ if (!userFields)
752
+ return userSchema;
753
+ // Compute encodings for the appendInput fields (annotation +
754
+ // inferred from typeConstructor for self schemas).
755
+ const appendEncodings = buildFieldEncodings(userFields, {});
756
+ const subbedFields = substituteSchemas(userFields, appendEncodings);
757
+ return Schema.Struct(subbedFields);
758
+ })()
759
+ : null;
437
760
  const historyRecordSchema = timeSeriesEnabled
438
761
  ? (() => {
439
762
  const hiddenFilter = (record) => hasHiddenFields
440
763
  ? Object.fromEntries(Object.entries(record).filter(([k]) => !hiddenFieldNames.has(k)))
441
764
  : record;
442
- return Schema.Struct(hiddenFilter({
443
- ...modelFields,
444
- ...fromSelfFields,
445
- }));
765
+ return Schema.Struct(hiddenFilter({ ...fields }));
446
766
  })()
447
767
  : null;
448
768
  return {
449
- modelSchema: modelSchema,
450
- modelDecodeSchema: modelDecodeVisibleSchema,
769
+ modelSchema: modelVisibleSchema,
451
770
  recordSchema: recordVisibleSchema,
452
771
  inputSchema: inputSchema,
453
- inputDecodeSchema: inputDecodeSchema,
454
772
  createSchema: createSchema,
455
773
  updateSchema: updateSchema,
456
- updateDecodeSchema: updateDecodeSchema,
457
774
  keySchema: keySchema,
458
775
  itemSchema: itemSchema,
459
776
  deletedRecordSchema: deletedRecordSchema,