@effect-dynamodb/schema 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/dist/Aggregate.d.ts +128 -0
  4. package/dist/Aggregate.d.ts.map +1 -0
  5. package/dist/Aggregate.js +69 -0
  6. package/dist/Aggregate.js.map +1 -0
  7. package/dist/DynamoModel.d.ts +370 -0
  8. package/dist/DynamoModel.d.ts.map +1 -0
  9. package/dist/DynamoModel.js +541 -0
  10. package/dist/DynamoModel.js.map +1 -0
  11. package/dist/DynamoSchema.d.ts +137 -0
  12. package/dist/DynamoSchema.d.ts.map +1 -0
  13. package/dist/DynamoSchema.js +188 -0
  14. package/dist/DynamoSchema.js.map +1 -0
  15. package/dist/Entity.d.ts +239 -0
  16. package/dist/Entity.d.ts.map +1 -0
  17. package/dist/Entity.js +558 -0
  18. package/dist/Entity.js.map +1 -0
  19. package/dist/Errors.d.ts +387 -0
  20. package/dist/Errors.d.ts.map +1 -0
  21. package/dist/Errors.js +218 -0
  22. package/dist/Errors.js.map +1 -0
  23. package/dist/KeyComposer.d.ts +264 -0
  24. package/dist/KeyComposer.d.ts.map +1 -0
  25. package/dist/KeyComposer.js +516 -0
  26. package/dist/KeyComposer.js.map +1 -0
  27. package/dist/Projection.d.ts +28 -0
  28. package/dist/Projection.d.ts.map +1 -0
  29. package/dist/Projection.js +39 -0
  30. package/dist/Projection.js.map +1 -0
  31. package/dist/index.d.ts +32 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +26 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/internal/AggregateCursor.d.ts +44 -0
  36. package/dist/internal/AggregateCursor.d.ts.map +1 -0
  37. package/dist/internal/AggregateCursor.js +18 -0
  38. package/dist/internal/AggregateCursor.js.map +1 -0
  39. package/dist/internal/AggregateEdges.d.ts +117 -0
  40. package/dist/internal/AggregateEdges.d.ts.map +1 -0
  41. package/dist/internal/AggregateEdges.js +51 -0
  42. package/dist/internal/AggregateEdges.js.map +1 -0
  43. package/dist/internal/AggregateSchemas.d.ts +76 -0
  44. package/dist/internal/AggregateSchemas.d.ts.map +1 -0
  45. package/dist/internal/AggregateSchemas.js +164 -0
  46. package/dist/internal/AggregateSchemas.js.map +1 -0
  47. package/dist/internal/AggregateTypes.d.ts +132 -0
  48. package/dist/internal/AggregateTypes.d.ts.map +1 -0
  49. package/dist/internal/AggregateTypes.js +7 -0
  50. package/dist/internal/AggregateTypes.js.map +1 -0
  51. package/dist/internal/DefaultCrypto.d.ts +26 -0
  52. package/dist/internal/DefaultCrypto.d.ts.map +1 -0
  53. package/dist/internal/DefaultCrypto.js +40 -0
  54. package/dist/internal/DefaultCrypto.js.map +1 -0
  55. package/dist/internal/EntityConfig.d.ts +152 -0
  56. package/dist/internal/EntityConfig.d.ts.map +1 -0
  57. package/dist/internal/EntityConfig.js +7 -0
  58. package/dist/internal/EntityConfig.js.map +1 -0
  59. package/dist/internal/EntitySchemas.d.ts +198 -0
  60. package/dist/internal/EntitySchemas.d.ts.map +1 -0
  61. package/dist/internal/EntitySchemas.js +798 -0
  62. package/dist/internal/EntitySchemas.js.map +1 -0
  63. package/dist/internal/EntityTypes.d.ts +233 -0
  64. package/dist/internal/EntityTypes.d.ts.map +1 -0
  65. package/dist/internal/EntityTypes.js +7 -0
  66. package/dist/internal/EntityTypes.js.map +1 -0
  67. package/dist/internal/SchemaAccessors.d.ts +90 -0
  68. package/dist/internal/SchemaAccessors.d.ts.map +1 -0
  69. package/dist/internal/SchemaAccessors.js +171 -0
  70. package/dist/internal/SchemaAccessors.js.map +1 -0
  71. package/package.json +70 -0
