effect-dynamodb 1.22.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/Aggregate.d.ts +9 -4
  2. package/dist/Aggregate.d.ts.map +1 -1
  3. package/dist/Aggregate.js +644 -219
  4. package/dist/Aggregate.js.map +1 -1
  5. package/dist/Batch.d.ts +7 -0
  6. package/dist/Batch.d.ts.map +1 -1
  7. package/dist/Batch.js +132 -7
  8. package/dist/Batch.js.map +1 -1
  9. package/dist/Collection.d.ts.map +1 -1
  10. package/dist/Collection.js +13 -31
  11. package/dist/Collection.js.map +1 -1
  12. package/dist/DynamoClient.d.ts +40 -3
  13. package/dist/DynamoClient.d.ts.map +1 -1
  14. package/dist/DynamoClient.js +70 -19
  15. package/dist/DynamoClient.js.map +1 -1
  16. package/dist/Entity.d.ts +138 -55
  17. package/dist/Entity.d.ts.map +1 -1
  18. package/dist/Entity.js +3061 -626
  19. package/dist/Entity.js.map +1 -1
  20. package/dist/EventStore.d.ts +15 -6
  21. package/dist/EventStore.d.ts.map +1 -1
  22. package/dist/EventStore.js +169 -148
  23. package/dist/EventStore.js.map +1 -1
  24. package/dist/Query.d.ts +75 -3
  25. package/dist/Query.d.ts.map +1 -1
  26. package/dist/Query.js +228 -30
  27. package/dist/Query.js.map +1 -1
  28. package/dist/Transaction.d.ts +13 -2
  29. package/dist/Transaction.d.ts.map +1 -1
  30. package/dist/Transaction.js +48 -38
  31. package/dist/Transaction.js.map +1 -1
  32. package/dist/index.d.ts +3 -3
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/internal/BoundCrud.d.ts +31 -20
  37. package/dist/internal/BoundCrud.d.ts.map +1 -1
  38. package/dist/internal/BoundCrud.js +1 -1
  39. package/dist/internal/BoundCrud.js.map +1 -1
  40. package/dist/internal/BoundQuery.d.ts +47 -2
  41. package/dist/internal/BoundQuery.d.ts.map +1 -1
  42. package/dist/internal/BoundQuery.js +182 -18
  43. package/dist/internal/BoundQuery.js.map +1 -1
  44. package/dist/internal/EntityCombinators.d.ts +32 -10
  45. package/dist/internal/EntityCombinators.d.ts.map +1 -1
  46. package/dist/internal/EntityCombinators.js +16 -7
  47. package/dist/internal/EntityCombinators.js.map +1 -1
  48. package/dist/internal/EntityOps.d.ts +44 -10
  49. package/dist/internal/EntityOps.d.ts.map +1 -1
  50. package/dist/internal/EntityOps.js +48 -5
  51. package/dist/internal/EntityOps.js.map +1 -1
  52. package/dist/internal/Expr.d.ts +23 -0
  53. package/dist/internal/Expr.d.ts.map +1 -1
  54. package/dist/internal/Expr.js +84 -9
  55. package/dist/internal/Expr.js.map +1 -1
  56. package/dist/internal/ItemSize.d.ts +38 -0
  57. package/dist/internal/ItemSize.d.ts.map +1 -0
  58. package/dist/internal/ItemSize.js +62 -0
  59. package/dist/internal/ItemSize.js.map +1 -0
  60. package/dist/internal/TransactWriteOps.d.ts +112 -19
  61. package/dist/internal/TransactWriteOps.d.ts.map +1 -1
  62. package/dist/internal/TransactWriteOps.js +210 -33
  63. package/dist/internal/TransactWriteOps.js.map +1 -1
  64. package/dist/internal/TransactableOps.d.ts +33 -14
  65. package/dist/internal/TransactableOps.d.ts.map +1 -1
  66. package/dist/internal/TransactableOps.js +46 -19
  67. package/dist/internal/TransactableOps.js.map +1 -1
  68. package/package.json +2 -2
package/dist/Aggregate.js CHANGED
@@ -11,16 +11,16 @@
11
11
  import { TypeId as SchemaAggregateTypeId } from "@effect-dynamodb/schema/Aggregate.js";
12
12
  import * as DynamoModel from "@effect-dynamodb/schema/DynamoModel.js";
13
13
  import { composeCollectionKey, composeKey } from "@effect-dynamodb/schema/DynamoSchema.js";
