effect-dynamodb 1.9.2 → 1.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/Entity.js CHANGED
@@ -6,13 +6,13 @@
6
6
  * carries derived schemas for 7 type extractors (Model, Record, Input, Update,
7
7
  * Key, Item, Marshalled).
8
8
  */
9
- import { getIdentifierField, getSparseFields, isConfiguredModel, isHidden, isRecordAst, isRecordSchema, isRef, isRefField, } from "@effect-dynamodb/schema/DynamoModel.js";
9
+ import { getIdentifierField, getSparseFields, isRefField, } from "@effect-dynamodb/schema/DynamoModel.js";
10
10
  import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
11
- import { CascadePartialFailure, ConditionalCheckFailed, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled, makeCompositeNullableError, OptimisticLockError, RefNotFound, StaleAppend, TransactionOverflow, UniqueConstraintViolation, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
11
+ import { CascadePartialFailure, ConditionalCheckFailed, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled, OptimisticLockError, RefNotFound, StaleAppend, TransactionOverflow, UniqueConstraintViolation, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
12
12
  import { makeDefaultCrypto } from "@effect-dynamodb/schema/internal/DefaultCrypto.js";
13
13
  import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
14
14
  import { normalizeGsiConfig } from "@effect-dynamodb/schema/KeyComposer.js";
15
- import { Context, Crypto, DateTime, Effect, Option, Schema, SchemaAST, Stream, } from "effect";
15
+ import { Context, Crypto, DateTime, Effect, Option, Schema, Stream } from "effect";
16
16
  import { DynamoClient } from "./DynamoClient.js";
17
17
  import { makeBoundAppend, makeBoundDelete, makeBoundPut, makeBoundUpdate, } from "./internal/BoundCrud.js";
18
18
  import { BoundQueryImpl } from "./internal/BoundQuery.js";
@@ -27,8 +27,8 @@ export { EntityDeleteImpl, EntityDeleteTypeId, EntityGetImpl, EntityOpTypeId, En
27
27
  import * as Projection from "@effect-dynamodb/schema/Projection.js";
28
28
  import { EntityDeleteImpl, EntityDeleteTypeId, EntityGetImpl, EntityOpTypeId, EntityPutImpl, EntityUpdateImpl, emptyUpdateState, returnValuesMap, } from "./internal/EntityOps.js";
29
29
  export { add, append, asItem, asModel, asNative, asRecord, cascade, clearMap, consistentRead, deleteFromSet, expectedVersion, pathAdd, pathAppend, pathDelete, pathIfNotExists, pathPrepend, pathRemove, pathSet, pathSubtract, project, remove, removeEntries, returnValues, set, subtract, } from "./internal/EntityCombinators.js";
30
+ import { allCompositeAttributes, allKeyFieldNames, getSchemaFields, primaryKeyComposites, resolveUniqueFields, } from "@effect-dynamodb/schema/internal/EntitySchemas.js";
30
31
  import { asModel, condition as conditionCombinator, expectedVersion, set, } from "./internal/EntityCombinators.js";
31
- import { allCompositeAttributes, allKeyFieldNames, buildDerivedSchemas, buildFieldEncodings, getFields, getSchemaFields, primaryKeyComposites, resolveSystemFields, resolveUniqueFields, validateNoTransformOverride, } from "@effect-dynamodb/schema/internal/EntitySchemas.js";
32
32
  // ---------------------------------------------------------------------------
33
33
  // Sparse-aware unique sentinel composition
34
34
  // ---------------------------------------------------------------------------
@@ -50,7 +50,7 @@ import { allCompositeAttributes, allKeyFieldNames, buildDerivedSchemas, buildFie
50
50
  * imported here for local use on the write path. `normalizeTtlSeconds` is
51
51
  * re-exported below so the public `Entity` namespace surface is unchanged.
52
52
  */
53
- import { normalizeTtlSeconds, resolveUniqueTtl } from "@effect-dynamodb/schema/Entity.js";
53
+ import { buildEntityDefinition, normalizeTtlSeconds, resolveUniqueTtl, } from "@effect-dynamodb/schema/Entity.js";
54
54
  export { normalizeTtlSeconds };
55
55
  const composeUniqueSentinel = (schema, entityType, constraintName, constraintDef, source, lookup) => {
56
56
  const fields = resolveUniqueFields(constraintDef);
@@ -171,459 +171,88 @@ export const make = (config) => {
171
171
  // Delegate to the internal implementation with normalized indexes
172
172
  return makeImpl({ ...config, indexes });
173
173
  };
174
- const makeImpl = (config) => {
175
- // Unwrap ConfiguredModel to get the raw model and attribute overrides
176
- const configured = isConfiguredModel(config.model) ? config.model : undefined;
177
- const rawModel = configured ? configured.model : config.model;
178
- const configuredAttributes = configured?.attributes ?? {};
179
- const isSchemaClass = typeof rawModel === "function";
180
- const modelFields = getFields(rawModel);
181
- const hasHiddenFields = Object.values(modelFields).some(isHidden);
182
- const systemFields = resolveSystemFields(config.timestamps, config.versioned, config.timeSeries, modelFields, configuredAttributes);
183
- // System-field collision policy:
184
- // - Timestamp collisions with a non-date model field silently yield the
185
- // field to the user (see `resolveSystemFields`).
186
- // - `version` collision is allowed. The field is stripped from
187
- // `inputSchema` / `createSchema` / `updateSchema` (see
188
- // `buildDerivedSchemas`), so user code cannot supply it via `put` /
189
- // `create` / `.set()`. Library continues to own the write path
190
- // (auto-increment + `.expectedVersion()` optimistic locking). The model
191
- // declaration is purely for type-level visibility — callers can
192
- // construct Schema.Class instances and read `version` off the record.
193
- // - Manual version fixups remain available by dropping to the raw
194
- // `DynamoClient` service; they deliberately do not appear on the
195
- // `Entity` surface to keep the locking invariant visibly inviolable.
196
- // ---------------------------------------------------------------------------
197
- // Validate indexes
198
- // ---------------------------------------------------------------------------
199
- const primaryIndex = config.indexes.primary;
200
- if (primaryIndex.pk.composite.length === 0 && primaryIndex.sk.composite.length === 0) {
201
- throw new Error(`[EDD-9001] Entity "${config.entityType}": primary key must have at least one composite attribute in pk or sk`);
202
- }
203
- // Build the set of valid composite attribute names: model fields + ref-derived ID fields
204
- const validCompositeFields = new Set(Object.keys(modelFields));
205
- for (const fieldName of Object.keys(modelFields)) {
206
- if (isRefField(fieldName, config.model)) {
207
- validCompositeFields.add(`${fieldName}Id`);
208
- }
209
- }
210
- for (const [indexName, indexDef] of Object.entries(config.indexes)) {
211
- for (const attr of [...indexDef.pk.composite, ...indexDef.sk.composite]) {
212
- if (!validCompositeFields.has(attr)) {
213
- throw new Error(`[EDD-9002] Entity "${config.entityType}": index "${indexName}" references unknown attribute "${attr}". ` +
214
- `Valid attributes: ${[...validCompositeFields].sort().join(", ")}`);
215
- }
216
- }
217
- }
218
- // ---------------------------------------------------------------------------
219
- // EDD-9025: composite attribute schemas must not include `null`.
220
- //
221
- // Composites participate in string composition (`acc#X#alert#Y`); `null` is
222
- // not a meaningful slot value. Allowing `Schema.NullOr` / `NullishOr` /
223
- // `Schema.Union([..., Schema.Null])` on a composite would let
224
- // `set({ composite: null })` typecheck (after the v1.6 update-payload
225
- // widening was reverted), then either blow up at runtime or silently
226
- // produce a key with the literal string `"null"` as a slot.
227
- //
228
- // Sparse pattern is still expressible via `Schema.optional(...)` (T |
229
- // undefined), which produces no Null AST node. See DESIGN.md §7.
230
- // ---------------------------------------------------------------------------
231
- // Resolve the AST for a domain composite name. For ref-derived `${name}Id`
232
- // composites, the schema is the referenced entity's identifier schema (not
233
- // the model field). For all other composites, it's the model field's
234
- // schema. Returns undefined when the composite isn't declared on the model
235
- // (already caught by EDD-9002 above, so this is defensive).
236
- const resolveCompositeSchemaForNullCheck = (compositeAttr) => {
237
- if (compositeAttr in modelFields) {
238
- return { schema: modelFields[compositeAttr], source: `model field` };
239
- }
240
- // Ref-derived ${name}Id composite — look up the identifier schema on the
241
- // referenced entity's model. We can't use the resolvedRefs array here
242
- // (it's built later); recompute inline against config.refs.
243
- if (config.refs) {
244
- for (const [refFieldName, refValue] of Object.entries(config.refs)) {
245
- if (`${refFieldName}Id` !== compositeAttr)
246
- continue;
247
- const refEntity = refValue.entity;
248
- const idField = getIdentifierField(refEntity.model);
249
- if (!idField)
250
- return undefined;
251
- return { schema: idField.schema, source: `ref "${refFieldName}" identifier` };
252
- }
253
- }
254
- return undefined;
255
- };
256
- // Recursively walk an AST and return a path-string if `null` is reachable
257
- // in the type union (anywhere — direct, nested in Union, behind Suspend).
258
- // `Schema.optional(X)` produces `T | undefined` (no Null) and is allowed.
259
- // `Schema.NullOr(X)` produces `T | null` and is rejected.
260
- const findNullInAst = (ast, path, seen = new Set()) => {
261
- if (seen.has(ast))
262
- return undefined;
263
- seen.add(ast);
264
- if (SchemaAST.isNull(ast))
265
- return `${path}.Null`;
266
- if (SchemaAST.isUnion(ast)) {
267
- for (let i = 0; i < ast.types.length; i++) {
268
- const found = findNullInAst(ast.types[i], `${path}.Union[${i}]`, seen);
269
- if (found)
270
- return found;
271
- }
272
- }
273
- // Other AST shapes (Declaration, String, Number, Suspend, ...) cannot
274
- // introduce `null` themselves. Suspend wrappers are handled
275
- // conservatively — we don't recurse into thunks because the EDD-9025
276
- // check is about static type unions, and recursive schemas wrapping
277
- // composite-eligible scalars are pathological for keys anyway.
278
- return undefined;
279
- };
280
- const checkCompositeForNull = (surface, compositeAttr) => {
281
- const resolved = resolveCompositeSchemaForNullCheck(compositeAttr);
282
- if (!resolved)
283
- return; // EDD-9002 already caught the unknown-attribute case
284
- const found = findNullInAst(resolved.schema.ast, resolved.source);
285
- if (found) {
286
- throw makeCompositeNullableError({
287
- entityType: config.entityType,
288
- surface,
289
- compositeAttribute: compositeAttr,
290
- schemaPath: found,
291
- });
292
- }
293
- };
294
- // primaryKey composites
295
- for (const attr of [
296
- ...config.indexes.primary.pk.composite,
297
- ...config.indexes.primary.sk.composite,
298
- ]) {
299
- checkCompositeForNull("primaryKey", attr);
300
- }
301
- // GSI composites (each index)
302
- for (const [indexName, indexDef] of Object.entries(config.indexes)) {
303
- if (indexName === "primary")
304
- continue;
305
- for (const attr of [...indexDef.pk.composite, ...indexDef.sk.composite]) {
306
- checkCompositeForNull(`index "${indexName}"`, attr);
307
- }
308
- }
309
- // Unique constraint composites — `unique` is keyed by constraint name,
310
- // value is a single field name OR an array of field names OR a config
311
- // object. resolveUniqueFields normalises all three to a list of field names.
312
- if (config.unique) {
313
- for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
314
- const fields = resolveUniqueFields(constraintDef);
315
- for (const f of fields) {
316
- checkCompositeForNull(`unique:${constraintName}`, f);
317
- }
318
- }
319
- }
320
- // ---------------------------------------------------------------------------
321
- // Validate timeSeries config (EDD-9010..9016)
322
- // ---------------------------------------------------------------------------
323
- if (config.timeSeries !== undefined && config.timeSeries !== null) {
324
- const ts = config.timeSeries;
325
- const orderBy = ts.orderBy;
326
- // EDD-9010: orderBy must name a model field
327
- if (!(orderBy in modelFields)) {
328
- throw new Error(`[EDD-9010] Entity "${config.entityType}": timeSeries.orderBy "${orderBy}" does not name a model field. ` +
329
- `Valid model fields: ${Object.keys(modelFields).sort().join(", ")}`);
330
- }
331
- // EDD-9011: orderBy must not be a primary-key composite (PK or SK)
332
- const primary = config.indexes.primary;
333
- const pkSkComposites = new Set([...primary.pk.composite, ...primary.sk.composite]);
334
- if (pkSkComposites.has(orderBy)) {
335
- throw new Error(`[EDD-9011] Entity "${config.entityType}": timeSeries.orderBy "${orderBy}" must not appear ` +
336
- `in the primary key pk or sk composite — it shadows the #e# event-SK infix.`);
337
- }
338
- // EDD-9012: mutually exclusive with versioned
339
- if (config.versioned !== undefined && config.versioned !== null && config.versioned !== false) {
340
- throw new Error(`[EDD-9012] Entity "${config.entityType}": timeSeries and versioned are mutually exclusive. ` +
341
- `Pick one consistency model per entity.`);
342
- }
343
- // EDD-9015: mutually exclusive with softDelete
344
- if (config.softDelete !== undefined &&
345
- config.softDelete !== null &&
346
- config.softDelete !== false) {
347
- throw new Error(`[EDD-9015] Entity "${config.entityType}": timeSeries and softDelete are mutually exclusive. ` +
348
- `Append on a soft-deleted item would land on a new empty row — not a sound resurrection model.`);
349
- }
350
- // EDD-9016: appendInput is required
351
- if (ts.appendInput === undefined || ts.appendInput === null) {
352
- throw new Error(`[EDD-9016] Entity "${config.entityType}": timeSeries.appendInput is required. ` +
353
- `Define a Schema.Struct whose fields are the subset of the model allowed in .append() input. ` +
354
- `Fields outside appendInput are preserved on the current item — this is the enrichment-preservation guarantee. ` +
355
- `To opt out (dangerous), pass the full model schema explicitly.`);
356
- }
357
- const appendInputFields = (() => {
358
- const ai = ts.appendInput;
359
- const fields = getSchemaFields(ai);
360
- if (fields)
361
- return Object.keys(fields);
362
- throw new Error(`[EDD-9016] Entity "${config.entityType}": timeSeries.appendInput must be a Schema.Struct or Schema.Class (.fields required).`);
363
- })();
364
- const appendInputFieldSet = new Set(appendInputFields);
365
- // EDD-9013: appendInput must include orderBy + all PK/SK composites
366
- if (!appendInputFieldSet.has(orderBy)) {
367
- throw new Error(`[EDD-9013] Entity "${config.entityType}": timeSeries.appendInput must include orderBy "${orderBy}". ` +
368
- `Without it .append() cannot evaluate the CAS condition.`);
369
- }
370
- for (const composite of pkSkComposites) {
371
- if (!appendInputFieldSet.has(composite)) {
372
- throw new Error(`[EDD-9013] Entity "${config.entityType}": timeSeries.appendInput missing primary-key composite "${composite}". ` +
373
- `Every PK/SK composite must appear in appendInput so the event can be addressed.`);
374
- }
375
- }
376
- // EDD-9014: orderBy must not name a ref field or ref-derived ${name}Id field
377
- for (const fieldName of Object.keys(modelFields)) {
378
- if (isRefField(fieldName, config.model)) {
379
- if (orderBy === fieldName || orderBy === `${fieldName}Id`) {
380
- throw new Error(`[EDD-9014] Entity "${config.entityType}": timeSeries.orderBy "${orderBy}" names a ref ` +
381
- `or ref-derived id field. Refs are create-time denormalisations and cannot serve as the event clock.`);
382
- }
383
- }
384
- }
385
- // Also: appendInput must not declare any ref-derived ${name}Id field —
386
- // ref changes go through .update(), not .append() (§4.6).
387
- for (const fieldName of Object.keys(modelFields)) {
388
- if (isRefField(fieldName, config.model)) {
389
- if (appendInputFieldSet.has(`${fieldName}Id`)) {
390
- throw new Error(`[EDD-9014] Entity "${config.entityType}": timeSeries.appendInput must not include ref-derived ` +
391
- `"${fieldName}Id" — refs cannot be reassigned via .append(). Use .update() to change a ref.`);
392
- }
393
- }
394
- }
395
- }
396
- // ---------------------------------------------------------------------------
397
- // Validate generatedId config (EDD-9008)
398
- // ---------------------------------------------------------------------------
399
- // The named field is auto-filled with a UUID at write time when omitted. It
400
- // MUST exist in the model AND participate in the primary key (pk or sk
401
- // composite) — generating an id that doesn't address the item would be a
402
- // silent no-op. Mirrors the timeSeries.orderBy validation style.
403
- if (config.generatedId !== undefined && config.generatedId !== null) {
404
- const gid = config.generatedId;
405
- const field = gid.field;
406
- // EDD-9008: field must name a model field
407
- if (!(field in modelFields)) {
408
- throw new Error(`[EDD-9008] Entity "${config.entityType}": generatedId.field "${field}" does not name a model field. ` +
409
- `Valid model fields: ${Object.keys(modelFields).sort().join(", ")}`);
410
- }
411
- // EDD-9008: field must participate in the primary key (pk or sk composite)
412
- const primary = config.indexes.primary;
413
- const pkSkComposites = new Set([...primary.pk.composite, ...primary.sk.composite]);
414
- if (!pkSkComposites.has(field)) {
415
- throw new Error(`[EDD-9008] Entity "${config.entityType}": generatedId.field "${field}" must participate in the ` +
416
- `primary key (pk or sk composite). An auto-generated id that does not compose into the primary ` +
417
- `key cannot address the item. Primary-key composites: ${[...pkSkComposites].sort().join(", ")}`);
418
- }
419
- }
420
- const resolvedRefs = config.refs
421
- ? Object.entries(config.refs).map(([fieldName, refValue]) => {
422
- // Extract entity from shorthand or expanded form
423
- const refEntity = refValue.entity;
424
- // Validate the field exists in the model
425
- const fieldSchema = modelFields[fieldName];
426
- if (!fieldSchema) {
427
- throw new Error(`Entity "${config.entityType}": refs config references field "${fieldName}" which does not exist in the model`);
428
- }
429
- // Check for ref annotation — either on the schema field or via DynamoModel.configure
430
- if (!isRef(fieldSchema) && !isRefField(fieldName, config.model)) {
431
- throw new Error(`Entity "${config.entityType}": refs config references field "${fieldName}" which does not have the DynamoModel.ref annotation. Add DynamoModel.ref to the model field or set { ref: true } in DynamoModel.configure.`);
432
- }
433
- // Resolve the identifier field from the referenced entity's model
434
- // getIdentifierField handles both schema-level annotations and ConfiguredModel overrides
435
- const idField = getIdentifierField(refEntity.model);
436
- if (!idField) {
437
- throw new Error(`Entity "${config.entityType}": ref field "${fieldName}" references entity "${refEntity.entityType}" which has no identifier field. Add DynamoModel.identifier to the model field or set { identifier: true } in DynamoModel.configure.`);
438
- }
439
- return {
440
- fieldName,
441
- idFieldName: `${fieldName}Id`,
442
- identifierField: idField.name,
443
- identifierSchema: idField.schema,
444
- refEntity: refEntity,
445
- refEntityType: refEntity.entityType,
446
- };
447
- })
448
- : [];
449
- const hasRefs = resolvedRefs.length > 0;
450
- // ---------------------------------------------------------------------------
451
- // Auto-generate cascade indexes from expanded ref configs
452
- // ---------------------------------------------------------------------------
453
- const cascadeIndexes = {};
454
- if (config.refs) {
455
- for (const [refFieldName, refValue] of Object.entries(config.refs)) {
456
- const cascadeConfig = refValue.cascade;
457
- if (!cascadeConfig)
458
- continue;
459
- const ref = resolvedRefs.find((r) => r.fieldName === refFieldName);
460
- cascadeIndexes[`_cascade_${refFieldName}`] = {
461
- index: cascadeConfig.index,
462
- pk: { field: cascadeConfig.pk.field, composite: [ref.idFieldName] },
463
- sk: { field: cascadeConfig.sk.field, composite: [...config.indexes.primary.pk.composite] },
464
- };
465
- }
466
- }
467
- let allIndexes = {
468
- ...config.indexes,
469
- ...cascadeIndexes,
470
- };
471
- // Build immutable fields set from ConfiguredModel (for update schema exclusion + upsert if_not_exists wrapping)
472
- const immutableFields = new Set();
473
- for (const [fieldName, attrConfig] of Object.entries(configuredAttributes)) {
474
- if (attrConfig.immutable)
475
- immutableFields.add(fieldName);
174
+ /** @internal A definition that has had its operations attached (has `.get`). */
175
+ const isOperational = (e) => typeof e.get === "function";
176
+ /** @internal Copy the schema + table tag a definition received via `_configure`
177
+ * (Table.make injects them at table-definition time) onto a promoted entity. */
178
+ const carryConfigure = (from, to) => {
179
+ if (from._schema !== undefined && from._tableTag !== undefined) {
180
+ to._configure(from._schema, from._tableTag);
476
181
  }
477
- // Resolve identifier field name from model annotation
478
- const resolvedIdentifier = getIdentifierField(config.model)?.name;
479
- // Validate: no model field combines a transform schema with a ConfiguredModel
480
- // storage override. The two configuration paths are mutually exclusive.
481
- validateNoTransformOverride(modelFields, configuredAttributes);
482
- // Resolve effective field encodings (annotation + ConfiguredModel override).
483
- // Used by buildDerivedSchemas to substitute self date schemas with bidirectional
484
- // transforms, and (below) by system-field generation to produce the right
485
- // wire primitive when the field is library-managed.
486
- const fieldEncodings = buildFieldEncodings(modelFields, configuredAttributes);
487
- const generatedIdField = config.generatedId !== undefined && config.generatedId !== null
488
- ? config.generatedId.field
489
- : undefined;
490
- const generatedIdVersion = config.generatedId !== undefined && config.generatedId !== null
491
- ? (config.generatedId.version ?? "v4")
492
- : undefined;
493
- const schemas = buildDerivedSchemas(modelFields, allIndexes, systemFields, resolvedRefs, immutableFields, resolvedIdentifier, config.timeSeries, fieldEncodings, generatedIdField);
494
- // schema and tableTag are injected via _configure() when the entity is registered
495
- // on a Table and bound through DynamoClient.make(). They are captured by operation
496
- // closures and resolved at runtime (inside Effects), not at definition time.
497
- // Using definite assignment (!) since _configure is called before any operation executes.
182
+ };
183
+ /**
184
+ * Promote a pure `@effect-dynamodb/schema` {@link EntityDefinition} into a full
185
+ * operational runtime {@link Entity}, attaching CRUD/query operations. The
186
+ * already-derived data bundle (`def._data`) is reused, so this is a thin
187
+ * op-attach — no re-validation or re-derivation. The table binding the pure
188
+ * definition already received (via `Table.make`) is preserved.
189
+ *
190
+ * **Ref targets are promoted too.** Write-time ref hydration calls `.get()` on
191
+ * each ref target (to denormalise it), so a pure ref target must be operational
192
+ * or the write crashes with `ref.refEntity.get is not a function`. Each ref
193
+ * target is promoted one level: `.get` (a read) does not itself hydrate refs, so
194
+ * the target's *own* ref targets need not be promoted — which also sidesteps
195
+ * cyclic refs (A→B→A) without recursion. An entity that is itself bound and
196
+ * written goes through `fromDefinition` independently and gets its own refs
197
+ * promoted.
198
+ *
199
+ * Used by `DynamoClient.make()` to bind pure definitions; idempotent on an
200
+ * entity that is already operational.
201
+ *
202
+ * @internal
203
+ */
204
+ export const fromDefinition = (def) => {
205
+ const resolvedRefs = def._data.resolvedRefs;
206
+ // Replace any pure ref target with an operational one so hydration's `.get`
207
+ // works. Already-operational targets (runtime-authored, or mixed refs once the
208
+ // shared AnyRefValue lands) pass through unchanged.
209
+ // Cast: a promoted ref target is a runtime `Entity`, not the pure
210
+ // `EntityDefinition` that `EntityDefinitionData.resolvedRefs` is typed against.
211
+ // Hydration only reads `.model`/`.entityType`/`.get` off it (the latter via the
212
+ // runtime `ResolvedRef` cast inside makeImpl), so this is sound.
213
+ const data = resolvedRefs.length > 0
214
+ ? {
215
+ ...def._data,
216
+ resolvedRefs: resolvedRefs.map((r) => isOperational(r.refEntity)
217
+ ? r
218
+ : { ...r, refEntity: promoteRefTarget(r.refEntity) }),
219
+ }
220
+ : def._data;
221
+ const runtime = makeImpl(def._config, data);
222
+ carryConfigure(def, runtime);
223
+ return runtime;
224
+ };
225
+ /** @internal Promote a ref target to an operational entity WITHOUT promoting its
226
+ * own ref targets (one level — see {@link fromDefinition}). The target's table
227
+ * binding is carried over so `.get` can compose keys. */
228
+ const promoteRefTarget = (def) => {
229
+ const runtime = makeImpl(def._config, def._data);
230
+ carryConfigure(def, runtime);
231
+ return runtime;
232
+ };
233
+ const makeImpl = (config, precomputedData) => {
234
+ // Derivation (validation + schema/ref/sparse/rename resolution) is shared with
235
+ // the pure `@effect-dynamodb/schema` Entity.make via `buildEntityDefinition`,
236
+ // giving a single source of truth for the EDD-90xx rules. Promotion of a pure
237
+ // definition (see `fromDefinition`) passes the already-computed bundle as
238
+ // `precomputedData`, so derivation runs exactly once per entity.
239
+ // Cast: the runtime config's `refs` uses the runtime `AnyRefValue` (whose
240
+ // `entity` is the operational `Entity`), which is nominally distinct from the
241
+ // schema package's `AnyRefValue` (`entity: EntityDefinition`). buildEntityDefinition
242
+ // only reads `.model`/`.entityType`/identifier off ref targets, both of which
243
+ // the runtime Entity carries, so the cast is sound.
244
+ const data = precomputedData ?? buildEntityDefinition(config);
245
+ const { systemFields, schemas, hasRefs, immutableFields, resolvedIdentifier, generatedIdField, generatedIdVersion, entityType, entityVersion, sparseFields, hasSparseFields, renameToDynamo, renameFromDynamo, resolveDbName, rawModel, isSchemaClass, hasHiddenFields, } = data;
246
+ // resolvedRefs carries the actual ref-target entity objects; at runtime they
247
+ // are operational Entities (for runtime-authored refs) so write-time hydration
248
+ // can call their CRUD ops. The pure bundle widens refEntity to EntityDefinition.
249
+ const resolvedRefs = data.resolvedRefs;
250
+ // allIndexes is mutable: _injectIndex adds collection-owned GSIs after make().
251
+ let allIndexes = { ...data.initialIndexes };
252
+ // schema + tableTag are injected via _configure() when the entity is registered
253
+ // on a Table and bound through DynamoClient.make(); captured by operation closures.
498
254
  let schema;
499
255
  let tableTag;
500
- const entityType = config.entityType;
501
- const entityVersion = 1;
502
- // Note: `fieldEncodings` is resolved earlier (above buildDerivedSchemas) and
503
- // used to substitute self date schemas in derived schemas. System timestamp
504
- // encodings are NOT in `fieldEncodings`; `generateTimestamp` produces wire
505
- // primitives directly for non-colliding system fields.
506
- // ---------------------------------------------------------------------------
507
- // Sparse Map fields (storedAs: 'sparse') — validation + resolution
508
- // ---------------------------------------------------------------------------
509
- const sparseFields = getSparseFields(config.model);
510
- const hasSparseFields = Object.keys(sparseFields).length > 0;
511
- if (hasSparseFields) {
512
- // EDD-9020: storedAs: 'sparse' is only valid on Schema.Record fields.
513
- for (const fieldName of Object.keys(sparseFields)) {
514
- const fieldSchema = modelFields[fieldName];
515
- if (!fieldSchema) {
516
- throw new Error(`[EDD-9020] Entity "${config.entityType}": sparse field "${fieldName}" does not exist on the model`);
517
- }
518
- const recordInfo = isRecordSchema(fieldSchema);
519
- if (!recordInfo) {
520
- throw new Error(`[EDD-9020] Entity "${config.entityType}": sparse field "${fieldName}" must be a Schema.Record. ` +
521
- `Got a non-Record schema. Sparse storage flattens Record entries into per-key top-level attributes.`);
522
- }
523
- // EDD-9021: inner value must not be another Record (no nested sparse).
524
- if (isRecordAst(recordInfo.valueAst)) {
525
- throw new Error(`[EDD-9021] Entity "${config.entityType}": sparse field "${fieldName}" has a Record-typed value schema. ` +
526
- `Nested sparse Records are not supported — use Schema.Struct, Schema.Number, etc. for the inner value.`);
527
- }
528
- }
529
- // EDD-9022: sparse fields cannot participate in primary key, GSI composites,
530
- // or unique constraints — keys aren't statically known at make() time.
531
- const allComposites = new Set(allCompositeAttributes(allIndexes));
532
- for (const fieldName of Object.keys(sparseFields)) {
533
- if (allComposites.has(fieldName)) {
534
- throw new Error(`[EDD-9022] Entity "${config.entityType}": sparse field "${fieldName}" cannot be a primary-key or GSI composite. ` +
535
- `Composite values must be known at make() time; sparse-map keys are not.`);
536
- }
537
- }
538
- if (config.unique) {
539
- const sparseSet = new Set(Object.keys(sparseFields));
540
- for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
541
- const fields = resolveUniqueFields(constraintDef);
542
- for (const f of fields) {
543
- if (sparseSet.has(f)) {
544
- throw new Error(`[EDD-9022] Entity "${config.entityType}": unique constraint "${constraintName}" cannot reference sparse field "${f}".`);
545
- }
546
- }
547
- }
548
- }
549
- // EDD-9023: sparse prefixes must be unique among themselves and must not
550
- // collide with non-sparse top-level model field names.
551
- const seenPrefix = new Map(); // prefix → fieldName
552
- for (const [fieldName, sparse] of Object.entries(sparseFields)) {
553
- const existing = seenPrefix.get(sparse.prefix);
554
- if (existing) {
555
- throw new Error(`[EDD-9023] Entity "${config.entityType}": sparse fields "${existing}" and "${fieldName}" share prefix "${sparse.prefix}". Prefixes must be distinct.`);
556
- }
557
- seenPrefix.set(sparse.prefix, fieldName);
558
- // Prefix must not collide with another (non-sparse) model field name.
559
- if (sparse.prefix !== fieldName && sparse.prefix in modelFields) {
560
- throw new Error(`[EDD-9023] Entity "${config.entityType}": sparse field "${fieldName}" prefix "${sparse.prefix}" collides with non-sparse field "${sparse.prefix}".`);
561
- }
562
- }
563
- }
564
- // EDD-9005: validate every configured TTL eagerly at make() time. Rejects
565
- // non-finite (infinite) durations and unparseable string forms here so the
566
- // write path never observes an invalid TTL (it would otherwise surface as a
567
- // defect mid-write). Bare numbers are rejected at the type level (footgun).
568
- {
569
- const configuredTtls = [];
570
- if (typeof config.versioned === "object" && config.versioned?.ttl !== undefined) {
571
- configuredTtls.push(config.versioned.ttl);
572
- }
573
- if (typeof config.softDelete === "object" && config.softDelete?.ttl !== undefined) {
574
- configuredTtls.push(config.softDelete.ttl);
575
- }
576
- if (config.timeSeries?.ttl !== undefined)
577
- configuredTtls.push(config.timeSeries.ttl);
578
- if (config.unique) {
579
- for (const def of Object.values(config.unique)) {
580
- const uTtl = resolveUniqueTtl(def);
581
- if (uTtl !== undefined)
582
- configuredTtls.push(uTtl);
583
- }
584
- }
585
- for (const ttl of configuredTtls)
586
- normalizeTtlSeconds(ttl);
587
- }
588
- // ---------------------------------------------------------------------------
589
- // Field renaming: domain name → DynamoDB attribute name (from ConfiguredModel)
590
- // ---------------------------------------------------------------------------
591
- const fieldRenames = {}; // domain → dynamo
592
- for (const [domainName, attrConfig] of Object.entries(configuredAttributes)) {
593
- if (attrConfig.field)
594
- fieldRenames[domainName] = attrConfig.field;
595
- }
596
- const hasRenames = Object.keys(fieldRenames).length > 0;
597
- /**
598
- * Rename domain field names to DynamoDB attribute names.
599
- * Called before toAttributeMap (put path).
600
- */
601
- const renameToDynamo = (item) => {
602
- if (!hasRenames)
603
- return;
604
- for (const [domain, dynamo] of Object.entries(fieldRenames)) {
605
- if (domain in item) {
606
- item[dynamo] = item[domain];
607
- delete item[domain];
608
- }
609
- }
610
- };
611
- /**
612
- * Rename DynamoDB attribute names back to domain field names.
613
- * Called after fromAttributeMap (get path).
614
- */
615
- const renameFromDynamo = (item) => {
616
- if (!hasRenames)
617
- return;
618
- for (const [domain, dynamo] of Object.entries(fieldRenames)) {
619
- if (dynamo in item) {
620
- item[domain] = item[dynamo];
621
- delete item[dynamo];
622
- }
623
- }
624
- };
625
- /** Resolve a domain field name to its DynamoDB attribute name. */
626
- const resolveDbName = (domainName) => fieldRenames[domainName] ?? domainName;
627
256
  /**
628
257
  * Flatten sparse Map fields into per-entry top-level attributes.
629
258
  * Called on the write path after rename + date serialization, before
@@ -817,19 +446,33 @@ const makeImpl = (config) => {
817
446
  const keyInput = { [ref.identifierField]: id };
818
447
  // Use asModel to convert EntityGet to Effect<ModelType>
819
448
  const getEffect = asModel(ref.refEntity.get(keyInput));
820
- const result = yield* getEffect.pipe(Effect.catchTag("ItemNotFound", () => Effect.fail(new RefNotFound({
449
+ const model = yield* getEffect.pipe(Effect.catchTag("ItemNotFound", () => Effect.fail(new RefNotFound({
821
450
  entity: entityType,
822
451
  field: ref.fieldName,
823
452
  refEntity: ref.refEntityType,
824
453
  refId: id,
825
454
  }))));
826
- return { fieldName: ref.fieldName, idFieldName: ref.idFieldName, data: result };
455
+ // `asModel` returns the DECODED domain value (transform fields lifted to
456
+ // their domain form, e.g. `Schema.DateTimeUtcFromString` → `DateTime`).
457
+ // The parent decodes this nested ref field via the ref target's ORIGINAL
458
+ // `Schema.Class` (`substituteSchemas` does not recurse into nested
459
+ // classes), whose Encoded side is the wire primitive. Re-encode through
460
+ // the ref's substituted `modelSchema` so the spliced value is wire-form:
461
+ // marshall-safe for `toAttributeMap` AND decoded exactly once by the
462
+ // parent (issue #72 — without this, a put marshalls a `DateTime` and the
463
+ // return decode fails "Expected string, got DateTime.Utc").
464
+ const wire = yield* Schema.encodeUnknownEffect(ref.refEntity.schemas.modelSchema)(model).pipe(Effect.mapError((cause) => new ValidationError({
465
+ entityType,
466
+ operation: "hydrateRefs.encode",
467
+ cause,
468
+ })));
469
+ return { fieldName: ref.fieldName, idFieldName: ref.idFieldName, data: wire };
827
470
  });
828
471
  });
829
472
  const results = yield* Effect.all(fetchEffects, { concurrency: "unbounded" });
830
473
  for (const r of results) {
831
- // Spread the Schema.Class instance to extract domain data as a plain object
832
- hydrated[r.fieldName] = { ...r.data };
474
+ // `data` is already a wire-form plain object from the ref's modelSchema.
475
+ hydrated[r.fieldName] = r.data;
833
476
  delete hydrated[r.idFieldName];
834
477
  }
835
478
  return hydrated;