@@ -0,0 +1,798 @@
1
+ /**
2
+ * @internal Entity schema building — derived schemas and system field resolution.
3
+ *
4
+ * Extracted from Entity.ts for decomposition. Not part of the public API.
5
+ */
6
+ import { DateTime, Effect, Option, Redacted, Schema, SchemaAST, SchemaGetter, SchemaIssue, } from "effect";
7
+ import { getEncoding, isHidden } from "../DynamoModel.js";
8
+ import { firstTypeParameterAst, getSchemaFields, transformWireKind as transformWireKindImpl, } from "./SchemaAccessors.js";
9
+ /**
10
+ * Resolve the effective DynamoEncoding for a model-declared field, applying
11
+ * ConfiguredModel storage overrides on top of schema-level annotations.
12
+ *
13
+ * Precedence: schema annotation (getEncoding) > inferred default (Effect date
14
+ * schemas) > none. ConfiguredModel `storedAs` then overrides storage only,
15
+ * preserving the domain from the schema side when present.
16
+ *
17
+ * Returns `null` if the field is not a date-compatible schema.
18
+ */
19
+ export const resolveFieldEncoding = (fieldSchema, override) => {
20
+ let encoding = getEncoding(fieldSchema) ?? inferDefaultEncoding(fieldSchema) ?? null;
21
+ if (override) {
22
+ encoding = {
23
+ storage: override.storage,
24
+ domain: encoding?.domain ?? override.domain,
25
+ };
26
+ }
27
+ return encoding;
28
+ };
29
+ /**
30
+ * Build the field → DynamoEncoding map for a set of model fields, merging
31
+ * ConfiguredModel storage overrides. Used at `Entity.make()` time.
32
+ */
33
+ export const buildFieldEncodings = (modelFields, configuredAttributes) => {
34
+ const encodings = {};
35
+ for (const [fieldName, fieldSchema] of Object.entries(modelFields)) {
36
+ const encoding = resolveFieldEncoding(fieldSchema, configuredAttributes[fieldName]?.encoding);
37
+ if (encoding)
38
+ encodings[fieldName] = encoding;
39
+ }
40
+ return encodings;
41
+ };
42
+ /** Check if a value is a Schema (has .ast property) vs a plain config object */
43
+ const isSchema = (value) => typeof value === "object" && value !== null && "ast" in value;
44
+ /**
45
+ * Resolve a single TimestampFieldConfig into field name + encoding.
46
+ * Returns [fieldName, encoding | null].
47
+ */
48
+ const resolveTimestampField = (config, defaultName) => {
49
+ if (config === undefined)
50
+ return [defaultName, null];
51
+ if (typeof config === "string")
52
+ return [config, null];
53
+ if (isSchema(config)) {
54
+ const encoding = getEncoding(config);
55
+ return [defaultName, encoding ?? null];
56
+ }
57
+ // Object config: { field?, schema? }
58
+ const fieldName = config.field ?? defaultName;
59
+ const encoding = config.schema ? (getEncoding(config.schema) ?? null) : null;
60
+ return [fieldName, encoding];
61
+ };
62
+ export const resolveSystemFields = (timestamps, versioned, timeSeries, modelFields = {}, configuredAttributes = {}) => {
63
+ let createdAt = null;
64
+ let createdAtEncoding = null;
65
+ let updatedAt = null;
66
+ let updatedAtEncoding = null;
67
+ let version = null;
68
+ if (timestamps === true) {
69
+ createdAt = "createdAt";
70
+ updatedAt = "updatedAt";
71
+ }
72
+ else if (typeof timestamps === "object" && timestamps !== null) {
73
+ ;
74
+ [createdAt, createdAtEncoding] = resolveTimestampField(timestamps.created, "createdAt");
75
+ [updatedAt, updatedAtEncoding] = resolveTimestampField(timestamps.updated, "updatedAt");
76
+ }
77
+ if (versioned === true) {
78
+ version = "version";
79
+ }
80
+ else if (typeof versioned === "object" && versioned !== null) {
81
+ version = versioned.field ?? "version";
82
+ }
83
+ // Time-series entities auto-suppress `updatedAt` — the `orderBy` attribute
84
+ // serves as the update clock. `createdAt` is preserved.
85
+ if (timeSeries !== undefined && timeSeries !== null) {
86
+ if (updatedAt !== null) {
87
+ if (typeof timestamps === "object" &&
88
+ timestamps !== null &&
89
+ timestamps.updated !== undefined) {
90
+ // eslint-disable-next-line no-console
91
+ console.warn("[effect-dynamodb] timeSeries entity: `timestamps.updated` is ignored — " +
92
+ "the `orderBy` attribute is the event-time clock.");
93
+ }
94
+ updatedAt = null;
95
+ updatedAtEncoding = null;
96
+ }
97
+ }
98
+ // Collision detection: when the model declares a field whose name matches
99
+ // an active system field, there are two cases:
100
+ // (a) Date-compatible schema → library-managed with user override
101
+ // support. Encoding reads from the model field (precedence over
102
+ // `timestamps` config default).
103
+ // (b) Non-date schema → user owns the field entirely. Library skips
104
+ // timestamp management for this field (effectively as if the
105
+ // corresponding `timestamps.created`/`updated` was never set).
106
+ // This preserves existing patterns where users declare `createdAt`
107
+ // as a plain string composite.
108
+ let createdAtCollision = false;
109
+ if (createdAt && createdAt in modelFields) {
110
+ const modelEncoding = resolveFieldEncoding(modelFields[createdAt], configuredAttributes[createdAt]?.encoding);
111
+ if (modelEncoding) {
112
+ createdAtEncoding = modelEncoding;
113
+ createdAtCollision = true;
114
+ }
115
+ else {
116
+ // Non-date collision: user owns the field.
117
+ createdAt = null;
118
+ createdAtEncoding = null;
119
+ }
120
+ }
121
+ let updatedAtCollision = false;
122
+ if (updatedAt && updatedAt in modelFields) {
123
+ const modelEncoding = resolveFieldEncoding(modelFields[updatedAt], configuredAttributes[updatedAt]?.encoding);
124
+ if (modelEncoding) {
125
+ updatedAtEncoding = modelEncoding;
126
+ updatedAtCollision = true;
127
+ }
128
+ else {
129
+ updatedAt = null;
130
+ updatedAtEncoding = null;
131
+ }
132
+ }
133
+ const versionCollision = !!(version && version in modelFields);
134
+ return {
135
+ createdAt,
136
+ createdAtEncoding,
137
+ createdAtCollision,
138
+ updatedAt,
139
+ updatedAtEncoding,
140
+ updatedAtCollision,
141
+ version,
142
+ versionCollision,
143
+ };
144
+ };
145
+ // ---------------------------------------------------------------------------
146
+ // Helper: collect primary key composite attribute names
147
+ // ---------------------------------------------------------------------------
148
+ export const primaryKeyComposites = (indexes) => {
149
+ const primary = indexes.primary;
150
+ if (!primary)
151
+ return [];
152
+ const composites = new Set();
153
+ for (const attr of primary.pk.composite)
154
+ composites.add(attr);
155
+ for (const attr of primary.sk.composite)
156
+ composites.add(attr);
157
+ return Array.from(composites);
158
+ };
159
+ // ---------------------------------------------------------------------------
160
+ // Helper: collect all key field names across all indexes
161
+ // ---------------------------------------------------------------------------
162
+ export const allKeyFieldNames = (indexes) => {
163
+ const fields = new Set();
164
+ for (const key of Object.keys(indexes)) {
165
+ const index = indexes[key];
166
+ if (index) {
167
+ fields.add(index.pk.field);
168
+ fields.add(index.sk.field);
169
+ }
170
+ }
171
+ return Array.from(fields);
172
+ };
173
+ // ---------------------------------------------------------------------------
174
+ // Helper: collect all composite attribute names across all indexes
175
+ // ---------------------------------------------------------------------------
176
+ export const allCompositeAttributes = (indexes) => {
177
+ const attrs = new Set();
178
+ for (const key of Object.keys(indexes)) {
179
+ const index = indexes[key];
180
+ if (index) {
181
+ for (const attr of index.pk.composite)
182
+ attrs.add(attr);
183
+ for (const attr of index.sk.composite)
184
+ attrs.add(attr);
185
+ }
186
+ }
187
+ return Array.from(attrs);
188
+ };
189
+ /**
190
+ * Re-export of the shared typed `.fields` accessor (returns `undefined` for
191
+ * non-Struct/Class schemas). Use this when the call site has its own
192
+ * fallback/error handling; use {@link getFields} when a missing `.fields` is a
193
+ * hard error.
194
+ */
195
+ export { getSchemaFields };
196
+ export const getFields = (model) => {
197
+ const fields = getSchemaFields(model);
198
+ if (fields)
199
+ return fields;
200
+ throw new Error("Entity model must be a Schema.Class or Schema.Struct with .fields");
201
+ };
202
+ /**
203
+ * Infer a default DynamoEncoding for standard Effect date schemas that lack
204
+ * an explicit DynamoEncoding annotation. This enables Pattern B (pure model
205
+ * with Schema.DateTimeUtcFromString etc.) to work without explicit storedAs.
206
+ *
207
+ * Detection uses the typeConstructor annotation from Effect's schema AST.
208
+ */
209
+ export const inferDefaultEncoding = (schema) => {
210
+ const resolved = SchemaAST.resolve(schema.ast);
211
+ if (!resolved)
212
+ return undefined;
213
+ const tc = resolved.typeConstructor;
214
+ if (tc?._tag === "effect/DateTime.Utc")
215
+ return { storage: "string", domain: "DateTime.Utc" };
216
+ if (tc?._tag === "effect/DateTime.Zoned")
217
+ return { storage: "string", domain: "DateTime.Zoned" };
218
+ // Check for native Date via meta annotation
219
+ const meta = resolved.meta;
220
+ if (meta?._tag === "isDateValid")
221
+ return { storage: "string", domain: "Date" };
222
+ return undefined;
223
+ };
224
+ // ---------------------------------------------------------------------------
225
+ // Self vs. transform schema detection
226
+ // ---------------------------------------------------------------------------
227
+ /**
228
+ * Returns true when the schema is a "self" schema — i.e. it carries no
229
+ * encoding transformation. Self schemas have `ast.encoding === undefined`.
230
+ *
231
+ * - `Schema.DateTimeUtc` — self
232
+ * - `Schema.DateTimeUtcFromString` — transform (encodes to string)
233
+ * - `Schema.RedactedFromValue(...)` — transform
234
+ * - any `.pipe(Schema.decodeTo(...))` chain — transform
235
+ */
236
+ export const isSelfSchema = (schema) => schema.ast.encoding === undefined;
237
+ /**
238
+ * True if the schema is a self DateTime/Date schema that can have its storage
239
+ * configured via a DynamoEncoding annotation. Distinguished from a transform
240
+ * by `ast.encoding === undefined`.
241
+ */
242
+ export const isSelfDateSchema = (schema) => {
243
+ if (!isSelfSchema(schema))
244
+ return false;
245
+ // The self-schema check is necessary but not sufficient — also confirm the
246
+ // type constructor matches one of the supported date domains.
247
+ return inferDefaultEncoding(schema) !== undefined;
248
+ };
249
+ /**
250
+ * Returns true when the schema is a transform schema with a date typeConstructor
251
+ * (e.g. `Schema.DateTimeUtcFromString`, `DynamoModel.DateEpochSeconds`).
252
+ * Used to enforce the "transform owns the wire format" policy at Entity.make().
253
+ */
254
+ export const isDateTransform = (schema) => {
255
+ if (isSelfSchema(schema))
256
+ return false;
257
+ return inferDefaultEncoding(schema) !== undefined;
258
+ };
259
+ // ---------------------------------------------------------------------------
260
+ // Self-date schema substitution
261
+ // ---------------------------------------------------------------------------
262
+ /**
263
+ * Convert any input value (string / number / DateTime / Date) to the wire
264
+ * primitive for a given encoding. Used by date substitutes' `encode` path.
265
+ *
266
+ * Wire format precisely matches the legacy `serializeDateForDynamo` output:
267
+ * - `string` storage → ISO 8601 string (UTC for DateTime.Utc / Date; extended
268
+ * format for DateTime.Zoned)
269
+ * - `epochMs` storage → integer milliseconds since the Unix epoch
270
+ * - `epochSeconds` storage → integer seconds since the Unix epoch (TTL format)
271
+ */
272
+ const toWirePrimitive = (value, encoding) => {
273
+ let epochMs;
274
+ let zoned;
275
+ if (DateTime.isDateTime(value)) {
276
+ if (DateTime.isZoned(value))
277
+ zoned = value;
278
+ epochMs = DateTime.toEpochMillis(value);
279
+ }
280
+ else if (value instanceof Date) {
281
+ epochMs = value.getTime();
282
+ }
283
+ else if (typeof value === "string") {
284
+ epochMs = new Date(value).getTime();
285
+ }
286
+ else if (typeof value === "number") {
287
+ // Numeric inputs are interpreted in the encoding's storage scale already.
288
+ if (encoding.storage === "epochSeconds")
289
+ epochMs = value * 1000;
290
+ else
291
+ epochMs = value;
292
+ }
293
+ else {
294
+ throw new Error(`[effect-dynamodb] toWirePrimitive: unsupported input ${typeof value} for ${encoding.domain}/${encoding.storage}`);
295
+ }
296
+ switch (encoding.storage) {
297
+ case "string":
298
+ if (encoding.domain === "DateTime.Zoned" && zoned)
299
+ return DateTime.formatIsoZoned(zoned);
300
+ return new Date(epochMs).toISOString();
301
+ case "epochMs":
302
+ return epochMs;
303
+ case "epochSeconds":
304
+ return Math.floor(epochMs / 1000);
305
+ }
306
+ };
307
+ /**
308
+ * Build a bidirectional Schema for a given DynamoEncoding.
309
+ *
310
+ * The encoded side is `Schema.Any` so `decode` tolerates either:
311
+ * - the wire primitive (string / number) — the storage-shape input, OR
312
+ * - a domain value (`DateTime.Utc`, `Date`, etc.) — what users typically pass
313
+ * into `entity.put({...})` and `.set({...})`.
314
+ *
315
+ * The `decode` direction always lifts to the domain type (DateTime.Utc /
316
+ * DateTime.Zoned / Date). The `encode` direction always produces the wire
317
+ * primitive. This makes both `Schema.encode(substituted)` and
318
+ * `Schema.decode(substituted)` work end-to-end regardless of whether the
319
+ * caller passed wire-form or domain-form.
320
+ *
321
+ * @internal
322
+ */
323
+ export const buildDateTransform = (encoding) => {
324
+ const targetSchema = (() => {
325
+ switch (encoding.domain) {
326
+ case "DateTime.Utc":
327
+ return Schema.DateTimeUtc;
328
+ case "DateTime.Zoned":
329
+ return Schema.DateTimeZoned;
330
+ case "Date":
331
+ return Schema.DateValid;
332
+ }
333
+ })();
334
+ const liftToDomain = (value) => {
335
+ if (DateTime.isDateTime(value))
336
+ return value;
337
+ if (value instanceof Date) {
338
+ switch (encoding.domain) {
339
+ case "DateTime.Utc":
340
+ return DateTime.makeUnsafe(value);
341
+ case "DateTime.Zoned":
342
+ return DateTime.makeZonedUnsafe(value, { timeZone: "UTC" });
343
+ case "Date":
344
+ return value;
345
+ }
346
+ }
347
+ if (typeof value === "string") {
348
+ switch (encoding.domain) {
349
+ case "DateTime.Utc":
350
+ return DateTime.makeUnsafe(value);
351
+ case "DateTime.Zoned": {
352
+ const match = value.match(/^(.+)\[(.+)\]$/);
353
+ if (match) {
354
+ const utc = DateTime.makeUnsafe(match[1]);
355
+ return DateTime.makeZonedUnsafe(utc, { timeZone: match[2] });
356
+ }
357
+ const utc = DateTime.makeUnsafe(value);
358
+ return DateTime.makeZonedUnsafe(utc, { timeZone: "UTC" });
359
+ }
360
+ case "Date":
361
+ return new Date(value);
362
+ }
363
+ }
364
+ if (typeof value === "number") {
365
+ const ms = encoding.storage === "epochSeconds" ? value * 1000 : value;
366
+ switch (encoding.domain) {
367
+ case "DateTime.Utc":
368
+ return DateTime.makeUnsafe(ms);
369
+ case "DateTime.Zoned":
370
+ return DateTime.makeZonedUnsafe(new Date(ms), { timeZone: "UTC" });
371
+ case "Date":
372
+ return new Date(ms);
373
+ }
374
+ }
375
+ return value;
376
+ };
377
+ return Schema.Any.pipe(Schema.decodeTo(targetSchema, {
378
+ decode: SchemaGetter.transformOrFail((value) => {
379
+ try {
380
+ return Effect.succeed(liftToDomain(value));
381
+ }
382
+ catch {
383
+ return Effect.fail(new SchemaIssue.InvalidType(Schema.Any.ast, Option.some(value)));
384
+ }
385
+ }),
386
+ encode: SchemaGetter.transformOrFail((value) => {
387
+ try {
388
+ return Effect.succeed(toWirePrimitive(value, encoding));
389
+ }
390
+ catch {
391
+ return Effect.fail(new SchemaIssue.InvalidType(Schema.Any.ast, Option.some(value)));
392
+ }
393
+ }),
394
+ }));
395
+ };
396
+ /**
397
+ * Detects `Schema.RedactedFromValue(inner)` fields (and `Schema.Redacted`).
398
+ * The encoded form of these schemas is "Forbidden" by Effect v4 design —
399
+ * encoding bails out to prevent accidental leaks. To make the round-trip work
400
+ * for our use case (write to DynamoDB, read back with Redacted on the domain
401
+ * side), we substitute with a custom transform that explicitly extracts the
402
+ * inner value via `Redacted.value` on encode and rewraps via `Redacted.make`
403
+ * on decode.
404
+ *
405
+ * Returns the inner schema (the wire-format schema) when the field is a
406
+ * Redacted-typed schema, otherwise undefined.
407
+ */
408
+ const tryGetRedactedInner = (schema) => {
409
+ const resolved = SchemaAST.resolve(schema.ast);
410
+ if (resolved?.typeConstructor?._tag !== "effect/Redacted")
411
+ return undefined;
412
+ // The decoded type is `Redacted<T>` where the inner schema is the first type
413
+ // parameter. `firstTypeParameterAst` isolates that single untyped AST read
414
+ // (no public accessor exists); we then rebuild a fully-functional Schema via
415
+ // `Schema.make` so the result has `.pipe`.
416
+ const innerAst = firstTypeParameterAst(schema.ast);
417
+ if (!innerAst)
418
+ return undefined;
419
+ return Schema.make(innerAst);
420
+ };
421
+ /**
422
+ * Build a substitute Schema for a `Schema.RedactedFromValue(inner)` field.
423
+ *
424
+ * The substitute round-trips: produces `Redacted<T>` on the domain side and
425
+ * the inner wire primitive on the encoded side. Both directions tolerate
426
+ * either form on input — a user passing a plain inner value is wrapped via
427
+ * `Redacted.make`; a user passing a `Redacted<T>` instance is unwrapped on
428
+ * encode via `Redacted.value`. This permissive shape lets the put/update
429
+ * paths handle the mixed wire/domain payloads users typically construct
430
+ * (e.g. `.put({ secret: Redacted.make(...) })`).
431
+ *
432
+ * The encoded side is `Schema.Any` so decode tolerates either form; the
433
+ * branch logic in `decode`/`encode` then steers the value to the right
434
+ * representation.
435
+ */
436
+ const buildRedactedSubstitute = (inner) => {
437
+ return Schema.Any.pipe(Schema.decodeTo(Schema.Redacted(inner), {
438
+ decode: SchemaGetter.transform((value) => Redacted.isRedacted(value) ? value : Redacted.make(value)),
439
+ encode: SchemaGetter.transform((r) => Redacted.isRedacted(r) ? Redacted.value(r) : r),
440
+ }));
441
+ };
442
+ /**
443
+ * Substitute model fields with bidirectional transforms where needed:
444
+ *
445
+ * - Self date schemas with an effective encoding (annotation or override)
446
+ * → tolerant date transform that accepts either wire form (string/number)
447
+ * or domain form (DateTime / Date) on decode, and always produces the
448
+ * configured wire primitive on encode.
449
+ * - `Schema.RedactedFromValue(inner)` fields → tolerant Redacted transform
450
+ * that accepts either form on decode and unwraps to wire primitive on
451
+ * encode (Effect v4's `RedactedFromValue` forbids encoding by default;
452
+ * our substitution makes the round-trip work for storage).
453
+ *
454
+ * Existing transform schemas (other than `RedactedFromValue`) are passed
455
+ * through unchanged — the user-declared transform IS the wire format.
456
+ *
457
+ * Schema.Class fields (and Schema.Class fields nested inside Schema.Array,
458
+ * Schema.optional, etc.) are NOT substituted here. Instead, callers run
459
+ * `decode → encode` against the substituted schema: `Schema.decode` is
460
+ * forgiving for Schema.Class (lifts plain objects to instances), and the
461
+ * tolerant date / Redacted substitutes make the decode pass accept both
462
+ * wire and domain values. The subsequent `Schema.encode` produces the
463
+ * canonical wire shape.
464
+ *
465
+ * @internal
466
+ */
467
+ export const substituteSchemas = (modelFields, fieldEncodings) => {
468
+ const out = {};
469
+ for (const [name, schema] of Object.entries(modelFields)) {
470
+ // 1. Self-date substitution. Pattern A: user declared `Schema.DateTimeUtc`
471
+ // and chose a wire format via annotation. We substitute with the
472
+ // tolerant date transform so the encode pipeline produces the right
473
+ // wire primitive.
474
+ if (isSelfDateSchema(schema)) {
475
+ const enc = fieldEncodings[name];
476
+ if (enc) {
477
+ out[name] = buildDateTransform(enc);
478
+ continue;
479
+ }
480
+ }
481
+ // 2. RedactedFromValue substitution. Effect v4's `RedactedFromValue`
482
+ // forbids encoding by design; substitute with a tolerant Redacted
483
+ // transform so the round-trip works for storage.
484
+ const redactedInner = tryGetRedactedInner(schema);
485
+ if (redactedInner !== undefined) {
486
+ out[name] = buildRedactedSubstitute(redactedInner);
487
+ continue;
488
+ }
489
+ // Default: pass through unchanged. Pattern B transforms (DynamoModel.*,
490
+ // Schema.DateTimeUtcFromString, Schema.NumberFromString, …) own their
491
+ // wire format directly — `Schema.encode` handles the conversion.
492
+ out[name] = schema;
493
+ }
494
+ return out;
495
+ };
496
+ /**
497
+ * Validate that no model field combines a transform schema with a storage
498
+ * override. The two configuration paths are mutually exclusive: either declare
499
+ * a self schema (`Schema.DateTimeUtc`) and let the annotation drive storage,
500
+ * OR declare a transform and own the wire format yourself — not both.
501
+ *
502
+ * Two override sources are checked:
503
+ *
504
+ * 1. `ConfiguredModel.storedAs` (configured attributes) — surfaces the
505
+ * override via `configuredAttributes[name].encoding`.
506
+ *
507
+ * 2. `DynamoModel.storedAs(...)` modifier piped onto the field schema —
508
+ * adds a `DynamoEncoding` annotation directly to the AST. We detect this
509
+ * by checking whether the field-level annotation disagrees with the
510
+ * transform's actual wire format (the `inferDefaultEncoding` of the
511
+ * transform). For an unmodified transform (e.g. `DynamoModel.DateString`
512
+ * with a matching ISO-string annotation), the two agree and the field is
513
+ * accepted.
514
+ *
515
+ * Throws at `Entity.make()` time if the conflict is detected.
516
+ *
517
+ * @internal
518
+ */
519
+ export const validateNoTransformOverride = (modelFields, configuredAttributes) => {
520
+ const conflictMessage = (name) => `[effect-dynamodb] Field "${name}": cannot apply DynamoEncoding storage override to a transform schema. ` +
521
+ `Either declare a self schema (Schema.DateTimeUtc) and let the annotation drive storage, OR declare a transform and own the wire format — not both.`;
522
+ for (const [name, schema] of Object.entries(modelFields)) {
523
+ if (!isDateTransform(schema))
524
+ continue;
525
+ // 1. ConfiguredModel.storedAs override?
526
+ const configOverride = configuredAttributes[name]?.encoding;
527
+ if (configOverride !== undefined) {
528
+ throw new Error(conflictMessage(name));
529
+ }
530
+ // 2. DynamoModel.storedAs() modifier on the schema itself? The modifier
531
+ // applies a `DynamoEncoding` annotation to the AST. Detect a conflict
532
+ // by comparing the annotation's storage with the transform's actual
533
+ // wire format (resolved via `transformWireKind`, which walks the typed
534
+ // `Base.encoding` chain to its leaf). Granularity: we can distinguish
535
+ // "string" vs "number" wire forms; we cannot distinguish epochMs vs
536
+ // epochSeconds at this layer. The string-vs-number mismatch covers the
537
+ // most common breaking case.
538
+ const annotated = getEncoding(schema);
539
+ if (annotated === undefined)
540
+ continue;
541
+ const transformWire = transformWireKind(schema);
542
+ if (transformWire === undefined)
543
+ continue;
544
+ const annotatedKind = annotated.storage === "string" ? "string" : "number";
545
+ if (transformWire !== annotatedKind) {
546
+ throw new Error(conflictMessage(name));
547
+ }
548
+ }
549
+ };
550
+ /**
551
+ * Read the wire form ("string" or "number") that a transform schema actually
552
+ * encodes to, by walking the encoding chain. Returns undefined when the chain
553
+ * is missing or the leaf isn't a String / Number node.
554
+ *
555
+ * Delegates to {@link SchemaAccessors.transformWireKind}, which uses the typed
556
+ * `Base.encoding` property and `SchemaAST.isString`/`isNumber` guards on the
557
+ * leaf `Link.to` AST.
558
+ */
559
+ const transformWireKind = (schema) => transformWireKindImpl(schema);
560
+ export const buildDerivedSchemas = (modelFields, indexes, systemFields, resolvedRefs = [], immutableFields = new Set(), identifierField = undefined, timeSeries = undefined, fieldEncodings = {}, generatedIdField = undefined) => {
561
+ // --- Substitute model fields ---
562
+ // Self date schemas (with effective encoding) are replaced with bidirectional
563
+ // transforms that produce the configured wire primitive. RedactedFromValue
564
+ // fields are replaced with a custom bidirectional transform so encoding
565
+ // round-trips. All other transform schemas are passed through — the user's
566
+ // transform IS the wire format and we never override it.
567
+ const fields = substituteSchemas(modelFields, fieldEncodings);
568
+ // --- Model Schema: pure model fields (for input decode/encode) ---
569
+ const modelSchema = Schema.Struct(fields);
570
+ // --- Record Schema: model + system fields ---
571
+ // System timestamp fields use Schema.DateTimeUtcFromString as the canonical
572
+ // wire ↔ domain transform when they're library-managed. When the field
573
+ // collides with a model-declared field, the model's substituted schema is
574
+ // already in `fields` and we don't add a system-schema entry.
575
+ const systemSchemaFields = {};
576
+ if (systemFields.createdAt && !systemFields.createdAtCollision) {
577
+ systemSchemaFields[systemFields.createdAt] = systemFields.createdAtEncoding
578
+ ? buildDateTransform(systemFields.createdAtEncoding)
579
+ : Schema.DateTimeUtcFromString;
580
+ }
581
+ if (systemFields.updatedAt && !systemFields.updatedAtCollision) {
582
+ systemSchemaFields[systemFields.updatedAt] = systemFields.updatedAtEncoding
583
+ ? buildDateTransform(systemFields.updatedAtEncoding)
584
+ : Schema.DateTimeUtcFromString;
585
+ }
586
+ if (systemFields.version && !systemFields.versionCollision) {
587
+ systemSchemaFields[systemFields.version] = Schema.Number;
588
+ }
589
+ const recordSchema = Schema.Struct({
590
+ ...fields,
591
+ ...systemSchemaFields,
592
+ });
593
+ // --- System-collision handling for input/update schemas ---
594
+ // When the model declares a field whose name matches an active system field,
595
+ // the input shape changes:
596
+ // - createdAt / updatedAt → marked optional (user may supply a value or
597
+ // leave absent and let the library generate).
598
+ // - version → stripped entirely (optimistic locking requires library-
599
+ // managed increment; user-supplied values would break the mechanic).
600
+ // `createdAt` is also treated as immutable in the update schema.
601
+ const optionalOnCollide = new Set();
602
+ if (systemFields.createdAtCollision && systemFields.createdAt)
603
+ optionalOnCollide.add(systemFields.createdAt);
604
+ if (systemFields.updatedAtCollision && systemFields.updatedAt)
605
+ optionalOnCollide.add(systemFields.updatedAt);
606
+ // Auto-generated id: optional in input (caller may omit and let the library
607
+ // fill it from the Crypto service) but required in recordSchema (a decoded
608
+ // record always carries it). Reuses the system-collision optionality path.
609
+ if (generatedIdField)
610
+ optionalOnCollide.add(generatedIdField);
611
+ const strippedFromInput = new Set();
612
+ if (systemFields.versionCollision && systemFields.version)
613
+ strippedFromInput.add(systemFields.version);
614
+ const strippedFromUpdate = new Set(strippedFromInput);
615
+ if (systemFields.createdAtCollision && systemFields.createdAt)
616
+ strippedFromUpdate.add(systemFields.createdAt);
617
+ const applySystemCollisionAdjustments = (fieldName, fieldSchema, kind) => {
618
+ const strippedSet = kind === "update" ? strippedFromUpdate : strippedFromInput;
619
+ if (strippedSet.has(fieldName))
620
+ return null;
621
+ if (optionalOnCollide.has(fieldName))
622
+ return Schema.optional(fieldSchema);
623
+ return fieldSchema;
624
+ };
625
+ // --- Input Schema: ref-aware (swap ref fields for ID fields when refs present) ---
626
+ const refFieldSet = new Set(resolvedRefs.map((r) => r.fieldName));
627
+ const buildInputFieldsAcc = () => {
628
+ const acc = {};
629
+ for (const [key, field] of Object.entries(fields)) {
630
+ if (refFieldSet.has(key))
631
+ continue;
632
+ const adjusted = applySystemCollisionAdjustments(key, field, "input");
633
+ if (adjusted !== null)
634
+ acc[key] = adjusted;
635
+ }
636
+ for (const ref of resolvedRefs) {
637
+ acc[ref.idFieldName] = ref.identifierSchema;
638
+ }
639
+ return acc;
640
+ };
641
+ const inputSchema = Schema.Struct(buildInputFieldsAcc());
642
+ // --- Create Schema: input fields minus identifier (or PK composites as fallback) ---
643
+ const pkComposites = new Set(primaryKeyComposites(indexes));
644
+ const createOmitFields = identifierField ? new Set([identifierField]) : pkComposites;
645
+ const createFieldsAcc = {};
646
+ for (const [key, field] of Object.entries(fields)) {
647
+ if (createOmitFields.has(key))
648
+ continue; // skip identifier/PK composites (auto-generated)
649
+ if (refFieldSet.has(key))
650
+ continue; // skip ref fields (replaced by ID fields)
651
+ const adjusted = applySystemCollisionAdjustments(key, field, "input");
652
+ if (adjusted !== null)
653
+ createFieldsAcc[key] = adjusted;
654
+ }
655
+ // Add ID fields for each ref (skip if the ref field is an omitted field)
656
+ for (const ref of resolvedRefs) {
657
+ if (createOmitFields.has(ref.fieldName))
658
+ continue;
659
+ createFieldsAcc[ref.idFieldName] = ref.identifierSchema;
660
+ }
661
+ const createSchema = Schema.Struct(createFieldsAcc);
662
+ // --- Update Schema: optional model fields minus key composites & immutable ---
663
+ // Each field is wrapped in `Schema.optional` so the key may be omitted
664
+ // entirely (the structural composer treats absent keys as absent for GSI
665
+ // composition under v3's two-way classification). The field's value type
666
+ // matches the model declaration exactly — there is no `null` widening,
667
+ // because EDD-9025 prevents composites from declaring `null` and the
668
+ // standard update payload follows the same shape. If the model declares a
669
+ // non-composite attribute as `Schema.NullOr(X)`, `set({ attr: null })`
670
+ // still typechecks; the v1.6 `NullishOr` wrap that allowed null on
671
+ // arbitrary fields (regardless of model declaration) is removed.
672
+ const buildUpdateFieldsAcc = () => {
673
+ const acc = {};
674
+ for (const [key, field] of Object.entries(fields)) {
675
+ if (pkComposites.has(key))
676
+ continue;
677
+ if (immutableFields.has(key))
678
+ continue;
679
+ if (refFieldSet.has(key))
680
+ continue;
681
+ if (strippedFromUpdate.has(key))
682
+ continue;
683
+ acc[key] = Schema.optional(field);
684
+ }
685
+ for (const ref of resolvedRefs) {
686
+ if (pkComposites.has(ref.fieldName))
687
+ continue;
688
+ if (immutableFields.has(ref.fieldName))
689
+ continue;
690
+ acc[ref.idFieldName] = Schema.optional(ref.identifierSchema);
691
+ }
692
+ return acc;
693
+ };
694
+ const updateSchema = Schema.Struct(buildUpdateFieldsAcc());
695
+ // --- Key Schema: primary key composite attributes only ---
696
+ const keyCompositeNames = primaryKeyComposites(indexes);
697
+ const keyFields = {};
698
+ for (const name of keyCompositeNames) {
699
+ const field = fields[name];
700
+ if (field)
701
+ keyFields[name] = field;
702
+ }
703
+ const keySchema = Schema.Struct(keyFields);
704
+ // --- Item Schema: record + key attrs (pk, sk, gsi*) + __edd_e__ ---
705
+ // Primary pk/sk are guaranteed present on every live item. GSI key fields
706
+ // are sparse: they're written only when every source composite resolves, and
707
+ // dropped when any composite is missing or indexPolicy evicts the index.
708
+ // Stream NewImage / raw query results therefore routinely lack GSI keys —
709
+ // mark them optional so decodeMarshalledItem decodes sparse items cleanly.
710
+ const primaryKeyFieldNames = new Set();
711
+ const primary = indexes.primary;
712
+ if (primary) {
713
+ primaryKeyFieldNames.add(primary.pk.field);
714
+ primaryKeyFieldNames.add(primary.sk.field);
715
+ }
716
+ const keyAttrFields = {};
717
+ for (const fieldName of allKeyFieldNames(indexes)) {
718
+ keyAttrFields[fieldName] = primaryKeyFieldNames.has(fieldName)
719
+ ? Schema.String
720
+ : Schema.optional(Schema.String);
721
+ }
722
+ const itemSchema = Schema.Struct({
723
+ ...fields,
724
+ ...systemSchemaFields,
725
+ ...keyAttrFields,
726
+ __edd_e__: Schema.String,
727
+ });
728
+ // --- Deleted Record Schema: record + deletedAt ---
729
+ const deletedRecordSchema = Schema.Struct({
730
+ ...fields,
731
+ ...systemSchemaFields,
732
+ deletedAt: Schema.String,
733
+ });
734
+ // --- Hidden-aware decode schemas: strip DynamoModel.Hidden fields from model/record ---
735
+ // These are used for "model" and "record" decode modes. Hidden fields are
736
+ // still present in "item" and "native" modes.
737
+ const hiddenFieldNames = new Set();
738
+ for (const [fieldName, fieldSchema] of Object.entries(modelFields)) {
739
+ if (isHidden(fieldSchema))
740
+ hiddenFieldNames.add(fieldName);
741
+ }
742
+ const hasHiddenFields = hiddenFieldNames.size > 0;
743
+ const modelVisibleSchema = hasHiddenFields
744
+ ? Schema.Struct(Object.fromEntries(Object.entries(fields).filter(([k]) => !hiddenFieldNames.has(k))))
745
+ : modelSchema;
746
+ const recordVisibleSchema = hasHiddenFields
747
+ ? Schema.Struct(Object.fromEntries(Object.entries({
748
+ ...fields,
749
+ ...systemSchemaFields,
750
+ }).filter(([k]) => !hiddenFieldNames.has(k))))
751
+ : recordSchema;
752
+ // --- Time-series derived schemas ---
753
+ // `appendInputSchema`: the user-supplied schema, with the same substitution
754
+ // rules applied as the model fields (self date schemas → tolerant
755
+ // transforms, RedactedFromValue → tolerant Redacted). `Entity.make()`
756
+ // enforces that every PK/SK composite + `orderBy` appears in its fields.
757
+ // `historyRecordSchema`: event-item shape. Substituted model fields + no
758
+ // system fields (events are point-in-time facts keyed by `orderBy`).
759
+ const timeSeriesEnabled = timeSeries !== undefined && timeSeries !== null;
760
+ const appendInputSchema = timeSeriesEnabled
761
+ ? (() => {
762
+ const userSchema = timeSeries.appendInput;
763
+ const userFields = getSchemaFields(userSchema);
764
+ if (!userFields)
765
+ return userSchema;
766
+ // Compute encodings for the appendInput fields (annotation +
767
+ // inferred from typeConstructor for self schemas).
768
+ const appendEncodings = buildFieldEncodings(userFields, {});
769
+ const subbedFields = substituteSchemas(userFields, appendEncodings);
770
+ return Schema.Struct(subbedFields);
771
+ })()
772
+ : null;
773
+ const historyRecordSchema = timeSeriesEnabled
774
+ ? (() => {
775
+ const hiddenFilter = (record) => hasHiddenFields
776
+ ? Object.fromEntries(Object.entries(record).filter(([k]) => !hiddenFieldNames.has(k)))
777
+ : record;
778
+ return Schema.Struct(hiddenFilter({ ...fields }));
779
+ })()
780
+ : null;
781
+ return {
782
+ modelSchema: modelVisibleSchema,
783
+ recordSchema: recordVisibleSchema,
784
+ inputSchema: inputSchema,
785
+ createSchema: createSchema,
786
+ updateSchema: updateSchema,
787
+ keySchema: keySchema,
788
+ itemSchema: itemSchema,
789
+ deletedRecordSchema: deletedRecordSchema,
790
+ appendInputSchema: appendInputSchema,
791
+ historyRecordSchema: historyRecordSchema,
792
+ };
793
+ };
794
+ // ---------------------------------------------------------------------------
795
+ // Unique constraint field resolver
796
+ // ---------------------------------------------------------------------------
797
+ export const resolveUniqueFields = (def) => Array.isArray(def) ? def : def.fields;
798
+ //# sourceMappingURL=EntitySchemas.js.map