14
- import { AggregateAssemblyError, AggregateDecompositionError, AggregateTransactionOverflow, RefNotFound, TransactionCancelled, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
14
+ import { AggregateAssemblyError, AggregateDecompositionError, AggregateTransactionOverflow, ConditionalCheckFailed, DynamoError, RefNotFound, TransactionCancelled, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
15
15
  import { makeCompositeKeyForm, toCompositeKeyRecord, } from "@effect-dynamodb/schema/internal/CompositeCodec.js";
16
- import { buildDateTransform, matchDateRepresentation, resolveSystemFields, substituteSchemaDeep, validateNoTransformOverride, } from "@effect-dynamodb/schema/internal/EntitySchemas.js";
17
- import { hasEncodingTransformation } from "@effect-dynamodb/schema/internal/SchemaAccessors.js";
16
+ import { buildDateTransform, containsWireTransform, matchDateRepresentation, optionalField, resolveSystemFields, substituteSchemaDeep, validateNoTransformOverride, } from "@effect-dynamodb/schema/internal/EntitySchemas.js";
17
+ import { extractArrayElement, getSchemaFields, hasEncodingTransformation, } from "@effect-dynamodb/schema/internal/SchemaAccessors.js";
18
18
  import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
19
- import { DateTime, Effect, Option, Schema, SchemaAST } from "effect";
19
+ import { DateTime, Effect, Equal, Option, Schema, SchemaAST, } from "effect";
20
20
  import * as Batch from "./Batch.js";
21
21
  import { DynamoClient } from "./DynamoClient.js";
22
22
  import { fromDefinition as entityFromDefinition } from "./Entity.js";
23
- import { compileExpr, createConditionOps, parseSimpleShorthand, } from "./internal/Expr.js";
23
+ import { compileExpr, createConditionOps, emptyPartProblem, isEmptyExpr, parseSimpleShorthand, } from "./internal/Expr.js";
24
24
  import { createPathBuilder } from "./internal/PathBuilder.js";
25
25
  import { generateTimestampPrimitive } from "./internal/TransactableOps.js";
26
26
  import { fromAttributeMap, toAttributeMap, toAttributeValue } from "./Marshaller.js";
@@ -99,6 +99,64 @@ const inferDateEncoding = (ast) => {
99
99
  }
100
100
  return undefined;
101
101
  };
102
+ /**
103
+ * A union that holds a date member alongside a member that is neither a date
104
+ * nor `Undefined` / `Null` — `Union([DateTimeUtcFromString, Number])`.
105
+ * `inferDateEncoding` looks through unions to find the date in `optional(date)`,
106
+ * so it reports such a field as a date, and encoding a `5` through a date
107
+ * transform throws. Its stored form is the union's own business: it is encoded
108
+ * member by member through the substituted union instead.
109
+ */
110
+ const isMixedDateUnion = (ast) => {
111
+ if (!SchemaAST.isUnion(ast))
112
+ return false;
113
+ const isDate = (member) => matchDateRepresentation(SchemaAST.resolve(member)) !==
114
+ undefined ||
115
+ matchDateRepresentation(member.annotations) !== undefined;
116
+ return ast.types.some((member) => !SchemaAST.isUndefined(member) && !SchemaAST.isNull(member) && !isDate(member));
117
+ };
118
+ /**
119
+ * The date branch shared by both encoder builders: an explicit
120
+ * `DynamoModel.storedAs` annotation, or the inferred default for a standard
121
+ * Effect date schema. It wins over the field's own encode because `storedAs` is
122
+ * precisely an override of it (a `Schema.DateTimeUtc` field marked
123
+ * `storedAs(DateEpochMs)` must store the epoch, not the ISO string its own schema
124
+ * would produce).
125
+ */
126
+ const dateAttrEncoder = (fieldSchema, options) => {
127
+ if (options?.mixedUnions !== true && isMixedDateUnion(fieldSchema.ast))
128
+ return undefined;
129
+ const encoding = DynamoModel.getEncoding(fieldSchema) ?? inferDateEncoding(fieldSchema.ast);
130
+ if (!encoding)
131
+ return undefined;
132
+ const encode = Schema.encodeUnknownSync(buildDateTransform(encoding));
133
+ return (value) => encode(value);
134
+ };
135
+ /**
136
+ * The field's own encode, with `decode -> encode` as the fallback so a caller
137
+ * who already supplied wire form round-trips to itself (the same strategy
138
+ * `Entity.put` uses). Neither working means the value is neither Type nor
139
+ * Encoded for this field; it is stored unchanged rather than introducing a
140
+ * write-time failure mode — that shape is already unreadable, and the tolerant
141
+ * read path reports it.
142
+ */
143
+ const ownAttrEncoder = (fieldSchema) => {
144
+ const codec = fieldSchema;
145
+ const encode = Schema.encodeUnknownOption(codec);
146
+ const decode = Schema.decodeUnknownOption(codec);
147
+ return (value) => {
148
+ const direct = encode(value);
149
+ if (Option.isSome(direct))
150
+ return direct.value;
151
+ const decoded = decode(value);
152
+ if (Option.isSome(decoded)) {
153
+ const reencoded = encode(decoded.value);
154
+ if (Option.isSome(reencoded))
155
+ return reencoded.value;
156
+ }
157
+ return value;
158
+ };
159
+ };
102
160
  /**
103
161
  * Build per-field ATTRIBUTE encoders for a schema's own fields — the write-side
104
162
  * equivalent of what `Entity.put` gets for free by encoding its whole input
@@ -106,70 +164,106 @@ const inferDateEncoding = (ast) => {
106
164
  *
107
165
  * Decomposition works from the schema-DECODED domain object, so every attribute
108
166
  * it produces is a Type-side value. Marshalling those directly stores a shape
109
- * the read path cannot decode: a `Schema.BigIntFromString` field lands as
110
- * `{N:"5"}` and assembly's decode (which expects the encoded string) rejects it,
111
- * so the aggregate cannot round-trip at all. Dates were noticed first (#72) and
112
- * got a date-only pass; the same argument applies to every transformed field.
167
+ * the read path cannot decode: a `DateTime` lands as a marshalled
168
+ * `{ epochMilliseconds, "~effect/DateTime", _tag }` map, a `Date` as `{M:{}}`,
169
+ * a `bigint` as `{N:"5"}`.
113
170
  *
114
171
  * Exactly ONE encoder per field, so nothing is ever encoded twice:
115
172
  *
116
- * 1. **A date encoding** — an explicit `DynamoModel.storedAs` annotation, or the
117
- * inferred default for a standard Effect date schema. This wins over the
118
- * field's own encode because `storedAs` is precisely an override of it
119
- * (a `Schema.DateTimeUtc` field marked `storedAs(DateEpochMs)` must store the
120
- * epoch, not the ISO string its own schema would produce). This branch is the
121
- * former `buildAttrEncoders`, unchanged.
122
- * 2. **Any other encoding transformation** — the field's own `encode`. A ref
123
- * field's schema is the referenced entity's, so a hydrated ref is encoded
124
- * with the entity it came from, not with the aggregate's schema.
125
- * 3. **No transformation** — no encoder at all, so the stored bytes are
173
+ * 1. **A date encoding** — see {@link dateAttrEncoder}.
174
+ * 2. **Anything that holds a wire transform AT ANY DEPTH** — a leaf transform, a
175
+ * `Schema.Class`, and also an `Array` / `Struct` / `Union` containing one, or
176
+ * a self date or `Redacted` nested anywhere ({@link containsWireTransform}).
177
+ * The attribute is encoded through the SAME substituted, tolerant schema the
178
+ * read path decodes it with (`substituteSchemaDeep` under
179
+ * `tolerantTransforms`, with the aggregate's ref resolver), so the stored
180
+ * form is exactly what assembly reads back — nested date leaves included —
181
+ * and a `DynamoModel.ref`-annotated field is encoded through the ref
182
+ * target's model. The field's own encode is the fallback.
183
+ *
184
+ * Gating on the TOP-LEVEL node instead (as before #133) skipped every
185
+ * container, because an `Arrays` / `Objects` node carries no encoding of its
186
+ * own: `Schema.Array(DateTimeUtcFromString)` and `Schema.Array(SomeClass)`
187
+ * attributes stored their `DateTime`s as maps.
188
+ * 3. **Nothing to encode** — no encoder at all, so the stored bytes are
126
189
  * identical to before this existed.
127
190
  *
128
- * The READ path is handled entirely by the aggregate's tolerant `decodeSchema`
129
- * (see `makeAggregate`), so no separate decoders are needed.
191
+ * Encoding stays PER ATTRIBUTE: decomposition and key composition need the
192
+ * Type-side values (`numericTypeWithStringEncoding`), so the aggregate is never
193
+ * encoded as a whole before it is decomposed.
130
194
  */
131
- const buildAttrEncoders = (fields) => {
195
+ const buildAttrEncoders = (fields, resolveRef) => {
132
196
  const encoders = {};
133
197
  if (!fields)
134
198
  return encoders;
135
199
  for (const field of Object.keys(fields)) {
136
200
  const fieldSchema = fields[field];
137
- // 1. Date encoding — `storedAs` override, else the inferred date default.
138
- const encoding = DynamoModel.getEncoding(fieldSchema) ?? inferDateEncoding(fieldSchema.ast);
139
- if (encoding) {
140
- const encode = Schema.encodeUnknownSync(buildDateTransform(encoding));
141
- encoders[field] = (value) => encode(value);
201
+ // 1. Date encoding.
202
+ const date = dateAttrEncoder(fieldSchema);
203
+ if (date) {
204
+ encoders[field] = date;
142
205
  continue;
143
206
  }
144
- // 3. Identity by construction — `SchemaAST` documents `encoding === undefined`
145
- // as "type and encoded forms are identical". Adding an encoder here could
146
- // only change bytes that are already correct.
147
- if (!hasEncodingTransformation(fieldSchema))
207
+ // 3. Nothing anywhere in the value has a wire form distinct from its domain form.
208
+ if (!containsWireTransform(fieldSchema))
148
209
  continue;
149
- // 2. The field's own encode, with `decode -> encode` as the fallback so a
150
- // caller who already supplied wire form round-trips to itself (the same
151
- // strategy `Entity.put` uses). Neither working means the value is neither
152
- // Type nor Encoded for this field; store it unchanged rather than
153
- // introduce a new write-time failure mode mid-release — that shape is
154
- // already unreadable, and the tolerant read path reports it.
155
- const codec = fieldSchema;
156
- const encode = Schema.encodeUnknownOption(codec);
157
- const decode = Schema.decodeUnknownOption(codec);
210
+ // 2. Encode through the read path's own (substituted, tolerant) schema. A
211
+ // one-field struct carries the field's optionality and lets `resolveRef`
212
+ // re-point the field itself, exactly as it does inside `decodeSchema`.
213
+ const substituted = substituteSchemaDeep(Schema.Struct({ [field]: fieldSchema }), {
214
+ tolerantTransforms: true,
215
+ resolveRef,
216
+ });
217
+ const encodeSubstituted = Schema.encodeUnknownOption(substituted);
218
+ const own = ownAttrEncoder(fieldSchema);
158
219
  encoders[field] = (value) => {
159
- const direct = encode(value);
160
- if (Option.isSome(direct))
161
- return direct.value;
162
- const decoded = decode(value);
163
- if (Option.isSome(decoded)) {
164
- const reencoded = encode(decoded.value);
165
- if (Option.isSome(reencoded))
166
- return reencoded.value;
167
- }
168
- return value;
220
+ const encoded = encodeSubstituted({ [field]: value });
221
+ if (Option.isSome(encoded))
222
+ return encoded.value[field];
223
+ return own(value);
169
224
  };
170
225
  }
171
226
  return encoders;
172
227
  };
228
+ /**
229
+ * The encoders sort-key composites are READ from — the encoders as they stood
230
+ * before #133, gated on the field's TOP-LEVEL encoding.
231
+ *
232
+ * A `many` edge's declared `sk.composite` (and a root list index's composites)
233
+ * are read off the item's attributes. Those attributes used to be encoded by
234
+ * these encoders only, so the stored keys were composed from their output: a
235
+ * top-level transformed field from its encoded form, anything nested (a
236
+ * `DateTime` or `NumberFromString` reached through `"umpire.dateOfBirth"`) from
237
+ * its domain form. Composing keys from the new, deeper encoders instead would
238
+ * re-spell those keys — orphaning every stored row and making `update` diff
239
+ * against keys that do not exist. Keys therefore keep this exact behaviour,
240
+ * and only the STORED attribute values gain the deeper encoding.
241
+ */
242
+ const buildKeyAttrEncoders = (fields) => {
243
+ const encoders = {};
244
+ if (!fields)
245
+ return encoders;
246
+ for (const field of Object.keys(fields)) {
247
+ const fieldSchema = fields[field];
248
+ const date = dateAttrEncoder(fieldSchema, { mixedUnions: true });
249
+ if (date) {
250
+ // Before, a value this encoder rejects (the `5` of a mixed date union)
251
+ // threw and failed the write, so there is no stored key to stay
252
+ // byte-identical with: compose from the value as it is.
253
+ encoders[field] = (value) => {
254
+ try {
255
+ return date(value);
256
+ }
257
+ catch {
258
+ return value;
259
+ }
260
+ };
261
+ }
262
+ else if (hasEncodingTransformation(fieldSchema))
263
+ encoders[field] = ownAttrEncoder(fieldSchema);
264
+ }
265
+ return encoders;
266
+ };
173
267
  /**
174
268
  * A schema's declared fields, if it exposes them (Schema.Struct / Schema.Class).
175
269
  *
@@ -185,11 +279,156 @@ const fieldsOf = (schema) => {
185
279
  const source = DynamoModel.isConfiguredModel(schema) ? schema.model : schema;
186
280
  return source?.fields;
187
281
  };
282
+ /** The value schema of a struct field, with any `optionalKey` / `optional` wrapper removed. */
283
+ const unwrapOptionalField = (field) => optionalField(field)?.inner ?? field;
284
+ /**
285
+ * Whether a field is a class-like value whose fields the schema walker cannot
286
+ * see. `DynamoModel.ref` annotates with `Schema.annotate`, which drops a
287
+ * `Schema.Class`'s `.fields`, so `substituteSchemaDeep` cannot recurse into it
288
+ * and the target's transformed fields keep their strict decoders. A plain class
289
+ * is introspectable and is left to the walker.
290
+ */
291
+ const isOpaqueRefField = (field) => {
292
+ const inner = unwrapOptionalField(field);
293
+ return getSchemaFields(inner) === undefined && SchemaAST.isDeclaration(inner.ast);
294
+ };
295
+ /**
296
+ * The fields of a `many` edge's ELEMENT, read off the parent model's array
297
+ * field (`players: Schema.Array(PlayerSheet)` → `PlayerSheet`'s fields).
298
+ * `undefined` when the field is not array-shaped (a custom `decompose`) or its
299
+ * element exposes no fields (a scalar, or an opaque ref).
300
+ */
301
+ const manyElementFields = (parentFields, edgeName) => {
302
+ const field = parentFields?.[edgeName];
303
+ const element = field === undefined ? undefined : extractArrayElement(field);
304
+ return element === undefined ? undefined : getSchemaFields(element);
305
+ };
188
306
  /**
189
- * Apply a node's date encoders in place — converts the node entity's own domain
190
- * date fields to their wire primitives before the decomposed item is marshalled
191
- * (issue #72: without this, an edge's `DateTime` field marshals to `{M:{}}` and
192
- * the subsequent `get`/assemble fails decoding it as a string).
307
+ * A sub-aggregate nested in another inherits its parent's discriminator, and
308
+ * its rows carry both (#133). Re-using an inherited attribute name would
309
+ * overwrite the parent's value on the inner rows: the two bindings of the
310
+ * parent would then key and assemble the inner sub-aggregate identically.
311
+ * Refused at `make()` time, naming the edge, rather than surfacing as a
312
+ * key collision or a mis-assembly at runtime.
313
+ */
314
+ const validateNestedDiscriminators = (aggregateName, edges, inherited = new Map(), path = []) => {
315
+ for (const [edgeName, edge] of Object.entries(edges)) {
316
+ if (!("_tag" in edge) || edge._tag !== "BoundSubAggregate")
317
+ continue;
318
+ const bound = edge;
319
+ const here = [...path, edgeName];
320
+ const own = Object.keys(bound.discriminator ?? {});
321
+ for (const attr of own) {
322
+ const owner = inherited.get(attr);
323
+ if (owner !== undefined) {
324
+ throw new Error(`[EDD-9056] Aggregate "${aggregateName}": the sub-aggregate at "${here.join(".")}" ` +
325
+ `declares discriminator "${attr}", which it already inherits from "${owner}". ` +
326
+ `A nested sub-aggregate's rows carry every enclosing discriminator, so the inner ` +
327
+ `value would overwrite the outer one. Give the nested binding a distinct attribute ` +
328
+ `name (e.g. { squadNo: 1 } inside { clubNo: 1 }).`);
329
+ }
330
+ }
331
+ const next = new Map(inherited);
332
+ for (const attr of own)
333
+ next.set(attr, here.join("."));
334
+ validateNestedDiscriminators(aggregateName, bound.aggregate.edges, next, here);
335
+ }
336
+ };
337
+ const unwrapEntityModel = (model) => DynamoModel.isConfiguredModel(model) ? model.model : model;
338
+ /**
339
+ * Map each ref FIELD SCHEMA the aggregate's schema walker cannot see into to the
340
+ * model it should be decoded (and encoded) as.
341
+ *
342
+ * `substituteSchemaDeep` recurses into plain classes / structs / arrays on its
343
+ * own. It cannot recurse into a `DynamoModel.ref`-annotated class (the
344
+ * annotation drops `.fields`), so that field's transformed leaves keep their
345
+ * strict decoders: a re-decoded domain `DateTime` is rejected as "Expected
346
+ * string", and a legacy marshalled one is never lifted. Registered here:
347
+ *
348
+ * - **root `one` / `ref` edge fields** — unconditionally, re-pointed at the
349
+ * edge entity's model (the behaviour since #72);
350
+ * - **sub-aggregate `one` / `ref` edge fields** and **the ref field inside a
351
+ * `many` element** (`PlayerSheet.player`, found by
352
+ * `deriveEntityFieldName(entity)` as hydration finds it), at root or in a
353
+ * sub-aggregate — only when OPAQUE. A plain class there is already walked,
354
+ * and swapping it for the entity's model could only make its decode stricter
355
+ * or change its class (#133);
356
+ * - **a `many` edge field whose ELEMENT is an opaque ref**
357
+ * (`Schema.Array(Player.pipe(DynamoModel.ref))`) — the array field itself,
358
+ * re-pointed at `Schema.Array(<entity model>)`, since the walker can only
359
+ * re-point fields, not array elements;
360
+ * - **refs nested inside an edge entity's own model** (`maker` on a `supplier`
361
+ * edge entity, #116).
362
+ *
363
+ * Keyed by schema IDENTITY. The resolver is consulted at every depth of the
364
+ * walk, so the former name-keyed table re-pointed every same-named field
365
+ * anywhere in the model — a `coach` field three levels down was decoded as the
366
+ * root `coach` edge's entity.
367
+ */
368
+ const collectRefTargets = (rootFields, rootEdges) => {
369
+ const targets = new Map();
370
+ const register = (field, model) => {
371
+ if (field !== undefined && model !== undefined && !targets.has(field)) {
372
+ targets.set(field, unwrapEntityModel(model));
373
+ }
374
+ };
375
+ const registerNested = (entity) => {
376
+ if (entity?.model === undefined)
377
+ return;
378
+ const entityFields = getSchemaFields(unwrapEntityModel(entity.model));
379
+ for (const nested of entity._data?.resolvedRefs ?? []) {
380
+ register(entityFields?.[nested.fieldName], nested.refEntity?.model);
381
+ }
382
+ };
383
+ const walk = (fields, edges, isRoot) => {
384
+ for (const [edgeName, edge] of Object.entries(edges)) {
385
+ if (!("_tag" in edge))
386
+ continue;
387
+ if (edge._tag === "RefEdge" || edge._tag === "OneEdge") {
388
+ const entity = edge.entity;
389
+ const field = fields?.[edgeName];
390
+ if (field !== undefined && (isRoot || isOpaqueRefField(field))) {
391
+ register(field, entity?.model);
392
+ }
393
+ registerNested(entity);
394
+ }
395
+ else if (edge._tag === "ManyEdge") {
396
+ const entity = edge.entity;
397
+ const arrayField = fields?.[edgeName];
398
+ const element = arrayField === undefined ? undefined : extractArrayElement(arrayField);
399
+ if (arrayField !== undefined && element !== undefined && isOpaqueRefField(element)) {
400
+ // The element IS an opaque ref (`Schema.Array(Player.pipe(DynamoModel.ref))`):
401
+ // re-point the whole array field at an array of the entity's model. The
402
+ // walker re-applies the field's optionality around the target.
403
+ const model = entity?.model;
404
+ if (model !== undefined && !targets.has(arrayField)) {
405
+ targets.set(arrayField, Schema.Array(unwrapEntityModel(model)));
406
+ }
407
+ }
408
+ else if (edge.entity !== undefined) {
409
+ // The element WRAPS the ref (`PlayerSheet { player }`), found by name as
410
+ // hydration finds it.
411
+ const refField = manyElementFields(fields, edgeName)?.[deriveEntityFieldName(edge.entity)];
412
+ if (refField !== undefined && isOpaqueRefField(refField)) {
413
+ register(refField, entity?.model);
414
+ }
415
+ }
416
+ registerNested(entity);
417
+ }
418
+ else if (edge._tag === "BoundSubAggregate") {
419
+ const bound = edge;
420
+ walk(fieldsOf(bound.aggregate.schema), bound.aggregate.edges, false);
421
+ }
422
+ }
423
+ };
424
+ walk(rootFields, rootEdges, true);
425
+ return targets;
426
+ };
427
+ /**
428
+ * Apply a node's attribute encoders in place — puts each attribute into its
429
+ * stored form before the decomposed item is marshalled (#72, #133: without
430
+ * this, a `DateTime` marshals to a `{ epochMilliseconds, … }` map that the
431
+ * read path cannot decode as the wire form).
193
432
  */
194
433
  const applyNodeAttrEncoders = (attrs, encoders) => {
195
434
  if (!encoders)
@@ -203,26 +442,8 @@ const applyNodeAttrEncoders = (attrs, encoders) => {
203
442
  // Top-level aggregate construction
204
443
  // ---------------------------------------------------------------------------
205
444
  const makeAggregate = (schema, config) => {
206
- // Build resolved graph by walking edges recursively
207
- const rootNode = resolveNode({
208
- fieldName: null,
209
- entityType: config.root.entityType,
210
- cardinality: "root",
211
- edges: config.edges,
212
- });
213
445
  const aggregateName = config.root.entityType;
214
446
  const contextFields = config.context ?? [];
215
- // Detect date encodings for all root schema fields (once, at make() time)
216
- // and build a per-field bidirectional substitute schema.
217
- //
218
- // Aggregates decompose/assemble at the root-attribute level — each date
219
- // field is converted in isolation (write: domain → wire primitive; read:
220
- // wire primitive → domain). To stay schema-driven (issue #29), we route
221
- // the conversion through the same `buildDateTransform` substitute used by
222
- // Entity. `Schema.encodeUnknownSync` produces the wire form on writes and
223
- // `Schema.decodeUnknownSync` lifts the wire form back on reads — wire
224
- // format is byte-identical to the legacy per-field helpers.
225
- //
226
447
  // Policy: aggregate fields that already carry a date transform schema
227
448
  // (e.g. `Schema.DateTimeUtcFromString`) cannot be combined with a
228
449
  // `DynamoModel.storedAs(...)` annotation that conflicts with the
@@ -235,58 +456,57 @@ const makeAggregate = (schema, config) => {
235
456
  // annotation conflict is checked.
236
457
  validateNoTransformOverride(schemaFields, {});
237
458
  }
238
- // Date ENCODERS for the write path: decompose works from the schema-decoded
239
- // domain object, so every root date field (Pattern A self-date AND Pattern B
240
- // transform) is serialized to its wire primitive for storage.
241
- const attrEncoders = buildAttrEncoders(schemaFields);
459
+ validateNestedDiscriminators(aggregateName, config.edges);
460
+ // Ref fields whose target the schema walker must be told about (see
461
+ // `collectRefTargets`). Keyed by the field SCHEMA, not its name: the resolver
462
+ // is consulted at every depth, so a name-keyed table re-pointed any
463
+ // same-named field anywhere in the model.
464
+ const refTargets = collectRefTargets(schemaFields, config.edges);
465
+ const resolveRef = (_name, field) => refTargets.get(field);
466
+ // Build resolved graph by walking edges recursively. Each node's attribute
467
+ // encoders come from the schema its decomposed values actually have — for a
468
+ // `many` edge that is the aggregate model's array ELEMENT (#133).
469
+ const rootNode = resolveNode({
470
+ fieldName: null,
471
+ entityType: config.root.entityType,
472
+ cardinality: "root",
473
+ edges: config.edges,
474
+ parentFields: schemaFields,
475
+ resolveRef,
476
+ });
477
+ // Attribute ENCODERS for the root item: decompose works from the
478
+ // schema-decoded domain object, so every root attribute holding a wire
479
+ // transform — a date, a transformed scalar, or a container of either — is
480
+ // put into its stored form. The key encoders are the pre-#133 ones, which the
481
+ // root's list-index composites are still read from (see `buildKeyAttrEncoders`).
482
+ const attrEncoders = buildAttrEncoders(schemaFields, resolveRef);
483
+ const keyAttrEncoders = buildKeyAttrEncoders(schemaFields);
484
+ const rootEncoders = { stored: attrEncoders, key: keyAttrEncoders };
242
485
  // Decode schema for read/assemble + input validation. Mirrors the raw `schema`
243
- // but recursively substitutes EVERY date / Redacted leaf — root and nested,
244
- // Pattern A and Pattern B — with a TOLERANT transform. This is the single
245
- // decode path for get / create / update:
486
+ // but recursively substitutes EVERY date / Redacted / transformed leaf — root
487
+ // and nested — with a TOLERANT transform. This is the single decode path for
488
+ // get / create / update:
246
489
  // - reads supply the stored wire form (string/number) → lifted to domain;
490
+ // a legacy marshalled `DateTime` map is rebuilt into a real one (#133);
247
491
  // - update re-decodes the mutated state, which carries domain `DateTime`
248
492
  // values → accepted as-is (a strict `*FromString` decoder would reject
249
493
  // them). It is decode-only, so making transforms tolerant is safe — the
250
- // stored wire format is still produced by `attrEncoders` / node encoders.
494
+ // stored wire format is still produced by the attribute encoders.
251
495
  // Nested edge / ref classes round-trip with their class instance identity
252
496
  // preserved (Option A). Returns the raw `schema` unchanged when it carries no
253
- // date / Redacted leaf at all (zero overhead).
254
- //
255
- // SINGLE ref/one edge fields carry the opaque `DynamoModel.ref` annotation, so
256
- // the substitution can't introspect them — `resolveRef` re-points each to its
257
- // edge target model (unwrapping `DynamoModel.configure`). MANY edges are
258
- // excluded: their model field is a `Schema.Array(...)` (or a wrapper class) that
259
- // `substituteSchemaDeep` introspects directly — re-pointing it at the element
260
- // model would drop the `Array` and yield "Expected object, got []" on assemble.
261
- const edgeRefModels = new Map();
262
- const unwrapModel = (model) => DynamoModel.isConfiguredModel(model) ? model.model : model;
263
- for (const [edgeName, edge] of Object.entries(config.edges)) {
264
- if (!("_tag" in edge))
265
- continue;
266
- if (edge._tag !== "RefEdge" && edge._tag !== "OneEdge")
267
- continue;
268
- const entity = edge.entity;
269
- const model = entity?.model;
270
- if (!model)
271
- continue;
272
- edgeRefModels.set(edgeName, unwrapModel(model));
273
- // A ref nested INSIDE an edge's model needs the same treatment. `maker` on
274
- // an edge entity is annotated with `DynamoModel.ref`, and `Schema.annotate`
275
- // drops a `Schema.Class`'s `.fields`, so the substitution cannot introspect
276
- // it and the target's own transformed fields keep their strict schemas —
277
- // which is what made `update` reject a `bigint` at
278
- // `["supplier"]["maker"]["founded"]` (#116). Registering the target by field
279
- // name re-points it exactly as a top-level edge is re-pointed.
280
- for (const nested of entity?._data?.resolvedRefs ?? []) {
281
- const nestedModel = nested.refEntity?.model;
282
- if (nestedModel && !edgeRefModels.has(nested.fieldName)) {
283
- edgeRefModels.set(nested.fieldName, unwrapModel(nestedModel));
284
- }
285
- }
286
- }
497
+ // such leaf at all (zero overhead).
287
498
  const decodeSchema = substituteSchemaDeep(schema, {
288
499
  tolerantTransforms: true,
289
- resolveRef: (name) => edgeRefModels.get(name),
500
+ resolveRef,
501
+ });
502
+ // The same schema for WRITES — `create` input and `update`'s mutated state —
503
+ // with the container `.check()` refinements the substitution rebuilds kept, so
504
+ // a write that breaks one is rejected (#133). Assembly keeps `decodeSchema`:
505
+ // a row written while those checks were not enforced must stay readable.
506
+ const writeSchema = substituteSchemaDeep(schema, {
507
+ tolerantTransforms: true,
508
+ resolveRef,
509
+ enforceChecks: true,
290
510
  });
291
511
  // Validate the collection index / consistency combination against the table's
292
512
  // primary key. Best effort: a table registering only aggregates has no primary key
@@ -360,6 +580,19 @@ const makeAggregate = (schema, config) => {
360
580
  // Build optics at construction time (once per aggregate definition)
361
581
  const classToPlain = Schema.toIso(schema);
362
582
  const opticRoot = Schema.toIsoFocus(schema);
583
+ // The same conversion through the READ schema. `toIso(schema)` re-checks the
584
+ // model's `.check()` refinements, so a stored row written while a container
585
+ // check was not enforced (#133) would make every `update` throw before the
586
+ // mutation could repair it. Reads accept such a row; so does this.
587
+ const readToPlain = Schema.toIso(decodeSchema);
588
+ const toPlainState = (value) => {
589
+ try {
590
+ return classToPlain.get(value);
591
+ }
592
+ catch {
593
+ return readToPlain.get(value);
594
+ }
595
+ };
363
596
  // Composite key form — the SAME rule and the SAME function the entity path
364
597
  // uses (`internal/CompositeCodec.ts`). Aggregates compose from assembled
365
598
  // domain objects, so without this a `DateEpochMs` composite would key on its
@@ -371,7 +604,24 @@ const makeAggregate = (schema, config) => {
371
604
  `${JSON.stringify(String(value))} resolves under neither encode nor decode->encode. ` +
372
605
  `Supply a value of the attribute's own type.`);
373
606
  });
374
- const keyRecord = (record) => toCompositeKeyRecord(compositeKeyForm, record);
607
+ // Only the attributes a key is ever composed from are put into key form. The
608
+ // rest of the record is never read off the result, and normalising it could
609
+ // only fail: a stored value that breaks a field's `.check()` (#133) made
610
+ // every `update` throw EDD-9050 over an attribute no key uses.
611
+ const compositeAttrs = new Set([
612
+ ...config.pk.composite,
613
+ ...(config.collection.sk?.composite ?? []),
614
+ ...(config.list?.pk.composite ?? []),
615
+ ...(config.list?.sk.composite ?? []),
616
+ ]);
617
+ const keyRecord = (record) => {
618
+ const composites = {};
619
+ for (const attr of compositeAttrs) {
620
+ if (attr in record)
621
+ composites[attr] = record[attr];
622
+ }
623
+ return toCompositeKeyRecord(compositeKeyForm, composites);
624
+ };
375
625
  /** Shared: compose PK and query all items */
376
626
  const fetchPartition = (key) => Effect.gen(function* () {
377
627
  const client = yield* DynamoClient;
@@ -423,7 +673,7 @@ const makeAggregate = (schema, config) => {
423
673
  // 2. Validate via schema decode (decodeSchema substitutes nested edge
424
674
  // self-date/Redacted leaves — Option A — and is identical to `schema`
425
675
  // when no edge needs it).
426
- const decoded = yield* Schema.decodeUnknownEffect(decodeSchema)(hydrated).pipe(Effect.mapError((cause) => new ValidationError({
676
+ const decoded = yield* Schema.decodeUnknownEffect(writeSchema)(hydrated).pipe(Effect.mapError((cause) => new ValidationError({
427
677
  entityType: aggregateName,
428
678
  operation: "aggregate.create",
429
679
  cause,
@@ -433,7 +683,7 @@ const makeAggregate = (schema, config) => {
433
683
  const composites = KeyComposer.extractComposites(config.pk.composite, keyRecord(assembled));
434
684
  const pkValue = composeCollectionKey(config.schema, config.collection.name, composites);
435
685
  // 4. Decompose into items grouped by sub-aggregate transaction boundaries
436
- const groups = yield* decomposeAggregate(assembled, rootNode, contextFields, attrEncoders, aggregateName, config.schema);
686
+ const groups = yield* decomposeAggregate(assembled, rootNode, contextFields, rootEncoders, aggregateName, config.schema);
437
687
  // 5. Compose collection SK composites for root item. Through the key
438
688
  // form — `update` already did (see `newDynamo` / `oldDynamo` below), so
439
689
  // leaving `create` raw made the two write paths mirror the collection SK
@@ -443,8 +693,25 @@ const makeAggregate = (schema, config) => {
443
693
  // Every row is new, so `created` has nothing to carry forward.
444
694
  const now = yield* DateTime.now;
445
695
  const dynamoGroups = buildDynamoItems(groups, config, pkValue, collectionSkComposites, keyRecord, stampFor(now, EMPTY_CREATED));
446
- // 7. Write via sub-aggregate transactions
447
- yield* writeTransactionGroups(client, tableConfig.name, dynamoGroups.map((g) => ({ group: g.group, puts: g.items, deletes: [] })), aggregateName);
696
+ // 7. Write via sub-aggregate transactions. The root item goes first in
697
+ // the first transaction, guarded by `attribute_not_exists` (#134):
698
+ // an existing aggregate cancels it before any edge or sub-aggregate
699
+ // row is written. Rows below the root need no guard of their own —
700
+ // they are only ever written after the root's transaction commits.
701
+ const rootSk = composeKey(config.schema, config.root.entityType, []);
702
+ const isRoot = (item) => item.sk?.S === rootSk;
703
+ const ordered = [...dynamoGroups]
704
+ .sort((a, b) => Number(b.items.some(isRoot)) - Number(a.items.some(isRoot)))
705
+ .map((g) => ({
706
+ group: g.group,
707
+ puts: [...g.items].sort((a, b) => Number(isRoot(b)) - Number(isRoot(a))),
708
+ deletes: [],
709
+ }));
710
+ yield* writeTransactionGroups(client, tableConfig.name, ordered, aggregateName, {
711
+ pkField: config.pk.field,
712
+ entityType: config.root.entityType,
713
+ key: { [config.pk.field]: pkValue, sk: rootSk },
714
+ });
448
715
  return decoded;
449
716
  }),
450
717
  update: (key, mutationFn) => Effect.gen(function* () {
@@ -459,7 +726,7 @@ const makeAggregate = (schema, config) => {
459
726
  // 1. Assemble current state
460
727
  const current = yield* assembleAggregate(decodeSchema, rootNode, allItems, contextFields, key, aggregateName, systemAttrs);
461
728
  // 2. Apply mutation — provide optics context for composable updates
462
- const state = classToPlain.get(current);
729
+ const state = toPlainState(current);
463
730
  const updated = mutationFn({
464
731
  state,
465
732
  cursor: makeCursor(state, opticRoot),
@@ -471,7 +738,7 @@ const makeAggregate = (schema, config) => {
471
738
  // for every date field (root + nested edges, Pattern A and Pattern B),
472
739
  // so update no longer trips the "Expected string, got DateTime" decode
473
740
  // that a strict `*FromString` schema would raise (#72 update path).
474
- const decoded = yield* Schema.decodeUnknownEffect(decodeSchema)(updated).pipe(Effect.mapError((cause) => new ValidationError({
741
+ const decoded = yield* Schema.decodeUnknownEffect(writeSchema)(updated).pipe(Effect.mapError((cause) => new ValidationError({
475
742
  entityType: aggregateName,
476
743
  operation: "aggregate.update",
477
744
  cause,
@@ -479,8 +746,8 @@ const makeAggregate = (schema, config) => {
479
746
  const assembledOld = { ...current };
480
747
  const assembledNew = { ...decoded };
481
748
  // 4. Decompose both old and new
482
- const oldGroups = yield* decomposeAggregate(assembledOld, rootNode, contextFields, attrEncoders, aggregateName, config.schema);
483
- const newGroups = yield* decomposeAggregate(assembledNew, rootNode, contextFields, attrEncoders, aggregateName, config.schema);
749
+ const oldGroups = yield* decomposeAggregate(assembledOld, rootNode, contextFields, rootEncoders, aggregateName, config.schema);
750
+ const newGroups = yield* decomposeAggregate(assembledNew, rootNode, contextFields, rootEncoders, aggregateName, config.schema);
484
751
  // 5. Diff at the ITEM (primary-key) level, not just the group level.
485
752
  // A `many`-edge element — and a cleared `one` edge — lives in its PARENT's
486
753
  // transaction group, so removing it SHRINKS a group rather than dropping a
@@ -576,7 +843,7 @@ const makeAggregate = (schema, config) => {
576
843
  // Root-item FilterExpression. Compiled once — the same two forms
577
844
  // `BoundQuery.filter()` takes, through the same `Expr` compiler, so
578
845
  // there is one filter vocabulary rather than a second dialect here.
579
- const rootFilter = compileListFilter(options?.filter);
846
+ const rootFilter = yield* compileListFilter(options?.filter, aggregateName);
580
847
  // Attribute names that make up a resume key on the list index: the index
581
848
  // key plus the table key. A `LastEvaluatedKey` supplies them when there
582
849
  // is one; this is the fallback for the case a filtered request over-reads
@@ -754,7 +1021,7 @@ const deepEqualGroups = (a, b) => {
754
1021
  });
755
1022
  };
756
1023
  const resolveNode = (args) => {
757
- const { assemble, cardinality, attrEncoders, decompose, discriminator, edges, entityType, fieldName, ownDiscriminator, refIdentifierField, skComposite, } = args;
1024
+ const { assemble, cardinality, attrEncoders, decompose, discriminator, edges, entityType, fieldName, keyAttrEncoders, ownDiscriminator, parentFields, refIdentifierField, resolveRef, skComposite, } = args;
758
1025
  const children = [];
759
1026
  for (const [field, edge] of Object.entries(edges)) {
760
1027
  if ("_tag" in edge) {
@@ -763,6 +1030,14 @@ const resolveNode = (args) => {
763
1030
  const mergedDisc = edge.discriminator
764
1031
  ? { ...(discriminator ?? {}), ...edge.discriminator }
765
1032
  : discriminator;
1033
+ // The item is the edge value flattened: encode it field by field with
1034
+ // the edge entity's model (#72), or — for an edge without an entity —
1035
+ // with the model field's own class.
1036
+ const parentField = parentFields?.[field];
1037
+ const valueFields = fieldsOf(edge.entity?.model) ??
1038
+ (parentField === undefined
1039
+ ? undefined
1040
+ : getSchemaFields(unwrapOptionalField(parentField)));
766
1041
  children.push(resolveNode({
767
1042
  fieldName: field,
768
1043
  entityType: edge.entityType,
@@ -770,11 +1045,19 @@ const resolveNode = (args) => {
770
1045
  edges: {},
771
1046
  discriminator: mergedDisc,
772
1047
  ownDiscriminator: edge.discriminator,
773
- // Encode this edge entity's own date fields on write (issue #72).
774
- attrEncoders: buildAttrEncoders(fieldsOf(edge.entity?.model)),
1048
+ attrEncoders: buildAttrEncoders(valueFields, resolveRef),
775
1049
  }));
776
1050
  }
777
1051
  else if (edge._tag === "ManyEdge") {
1052
+ // The item is one decomposed ELEMENT. Its encoders must come from the
1053
+ // element's schema — `PlayerSheet { player, isCaptain }` — not from the
1054
+ // edge entity's model (`Player { id, name, dateOfBirth }`), whose field
1055
+ // names never match a wrapper element's, so a hydrated ref inside the
1056
+ // element was never encoded (#133). An element that exposes no fields
1057
+ // (a custom `decompose`, an opaque ref element) falls back to the
1058
+ // entity's model, which is what such an element IS.
1059
+ const entityFields = fieldsOf(edge.entity?.model);
1060
+ const elementFields = manyElementFields(parentFields, field) ?? entityFields;
778
1061
  children.push(resolveNode({
779
1062
  fieldName: field,
780
1063
  entityType: edge.entityType,
@@ -783,7 +1066,10 @@ const resolveNode = (args) => {
783
1066
  discriminator,
784
1067
  assemble: edge.assemble,
785
1068
  decompose: edge.decompose,
786
- attrEncoders: buildAttrEncoders(fieldsOf(edge.entity?.model)),
1069
+ attrEncoders: buildAttrEncoders(elementFields, resolveRef),
1070
+ // Sort-key composites keep reading the pre-#133 encoding (the entity
1071
+ // model's top-level transforms), so no stored key is re-spelled.
1072
+ keyAttrEncoders: buildKeyAttrEncoders(entityFields),
787
1073
  // Declared sort-key composites, authoritative when present (#103).
788
1074
  skComposite: edge.sk?.composite,
789
1075
  refIdentifierField: edge.entity
@@ -793,16 +1079,28 @@ const resolveNode = (args) => {
793
1079
  }
794
1080
  else if (edge._tag === "BoundSubAggregate") {
795
1081
  const bound = edge;
1082
+ const subFields = fieldsOf(bound.aggregate.schema);
1083
+ // A sub-aggregate nested in another inherits its parent's discriminator,
1084
+ // exactly as a `one` edge does: its rows carry the parent's attributes
1085
+ // (`clubNo`) as well as their own (`squadNo`), so assembly — which
1086
+ // matches on the merged set — can tell the two bindings of the parent
1087
+ // apart. At the top level there is no parent discriminator and this is
1088
+ // `bound.discriminator`, as before.
1089
+ const inherited = discriminator === undefined
1090
+ ? bound.discriminator
1091
+ : { ...discriminator, ...bound.discriminator };
796
1092
  const subChildren = resolveNode({
797
1093
  fieldName: field,
798
1094
  entityType: bound.aggregate.root.entityType,
799
1095
  cardinality: "one",
800
1096
  edges: bound.aggregate.edges,
801
- discriminator: bound.discriminator,
1097
+ parentFields: subFields,
1098
+ resolveRef,
1099
+ discriminator: inherited,
802
1100
  ownDiscriminator: bound.discriminator,
803
1101
  // The sub-aggregate root item carries the sub-schema's own (non-edge)
804
- // date fields; its child edges get their own encoders via recursion.
805
- attrEncoders: buildAttrEncoders(fieldsOf(bound.aggregate.schema)),
1102
+ // fields; its child edges get their own encoders via recursion.
1103
+ attrEncoders: buildAttrEncoders(subFields, resolveRef),
806
1104
  });
807
1105
  children.push(subChildren);
808
1106
  }
@@ -821,6 +1119,7 @@ const resolveNode = (args) => {
821
1119
  ...(skComposite !== undefined && { skComposite }),
822
1120
  ...(refIdentifierField !== undefined && { refIdentifierField }),
823
1121
  attrEncoders,
1122
+ ...(keyAttrEncoders !== undefined && { keyAttrEncoders }),
824
1123
  };
825
1124
  };
826
1125
  // ---------------------------------------------------------------------------
@@ -1203,12 +1502,62 @@ const collectRefIdsFromEdges = (result, input, edges, path) => {
1203
1502
  }
1204
1503
  }
1205
1504
  };
1505
+ /**
1506
+ * Deep-copy a create input so ref replacement can mutate it — like
1507
+ * `structuredClone`, which this replaces, except that DOMAIN VALUES survive.
1508
+ *
1509
+ * `structuredClone` copies only own enumerable properties, so it reduced every
1510
+ * Effect data type to a bare object: a `DateTime` became `{ epochMilliseconds }`
1511
+ * with no prototype or tag, a `Redacted` became `{}`, an `Option` lost its
1512
+ * variant. No schema accepts those, so any aggregate with a ref edge rejected
1513
+ * such a value in its create input (#133).
1514
+ *
1515
+ * - Values implementing `Equal` — `DateTime`, `Redacted`, `Option`, `Duration`,
1516
+ * `Chunk`, `HashMap`, … — are immutable, so they are kept by reference.
1517
+ * - Built-ins `structuredClone` knows (`Date`, `Map`, `Set`, `RegExp`, binary
1518
+ * data) still go through it.
1519
+ * - Plain objects, arrays and other class instances (a `Schema.Class` value
1520
+ * among them) are walked and copied to plain objects, as `structuredClone`
1521
+ * copied them — so ref replacement never mutates the caller's objects.
1522
+ * - Cycles are preserved, as `structuredClone` preserved them.
1523
+ */
1524
+ const cloneInput = (value, seen = new WeakMap()) => {
1525
+ if (value === null || typeof value !== "object")
1526
+ return value;
1527
+ if (Equal.isEqual(value))
1528
+ return value;
1529
+ const prior = seen.get(value);
1530
+ if (prior !== undefined)
1531
+ return prior;
1532
+ if (Array.isArray(value)) {
1533
+ const copy = [];
1534
+ seen.set(value, copy);
1535
+ for (const entry of value)
1536
+ copy.push(cloneInput(entry, seen));
1537
+ return copy;
1538
+ }
1539
+ if (value instanceof Date ||
1540
+ value instanceof Map ||
1541
+ value instanceof Set ||
1542
+ value instanceof RegExp ||
1543
+ ArrayBuffer.isView(value) ||
1544
+ value instanceof ArrayBuffer) {
1545
+ const copy = structuredClone(value);
1546
+ seen.set(value, copy);
1547
+ return copy;
1548
+ }
1549
+ const copy = {};
1550
+ seen.set(value, copy);
1551
+ for (const [key, entry] of Object.entries(value))
1552
+ copy[key] = cloneInput(entry, seen);
1553
+ return copy;
1554
+ };
1206
1555
  /**
1207
1556
  * Replace ref ID fields in the input with hydrated domain data.
1208
1557
  */
1209
1558
  const replaceRefIds = (input, refRequests, lookup) => {
1210
1559
  // Deep clone the input for mutation
1211
- const result = structuredClone(input);
1560
+ const result = cloneInput(input);
1212
1561
  for (const req of refRequests) {
1213
1562
  const lookupKey = `${req.entity.entityType}:${req.id}`;
1214
1563
  const data = lookup.get(lookupKey);
@@ -1239,31 +1588,35 @@ const replaceRefIds = (input, refRequests, lookup) => {
1239
1588
  * Decompose an assembled domain object into DynamoDB items grouped by
1240
1589
  * sub-aggregate transaction boundaries.
1241
1590
  */
1242
- const decomposeAggregate = (assembled, rootNode, contextFields, attrEncoders, aggregateName, schema) => Effect.gen(function* () {
1591
+ const decomposeAggregate = (assembled, rootNode, contextFields, encoders, aggregateName, schema) => Effect.gen(function* () {
1243
1592
  const items = [];
1244
- // Extract context values from the root, serializing date fields for DynamoDB storage.
1593
+ const encodeWith = (set, field, value) => {
1594
+ const encode = set[field];
1595
+ return encode && value != null ? encode(value) : value;
1596
+ };
1597
+ // Extract context values from the root, encoded for DynamoDB storage.
1245
1598
  // Domain Date/DateTime objects must be encoded before toAttributeMap() marshalling,
1246
1599
  // otherwise Date objects become { M: {} } (no enumerable properties).
1247
- // The encoder is the substituted bidirectional date schema built at make() time.
1248
- const contextValues = {};
1600
+ const contextValues = { stored: {}, key: {} };
1249
1601
  for (const field of contextFields) {
1250
1602
  const value = assembled[field];
1251
- const encode = attrEncoders[field];
1252
- contextValues[field] = encode && value != null ? encode(value) : value;
1603
+ contextValues.stored[field] = encodeWith(encoders.stored, field, value);
1604
+ contextValues.key[field] = encodeWith(encoders.key, field, value);
1253
1605
  }
1254
1606
  // Root item: fields not claimed by edges
1255
1607
  const edgeFieldNames = new Set(rootNode.children.map((c) => c.fieldName).filter(Boolean));
1256
1608
  const rootAttrs = {};
1609
+ const rootKeyAttrs = {};
1257
1610
  for (const [key, value] of Object.entries(assembled)) {
1258
1611
  if (edgeFieldNames.has(key))
1259
1612
  continue;
1260
- // Serialize date fields in root attributes (same as context values)
1261
- const encode = attrEncoders[key];
1262
- rootAttrs[key] = encode && value != null ? encode(value) : value;
1613
+ rootAttrs[key] = encodeWith(encoders.stored, key, value);
1614
+ rootKeyAttrs[key] = encodeWith(encoders.key, key, value);
1263
1615
  }
1264
1616
  items.push({
1265
1617
  entityType: rootNode.entityType,
1266
1618
  attributes: rootAttrs,
1619
+ keyAttributes: rootKeyAttrs,
1267
1620
  transactionGroup: "root",
1268
1621
  skComposites: [],
1269
1622
  member: rootNode.entityType,
@@ -1326,13 +1679,21 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
1326
1679
  if (node.cardinality === "one" && node.children.length > 0) {
1327
1680
  // Sub-aggregate — becomes its own transaction group
1328
1681
  const subValue = value;
1329
- const txGroup = node.fieldName ?? node.entityType;
1330
- // Discriminator values for SK composition (name#value pairs from own discriminator)
1331
- const discValues = node.ownDiscriminator
1332
- ? Object.entries(node.ownDiscriminator)
1333
- .filter(([, v]) => typeof v !== "function")
1334
- .flatMap(([k, v]) => [k, KeyComposer.serializeValue(v)])
1335
- : [];
1682
+ // A nested sub-aggregate is its own transaction group, named by its path
1683
+ // (`club2.squad`) so the two bindings of its parent stay distinct groups.
1684
+ const ownName = node.fieldName ?? node.entityType;
1685
+ const txGroup = parentGroup === "root" ? ownName : `${parentGroup}.${ownName}`;
1686
+ // Discriminator values for SK composition: the parent sub-aggregate's
1687
+ // (name#value pairs, empty at the top level) then this node's own, so a
1688
+ // nested sub-aggregate's rows are keyed apart per binding of its parent.
1689
+ const discValues = [
1690
+ ...parentDiscriminatorValues,
1691
+ ...(node.ownDiscriminator
1692
+ ? Object.entries(node.ownDiscriminator)
1693
+ .filter(([, v]) => typeof v !== "function")
1694
+ .flatMap(([k, v]) => [k, KeyComposer.serializeValue(v)])
1695
+ : []),
1696
+ ];
1336
1697
  // Sub-aggregate root item: fields not claimed by child edges
1337
1698
  const childEdgeNames = new Set(node.children.map((c) => c.fieldName).filter(Boolean));
1338
1699
  const subRootAttrs = {};
@@ -1344,7 +1705,7 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
1344
1705
  // Serialize this node's own date fields to their wire primitive (#72).
1345
1706
  applyNodeAttrEncoders(subRootAttrs, node.attrEncoders);
1346
1707
  // Inject context and discriminator
1347
- mergeContextValues(subRootAttrs, contextValues);
1708
+ mergeContextValues(subRootAttrs, contextValues.stored);
1348
1709
  if (node.discriminator) {
1349
1710
  for (const [k, v] of Object.entries(node.discriminator)) {
1350
1711
  if (typeof v !== "function")
@@ -1369,7 +1730,7 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
1369
1730
  const attrs = { ...value };
1370
1731
  // Serialize this edge entity's own date fields to their wire primitive (#72).
1371
1732
  applyNodeAttrEncoders(attrs, node.attrEncoders);
1372
- mergeContextValues(attrs, contextValues);
1733
+ mergeContextValues(attrs, contextValues.stored);
1373
1734
  if (node.discriminator) {
1374
1735
  for (const [k, v] of Object.entries(node.discriminator)) {
1375
1736
  if (typeof v !== "function")
@@ -1397,18 +1758,30 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
1397
1758
  : Array.isArray(value)
1398
1759
  ? value
1399
1760
  : [];
1400
- for (const elem of arrayItems) {
1401
- const attrs = { ...elem };
1402
- // Serialize this edge entity's own date fields to their wire primitive (#72).
1403
- applyNodeAttrEncoders(attrs, node.attrEncoders);
1404
- mergeContextValues(attrs, contextValues);
1761
+ const withContext = (attrs, context) => {
1762
+ mergeContextValues(attrs, context);
1405
1763
  if (node.discriminator) {
1406
1764
  for (const [k, v] of Object.entries(node.discriminator)) {
1407
1765
  if (typeof v !== "function")
1408
1766
  attrs[k] = v;
1409
1767
  }
1410
1768
  }
1411
- const itemComposites = yield* manyEdgeSkComposites(node, attrs, aggregateName);
1769
+ return attrs;
1770
+ };
1771
+ for (const elem of arrayItems) {
1772
+ // The element's attributes in their STORED form — every nested date,
1773
+ // transformed scalar and hydrated ref encoded (#72, #133).
1774
+ const attrs = { ...elem };
1775
+ applyNodeAttrEncoders(attrs, node.attrEncoders);
1776
+ withContext(attrs, contextValues.stored);
1777
+ // The sort key is read from the element as the pre-#133 encoders left
1778
+ // it, NOT from `attrs`: encoding a ref's nested `NumberFromString` turns
1779
+ // a padded `0000000000000005` key segment into `5`, so composing from
1780
+ // the stored form would re-spell (and orphan) existing rows.
1781
+ const keyAttrs = { ...elem };
1782
+ applyNodeAttrEncoders(keyAttrs, node.keyAttrEncoders);
1783
+ withContext(keyAttrs, contextValues.key);
1784
+ const itemComposites = yield* manyEdgeSkComposites(node, keyAttrs, aggregateName);
1412
1785
  items.push({
1413
1786
  entityType: node.entityType,
1414
1787
  attributes: attrs,
@@ -1593,7 +1966,7 @@ keyRecord, stamp) => groups.map((group) => ({
1593
1966
  }
1594
1967
  // List collection GSI keys (root items only)
1595
1968
  if (isRootItem && config.list) {
1596
- const listPkComposites = KeyComposer.extractComposites(config.list.pk.composite, keyRecord(item.attributes));
1969
+ const listPkComposites = KeyComposer.extractComposites(config.list.pk.composite, keyRecord(item.keyAttributes ?? item.attributes));
1597
1970
  if (config.list.cardinality) {
1598
1971
  const shard = hashToShard(pkValue, config.list.cardinality);
1599
1972
  attrs[config.list.pk.field] = composeCollectionKey(config.schema, config.list.name, [
@@ -1604,7 +1977,7 @@ keyRecord, stamp) => groups.map((group) => ({
1604
1977
  else {
1605
1978
  attrs[config.list.pk.field] = composeCollectionKey(config.schema, config.list.name, listPkComposites);
1606
1979
  }
1607
- const listSkComposites = KeyComposer.extractComposites(config.list.sk.composite, keyRecord(item.attributes));
1980
+ const listSkComposites = KeyComposer.extractComposites(config.list.sk.composite, keyRecord(item.keyAttributes ?? item.attributes));
1608
1981
  attrs[config.list.sk.field] = composeCollectionKey(config.schema, config.list.name, listSkComposites);
1609
1982
  }
1610
1983
  // System timestamps — last, so a generated value wins over the same-named
@@ -1623,56 +1996,78 @@ keyRecord, stamp) => groups.map((group) => ({
1623
1996
  return toAttributeMap(attrs);
1624
1997
  }),
1625
1998
  }));
1626
- // ---------------------------------------------------------------------------
1627
- // Internal: Write transaction groups to DynamoDB
1628
- // ---------------------------------------------------------------------------
1629
- const writeTransactionGroups = (client, tableName, groups, aggregateName) => Effect.gen(function* () {
1630
- // Validate ALL group sizes BEFORE writing any group. Each group is its own
1631
- // transaction (DynamoDB: 100 items per TransactWriteItems), and the per-group
1632
- // count now spans Puts AND Deletes together — so an `update` that both adds and
1633
- // removes many edges roughly doubles the count. Checking up front means an
1634
- // oversized later group fails fast instead of partially committing earlier ones.
1635
- for (const group of groups) {
1636
- const itemCount = group.puts.length + group.deletes.length;
1637
- if (itemCount > 100) {
1638
- return yield* new AggregateTransactionOverflow({
1639
- aggregate: aggregateName,
1640
- subgraph: group.group,
1641
- itemCount,
1642
- limit: 100,
1643
- });
1644
- }
1645
- }
1646
- for (const group of groups) {
1647
- // Each group is one transaction — Puts AND Deletes applied atomically, so an
1648
- // edge add and a sibling edge removal in the same sub-aggregate commit together.
1649
- const itemCount = group.puts.length + group.deletes.length;
1650
- if (itemCount === 0)
1651
- continue;
1652
- // Build TransactWriteItems request — Puts first, then Deletes for orphans.
1653
- const transactItems = [
1654
- ...group.puts.map((item) => ({ Put: { TableName: tableName, Item: item } })),
1655
- ...group.deletes.map((key) => ({ Delete: { TableName: tableName, Key: key } })),
1656
- ];
1657
- yield* client.transactWriteItems({ TransactItems: transactItems }).pipe(Effect.mapError((error) => {
1658
- if (error.cause != null &&
1659
- typeof error.cause === "object" &&
1660
- "name" in error.cause &&
1661
- error.cause.name === "TransactionCanceledException") {
1662
- const cancelled = error.cause;
1663
- return new TransactionCancelled({
1664
- operation: "TransactWriteItems",
1665
- reasons: (cancelled.CancellationReasons ?? []).map((r) => ({
1666
- code: r?.Code,
1667
- message: r?.Message,
1668
- })),
1669
- cause: error.cause,
1999
+ function writeTransactionGroups(client, tableName, groups, aggregateName, rootGuard) {
2000
+ return Effect.gen(function* () {
2001
+ // Validate ALL group sizes BEFORE writing any group. Each group is its own
2002
+ // transaction (DynamoDB: 100 items per TransactWriteItems), and the per-group
2003
+ // count now spans Puts AND Deletes together — so an `update` that both adds and
2004
+ // removes many edges roughly doubles the count. Checking up front means an
2005
+ // oversized later group fails fast instead of partially committing earlier ones.
2006
+ for (const group of groups) {
2007
+ const itemCount = group.puts.length + group.deletes.length;
2008
+ if (itemCount > 100) {
2009
+ return yield* new AggregateTransactionOverflow({
2010
+ aggregate: aggregateName,
2011
+ subgraph: group.group,
2012
+ itemCount,
2013
+ limit: 100,
1670
2014
  });
1671
2015
  }
1672
- return error;
1673
- }));
1674
- }
1675
- });
2016
+ }
2017
+ for (const [groupIndex, group] of groups.entries()) {
2018
+ // Each group is one transaction — Puts AND Deletes applied atomically, so an
2019
+ // edge add and a sibling edge removal in the same sub-aggregate commit together.
2020
+ const itemCount = group.puts.length + group.deletes.length;
2021
+ if (itemCount === 0)
2022
+ continue;
2023
+ // The root item, in the first transaction only.
2024
+ const rootIndex = rootGuard !== undefined && groupIndex === 0
2025
+ ? group.puts.findIndex((item) => item.sk?.S === rootGuard.key.sk)
2026
+ : -1;
2027
+ // Build TransactWriteItems request — Puts first, then Deletes for orphans.
2028
+ const transactItems = [
2029
+ ...group.puts.map((item, i) => ({
2030
+ Put: {
2031
+ TableName: tableName,
2032
+ Item: item,
2033
+ ...(rootGuard !== undefined && i === rootIndex
2034
+ ? {
2035
+ ConditionExpression: "attribute_not_exists(#pk)",
2036
+ ExpressionAttributeNames: { "#pk": rootGuard.pkField },
2037
+ }
2038
+ : {}),
2039
+ },
2040
+ })),
2041
+ ...group.deletes.map((key) => ({ Delete: { TableName: tableName, Key: key } })),
2042
+ ];
2043
+ yield* client.transactWriteItems({ TransactItems: transactItems }).pipe(Effect.mapError((error) => {
2044
+ if (error.cause != null &&
2045
+ typeof error.cause === "object" &&
2046
+ "name" in error.cause &&
2047
+ error.cause.name === "TransactionCanceledException") {
2048
+ const cancelled = error.cause;
2049
+ if (rootGuard !== undefined &&
2050
+ rootIndex >= 0 &&
2051
+ cancelled.CancellationReasons?.[rootIndex]?.Code === "ConditionalCheckFailed") {
2052
+ return new ConditionalCheckFailed({
2053
+ entityType: rootGuard.entityType,
2054
+ key: rootGuard.key,
2055
+ });
2056
+ }
2057
+ return new TransactionCancelled({
2058
+ operation: "TransactWriteItems",
2059
+ reasons: (cancelled.CancellationReasons ?? []).map((r) => ({
2060
+ code: r?.Code,
2061
+ message: r?.Message,
2062
+ })),
2063
+ cause: error.cause,
2064
+ });
2065
+ }
2066
+ return error;
2067
+ }));
2068
+ }
2069
+ });
2070
+ }
1676
2071
  // ---------------------------------------------------------------------------
1677
2072
  // Internal: Delete all items in an aggregate partition
1678
2073
  // ---------------------------------------------------------------------------
@@ -1686,14 +2081,35 @@ const deleteAllItems = (client, tableName, allItems, pkField) => Effect.gen(func
1686
2081
  }),
1687
2082
  },
1688
2083
  }));
1689
- // Chunk into batches of 25 (DynamoDB batchWriteItem limit)
2084
+ // Chunk into batches of 25 (DynamoDB batchWriteItem limit). Items DynamoDB
2085
+ // leaves unprocessed (throttling) are retried with exponential backoff, as
2086
+ // `Batch.write` retries them; a bounded number of retries, then a clear
2087
+ // failure — never a silent partial delete (#134).
1690
2088
  for (let i = 0; i < deleteRequests.length; i += 25) {
1691
- const chunk = deleteRequests.slice(i, i + 25);
1692
- yield* client.batchWriteItem({
1693
- RequestItems: { [tableName]: chunk },
1694
- });
2089
+ let pending = deleteRequests.slice(i, i + 25);
2090
+ let retries = 0;
2091
+ while (pending.length > 0) {
2092
+ const response = yield* client.batchWriteItem({
2093
+ RequestItems: { [tableName]: pending },
2094
+ });
2095
+ pending = (response.UnprocessedItems?.[tableName] ?? []);
2096
+ if (pending.length === 0)
2097
+ break;
2098
+ retries++;
2099
+ if (retries > DELETE_MAX_RETRIES) {
2100
+ return yield* new DynamoError({
2101
+ operation: "BatchWriteItem",
2102
+ cause: new Error(`Aggregate delete: ${pending.length} unprocessed item(s) remain after ` +
2103
+ `${DELETE_MAX_RETRIES} retries; the partition is partly deleted.`),
2104
+ });
2105
+ }
2106
+ yield* Effect.sleep(`${DELETE_BASE_DELAY_MS * 2 ** (retries - 1)} millis`);
2107
+ }
1695
2108
  }
1696
2109
  });
2110
+ /** `Batch.write`'s bound and backoff, for `deleteAllItems`' unprocessed items. */
2111
+ const DELETE_MAX_RETRIES = 5;
2112
+ const DELETE_BASE_DELAY_MS = 100;
1697
2113
  // ---------------------------------------------------------------------------
1698
2114
  // Internal: list filter compilation
1699
2115
  // ---------------------------------------------------------------------------
@@ -1702,18 +2118,27 @@ const deleteAllItems = (client, tableName, allItems, pkField) => Effect.gen(func
1702
2118
  *
1703
2119
  * Both forms route through the same `Expr` compiler `BoundQuery.filter()` uses,
1704
2120
  * so `{ status: "shipped" }` and `(t, { eq }) => eq(t.status, "shipped")` mean
1705
- * the same thing here as they do on an entity query. A shorthand with no
1706
- * entries compiles to the empty string — that is a no-op, not `FilterExpression: ""`,
1707
- * which DynamoDB rejects.
2121
+ * the same thing here as they do on an entity query, under the same empty-part
2122
+ * rules (#134): a filter that asserts nothing (`{}`, `and()`) is no filter, an
2123
+ * empty part directly under `and` is left out, and one anywhere else — or an
2124
+ * `or()` with no parts, or an `isIn` with no values — is refused with a
2125
+ * `ValidationError` before anything is sent.
1708
2126
  */
1709
- const compileListFilter = (filter) => {
2127
+ const compileListFilter = (filter, aggregateName) => {
1710
2128
  if (filter === undefined)
1711
- return undefined;
2129
+ return Effect.succeed(undefined);
1712
2130
  const expr = typeof filter === "function"
1713
2131
  ? filter(createPathBuilder(), createConditionOps())
1714
2132
  : parseSimpleShorthand(filter);
1715
- const compiled = compileExpr(expr);
1716
- return compiled.expression === "" ? undefined : compiled;
2133
+ const problem = emptyPartProblem(expr);
2134
+ if (problem !== undefined) {
2135
+ return Effect.fail(new ValidationError({
2136
+ entityType: aggregateName,
2137
+ operation: "list.filter",
2138
+ cause: `list filter: ${problem} Nothing was sent.`,
2139
+ }));
2140
+ }
2141
+ return Effect.succeed(isEmptyExpr(expr) ? undefined : compileExpr(expr));
1717
2142
  };
1718
2143
  /**
1719
2144
  * @internal DynamoDB `Limit` for the next request — the same rule `Query.ts`