effect-dynamodb 1.3.3 → 1.5.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 (50) hide show
  1. package/dist/Aggregate.d.ts.map +1 -1
  2. package/dist/Aggregate.js +51 -23
  3. package/dist/Aggregate.js.map +1 -1
  4. package/dist/DynamoModel.d.ts +50 -2
  5. package/dist/DynamoModel.d.ts.map +1 -1
  6. package/dist/DynamoModel.js +73 -1
  7. package/dist/DynamoModel.js.map +1 -1
  8. package/dist/Entity.d.ts +48 -21
  9. package/dist/Entity.d.ts.map +1 -1
  10. package/dist/Entity.js +586 -223
  11. package/dist/Entity.js.map +1 -1
  12. package/dist/Errors.d.ts +48 -1
  13. package/dist/Errors.d.ts.map +1 -1
  14. package/dist/Errors.js +38 -1
  15. package/dist/Errors.js.map +1 -1
  16. package/dist/Marshaller.d.ts +29 -12
  17. package/dist/Marshaller.d.ts.map +1 -1
  18. package/dist/Marshaller.js +104 -84
  19. package/dist/Marshaller.js.map +1 -1
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/internal/BoundCrud.d.ts +68 -0
  25. package/dist/internal/BoundCrud.d.ts.map +1 -1
  26. package/dist/internal/BoundCrud.js +42 -1
  27. package/dist/internal/BoundCrud.js.map +1 -1
  28. package/dist/internal/EntityCombinators.d.ts +28 -0
  29. package/dist/internal/EntityCombinators.d.ts.map +1 -1
  30. package/dist/internal/EntityCombinators.js +40 -0
  31. package/dist/internal/EntityCombinators.js.map +1 -1
  32. package/dist/internal/EntityOps.d.ts +18 -0
  33. package/dist/internal/EntityOps.d.ts.map +1 -1
  34. package/dist/internal/EntityOps.js +2 -0
  35. package/dist/internal/EntityOps.js.map +1 -1
  36. package/dist/internal/EntitySchemas.d.ts +93 -16
  37. package/dist/internal/EntitySchemas.d.ts.map +1 -1
  38. package/dist/internal/EntitySchemas.js +396 -79
  39. package/dist/internal/EntitySchemas.js.map +1 -1
  40. package/dist/internal/EntityTypes.d.ts +7 -12
  41. package/dist/internal/EntityTypes.d.ts.map +1 -1
  42. package/dist/internal/PathBuilder.d.ts +29 -4
  43. package/dist/internal/PathBuilder.d.ts.map +1 -1
  44. package/dist/internal/PathBuilder.js +47 -6
  45. package/dist/internal/PathBuilder.js.map +1 -1
  46. package/dist/internal/TransactableOps.d.ts +5 -0
  47. package/dist/internal/TransactableOps.d.ts.map +1 -1
  48. package/dist/internal/TransactableOps.js +45 -27
  49. package/dist/internal/TransactableOps.js.map +1 -1
  50. package/package.json +1 -1
package/dist/Entity.js CHANGED
@@ -6,26 +6,26 @@
6
6
  * carries derived schemas for 7 type extractors (Model, Record, Input, Update,
7
7
  * Key, Item, Marshalled).
8
8
  */
9
- import { DateTime, Duration, Effect, Schema, Stream } from "effect";
9
+ import { DateTime, Duration, Effect, Option, Schema, Stream } from "effect";
10
10
  import { DynamoClient } from "./DynamoClient.js";
11
- import { getEncoding, getIdentifierField, isConfiguredModel, isHidden, isRef, isRefField, } from "./DynamoModel.js";
11
+ import { getIdentifierField, getSparseFields, isConfiguredModel, isHidden, isRecordAst, isRecordSchema, isRef, isRefField, } from "./DynamoModel.js";
12
12
  import * as DynamoSchema from "./DynamoSchema.js";
13
- import { CascadePartialFailure, ConditionalCheckFailed, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled, OptimisticLockError, RefNotFound, TransactionOverflow, UniqueConstraintViolation, ValidationError, } from "./Errors.js";
14
- import { makeBoundDelete, makeBoundPut, makeBoundUpdate } from "./internal/BoundCrud.js";
13
+ import { CascadePartialFailure, ConditionalCheckFailed, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled, OptimisticLockError, RefNotFound, StaleAppend, TransactionOverflow, UniqueConstraintViolation, ValidationError, } from "./Errors.js";
14
+ import { makeBoundAppend, makeBoundDelete, makeBoundPut, makeBoundUpdate, } from "./internal/BoundCrud.js";
15
15
  import { BoundQueryImpl } from "./internal/BoundQuery.js";
16
16
  import { compileExpr, createConditionOps, isExpr, parseShorthand, parseSimpleShorthand, } from "./internal/Expr.js";
17
17
  import { compilePath, createPathBuilder } from "./internal/PathBuilder.js";
18
18
  import * as KeyComposer from "./KeyComposer.js";
19
19
  import { normalizeGsiConfig } from "./KeyComposer.js";
20
- import { deserializeDateFromDynamo, fromAttributeMap, serializeDateForDynamo, toAttributeMap, toAttributeValue, } from "./Marshaller.js";
20
+ import { decodeSparseFields, encodeSparseFields, fromAttributeMap, toAttributeMap, toAttributeValue, } from "./Marshaller.js";
21
21
  import * as Query from "./Query.js";
22
22
  import { filterExpr, selectPaths } from "./Query.js";
23
23
  export { EntityDeleteImpl, EntityDeleteTypeId, EntityGetImpl, EntityOpTypeId, EntityPutImpl, EntityUpdateImpl, EntityUpdateTypeId, emptyUpdateState, returnValuesMap, } from "./internal/EntityOps.js";
24
24
  import { EntityDeleteImpl, EntityDeleteTypeId, EntityGetImpl, EntityOpTypeId, EntityPutImpl, EntityUpdateImpl, emptyUpdateState, returnValuesMap, } from "./internal/EntityOps.js";
25
25
  import * as Projection from "./Projection.js";
26
- export { add, append, asItem, asModel, asNative, asRecord, cascade, consistentRead, deleteFromSet, expectedVersion, pathAdd, pathAppend, pathDelete, pathIfNotExists, pathPrepend, pathRemove, pathSet, pathSubtract, project, remove, returnValues, set, subtract, } from "./internal/EntityCombinators.js";
26
+ 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";
27
27
  import { asModel, condition as conditionCombinator, expectedVersion, set, } from "./internal/EntityCombinators.js";
28
- import { allCompositeAttributes, allKeyFieldNames, buildDerivedSchemas, getFields, inferDefaultEncoding, primaryKeyComposites, resolveSystemFields, resolveUniqueFields, } from "./internal/EntitySchemas.js";
28
+ import { allCompositeAttributes, allKeyFieldNames, buildDerivedSchemas, buildFieldEncodings, getFields, primaryKeyComposites, resolveSystemFields, resolveUniqueFields, validateNoTransformOverride, } from "./internal/EntitySchemas.js";
29
29
  // ---------------------------------------------------------------------------
30
30
  // Sparse-aware unique sentinel composition
31
31
  // ---------------------------------------------------------------------------
@@ -61,6 +61,40 @@ const composeUniqueSentinel = (schema, entityType, constraintName, constraintDef
61
61
  };
62
62
  };
63
63
  // ---------------------------------------------------------------------------
64
+ // Encode-or-decode-encode helper
65
+ // ---------------------------------------------------------------------------
66
+ /**
67
+ * Validate user input and produce wire-form output, accepting both Type and
68
+ * Encoded shapes. Strategy:
69
+ *
70
+ * 1. Try `Schema.encode(input)`. This is the canonical Type → Encoded
71
+ * conversion and works when the user passes Type values for transforms
72
+ * (e.g. a `DateTime.Utc` for `Schema.DateTimeUtcFromString`, a `number`
73
+ * for `Schema.NumberFromString`, a `Redacted<string>` for our
74
+ * RedactedFromValue substitute).
75
+ *
76
+ * 2. On encode failure, fall back to `Schema.decode → Schema.encode`. This
77
+ * lifts plain inputs to the Type shape first (`Schema.Class` decode is
78
+ * forgiving — it constructs a class instance from a plain object), then
79
+ * re-encodes back to wire form. Required for nested `Schema.Class`
80
+ * fields whose Type is a class instance but whose Encoded is a plain
81
+ * object — users typically construct the plain object directly.
82
+ *
83
+ * Returns the Encoded shape (a plain object whose values are wire
84
+ * primitives), ready for `toAttributeMap`.
85
+ *
86
+ * @internal
87
+ */
88
+ const encodeOrDecodeEncode = (schema, input, entityType, operation) => Schema.encodeUnknownEffect(schema)(input).pipe(Effect.catch((primaryCause) => Schema.decodeUnknownEffect(schema)(input).pipe(Effect.flatMap((decoded) => Schema.encodeUnknownEffect(schema)(decoded)),
89
+ // On fallback failure, surface the original encode error — its
90
+ // message is keyed on the caller's input shape, which is what the
91
+ // user expects to see.
92
+ Effect.catch(() => Effect.fail(new ValidationError({
93
+ entityType,
94
+ operation: `${operation}.decode`,
95
+ cause: primaryCause,
96
+ }))))));
97
+ // ---------------------------------------------------------------------------
64
98
  // Transaction limit pre-check (100-item DynamoDB limit)
65
99
  // ---------------------------------------------------------------------------
66
100
  /**
@@ -306,7 +340,15 @@ const makeImpl = (config) => {
306
340
  }
307
341
  // Resolve identifier field name from model annotation
308
342
  const resolvedIdentifier = getIdentifierField(config.model)?.name;
309
- const schemas = buildDerivedSchemas(modelFields, allIndexes, systemFields, resolvedRefs, immutableFields, resolvedIdentifier, config.timeSeries);
343
+ // Validate: no model field combines a transform schema with a ConfiguredModel
344
+ // storage override. The two configuration paths are mutually exclusive.
345
+ validateNoTransformOverride(modelFields, configuredAttributes);
346
+ // Resolve effective field encodings (annotation + ConfiguredModel override).
347
+ // Used by buildDerivedSchemas to substitute self date schemas with bidirectional
348
+ // transforms, and (below) by system-field generation to produce the right
349
+ // wire primitive when the field is library-managed.
350
+ const fieldEncodings = buildFieldEncodings(modelFields, configuredAttributes);
351
+ const schemas = buildDerivedSchemas(modelFields, allIndexes, systemFields, resolvedRefs, immutableFields, resolvedIdentifier, config.timeSeries, fieldEncodings);
310
352
  // schema and tableTag are injected via _configure() when the entity is registered
311
353
  // on a Table and bound through DynamoClient.make(). They are captured by operation
312
354
  // closures and resolved at runtime (inside Effects), not at definition time.
@@ -315,37 +357,68 @@ const makeImpl = (config) => {
315
357
  let tableTag;
316
358
  const entityType = config.entityType;
317
359
  const entityVersion = 1;
360
+ // Note: `fieldEncodings` is resolved earlier (above buildDerivedSchemas) and
361
+ // used to substitute self date schemas in derived schemas. System timestamp
362
+ // encodings are NOT in `fieldEncodings`; `generateTimestamp` produces wire
363
+ // primitives directly for non-colliding system fields.
318
364
  // ---------------------------------------------------------------------------
319
- // DynamoEncoding map: field name → encoding for date-annotated fields
320
- // Merges schema annotations with ConfiguredModel storage overrides.
365
+ // Sparse Map fields (storedAs: 'sparse') — validation + resolution
321
366
  // ---------------------------------------------------------------------------
322
- const fieldEncodings = {};
323
- for (const [fieldName, fieldSchema] of Object.entries(modelFields)) {
324
- const encoding = getEncoding(fieldSchema);
325
- if (encoding) {
326
- fieldEncodings[fieldName] = encoding;
367
+ const sparseFields = getSparseFields(config.model);
368
+ const hasSparseFields = Object.keys(sparseFields).length > 0;
369
+ if (hasSparseFields) {
370
+ // EDD-9020: storedAs: 'sparse' is only valid on Schema.Record fields.
371
+ for (const fieldName of Object.keys(sparseFields)) {
372
+ const fieldSchema = modelFields[fieldName];
373
+ if (!fieldSchema) {
374
+ throw new Error(`[EDD-9020] Entity "${config.entityType}": sparse field "${fieldName}" does not exist on the model`);
375
+ }
376
+ const recordInfo = isRecordSchema(fieldSchema);
377
+ if (!recordInfo) {
378
+ throw new Error(`[EDD-9020] Entity "${config.entityType}": sparse field "${fieldName}" must be a Schema.Record. ` +
379
+ `Got a non-Record schema. Sparse storage flattens Record entries into per-key top-level attributes.`);
380
+ }
381
+ // EDD-9021: inner value must not be another Record (no nested sparse).
382
+ if (isRecordAst(recordInfo.valueAst)) {
383
+ throw new Error(`[EDD-9021] Entity "${config.entityType}": sparse field "${fieldName}" has a Record-typed value schema. ` +
384
+ `Nested sparse Records are not supported — use Schema.Struct, Schema.Number, etc. for the inner value.`);
385
+ }
327
386
  }
328
- else {
329
- // Auto-detect standard Effect date schemas (Pattern B: pure model)
330
- const inferred = inferDefaultEncoding(fieldSchema);
331
- if (inferred)
332
- fieldEncodings[fieldName] = inferred;
387
+ // EDD-9022: sparse fields cannot participate in primary key, GSI composites,
388
+ // or unique constraints — keys aren't statically known at make() time.
389
+ const allComposites = new Set(allCompositeAttributes(allIndexes));
390
+ for (const fieldName of Object.keys(sparseFields)) {
391
+ if (allComposites.has(fieldName)) {
392
+ throw new Error(`[EDD-9022] Entity "${config.entityType}": sparse field "${fieldName}" cannot be a primary-key or GSI composite. ` +
393
+ `Composite values must be known at make() time; sparse-map keys are not.`);
394
+ }
333
395
  }
334
- }
335
- // Apply ConfiguredModel storage overrides (takes precedence over schema annotations)
336
- for (const [fieldName, attrConfig] of Object.entries(configuredAttributes)) {
337
- if (attrConfig.encoding) {
338
- const existing = fieldEncodings[fieldName];
339
- fieldEncodings[fieldName] = {
340
- storage: attrConfig.encoding.storage,
341
- domain: existing?.domain ?? attrConfig.encoding.domain,
342
- };
396
+ if (config.unique) {
397
+ const sparseSet = new Set(Object.keys(sparseFields));
398
+ for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
399
+ const fields = resolveUniqueFields(constraintDef);
400
+ for (const f of fields) {
401
+ if (sparseSet.has(f)) {
402
+ throw new Error(`[EDD-9022] Entity "${config.entityType}": unique constraint "${constraintName}" cannot reference sparse field "${f}".`);
403
+ }
404
+ }
405
+ }
406
+ }
407
+ // EDD-9023: sparse prefixes must be unique among themselves and must not
408
+ // collide with non-sparse top-level model field names.
409
+ const seenPrefix = new Map(); // prefix → fieldName
410
+ for (const [fieldName, sparse] of Object.entries(sparseFields)) {
411
+ const existing = seenPrefix.get(sparse.prefix);
412
+ if (existing) {
413
+ throw new Error(`[EDD-9023] Entity "${config.entityType}": sparse fields "${existing}" and "${fieldName}" share prefix "${sparse.prefix}". Prefixes must be distinct.`);
414
+ }
415
+ seenPrefix.set(sparse.prefix, fieldName);
416
+ // Prefix must not collide with another (non-sparse) model field name.
417
+ if (sparse.prefix !== fieldName && sparse.prefix in modelFields) {
418
+ throw new Error(`[EDD-9023] Entity "${config.entityType}": sparse field "${fieldName}" prefix "${sparse.prefix}" collides with non-sparse field "${sparse.prefix}".`);
419
+ }
343
420
  }
344
421
  }
345
- // Note: System timestamp encodings are NOT added to fieldEncodings.
346
- // generateTimestamp() handles serialization directly for timestamps.
347
- // This avoids double-serialization in serializeDateFields.
348
- const hasDateFields = Object.keys(fieldEncodings).length > 0;
349
422
  // ---------------------------------------------------------------------------
350
423
  // Field renaming: domain name → DynamoDB attribute name (from ConfiguredModel)
351
424
  // ---------------------------------------------------------------------------
@@ -386,30 +459,27 @@ const makeImpl = (config) => {
386
459
  /** Resolve a domain field name to its DynamoDB attribute name. */
387
460
  const resolveDbName = (domainName) => fieldRenames[domainName] ?? domainName;
388
461
  /**
389
- * Convert domain date values to storage primitives for DynamoEncoding-annotated fields.
390
- * Called before toAttributeMap (put path).
462
+ * Flatten sparse Map fields into per-entry top-level attributes.
463
+ * Called on the write path after rename + date serialization, before
464
+ * `toAttributeMap`. No-op when the entity has no sparse fields.
465
+ *
466
+ * Throws on key validation errors (caught by callers and translated into
467
+ * tagged `ValidationError` at the entity boundary).
391
468
  */
392
- const serializeDateFields = (item) => {
393
- if (!hasDateFields)
469
+ const serializeSparseFields = (item) => {
470
+ if (!hasSparseFields)
394
471
  return;
395
- for (const [field, encoding] of Object.entries(fieldEncodings)) {
396
- if (field in item && item[field] != null) {
397
- item[field] = serializeDateForDynamo(item[field], encoding);
398
- }
399
- }
472
+ encodeSparseFields(item, sparseFields);
400
473
  };
401
474
  /**
402
- * Convert storage primitives back to domain values for DynamoEncoding-annotated fields.
403
- * Called after fromAttributeMap (get path).
475
+ * Rebuild sparse Map fields from flattened top-level attributes.
476
+ * Called on the read path after `fromAttributeMap`, before rename + date
477
+ * deserialization. No-op when the entity has no sparse fields.
404
478
  */
405
- const deserializeDateFields = (raw) => {
406
- if (!hasDateFields)
479
+ const deserializeSparseFields = (raw) => {
480
+ if (!hasSparseFields)
407
481
  return;
408
- for (const [field, encoding] of Object.entries(fieldEncodings)) {
409
- if (field in raw && raw[field] != null) {
410
- raw[field] = deserializeDateFromDynamo(raw[field], encoding);
411
- }
412
- }
482
+ decodeSparseFields(raw, sparseFields);
413
483
  };
414
484
  // ---------------------------------------------------------------------------
415
485
  // Helpers
@@ -425,8 +495,8 @@ const makeImpl = (config) => {
425
495
  /** Attach the model class prototype to a decoded plain object (when model is Schema.Class). */
426
496
  const attachPrototype = (decoded) => isSchemaClass ? Object.assign(Object.create(rawModel.prototype), decoded) : decoded;
427
497
  const decodeRecord = (raw) => {
498
+ deserializeSparseFields(raw);
428
499
  renameFromDynamo(raw);
429
- deserializeDateFields(raw);
430
500
  return Schema.decodeUnknownEffect(schemas.recordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
431
501
  entityType,
432
502
  operation: "decode",
@@ -437,15 +507,15 @@ const makeImpl = (config) => {
437
507
  const decodeAs = (raw, marshalled, mode) => {
438
508
  if (mode === "native")
439
509
  return Effect.succeed(marshalled);
440
- // Rename DynamoDB attributes back to domain field names
510
+ // Collect flattened sparse-map attributes back into the domain Record.
511
+ // Must happen before rename so the rebuilt Record sits at the domain field
512
+ // name (sparse field renaming is not supported).
513
+ deserializeSparseFields(raw);
514
+ // Rename DynamoDB attributes back to domain field names. Substituted
515
+ // schemas handle wire→domain conversion (date primitives, Redacted, etc.).
441
516
  renameFromDynamo(raw);
442
- // Convert date storage primitives to domain values before Schema decode.
443
- // recordSchema/itemSchema use fromSelf variants for annotated fields,
444
- // so they accept domain values directly. modelSchema also uses fromSelf
445
- // for the decode path after deserialization.
446
- deserializeDateFields(raw);
447
517
  const targetSchema = mode === "model"
448
- ? schemas.modelDecodeSchema
518
+ ? schemas.modelSchema
449
519
  : mode === "record"
450
520
  ? schemas.recordSchema
451
521
  : schemas.itemSchema;
@@ -461,33 +531,26 @@ const makeImpl = (config) => {
461
531
  };
462
532
  const nowIso = () => new Date().toISOString();
463
533
  /**
464
- * Generate a timestamp value appropriate for the field's encoding.
534
+ * Generate a timestamp value as a wire primitive ready for marshalling.
465
535
  * Default (no encoding): ISO string. Custom encoding: serialized primitive.
466
536
  *
467
- * Used for non-colliding system fields (field not declared in model) — the
468
- * value is written straight into the DynamoDB item and is NOT routed through
469
- * `serializeDateFields` (because the field is absent from `fieldEncodings`).
537
+ * Used for system fields whether colliding or not — the value is always a
538
+ * wire primitive (string or number) ready for `toAttributeMap`.
470
539
  */
471
540
  const generateTimestamp = (encoding) => {
472
541
  if (!encoding)
473
542
  return nowIso();
474
- const now = new Date();
475
- return serializeDateForDynamo(now, encoding);
476
- };
477
- /**
478
- * Generate a timestamp as a domain value (DateTime.Utc / DateTime.Zoned / Date),
479
- * matching the field's declared domain. Used for system fields that COLLIDE
480
- * with a model-declared field — the value flows through `serializeDateFields`
481
- * (which requires a domain value) alongside any user-supplied override.
482
- */
483
- const generateDomainTimestamp = (encoding) => {
484
- switch (encoding.domain) {
485
- case "DateTime.Utc":
486
- return DateTime.makeUnsafe(new Date());
487
- case "DateTime.Zoned":
488
- return DateTime.makeZonedUnsafe(new Date(), { timeZone: "UTC" });
489
- case "Date":
490
- return new Date();
543
+ const now = Date.now();
544
+ switch (encoding.storage) {
545
+ case "string":
546
+ // For DateTime.Zoned this would need the extended ISO format, but
547
+ // system timestamps generated here are UTC — DateTime.Zoned-typed
548
+ // collision fields are rare and not previously special-cased either.
549
+ return new Date(now).toISOString();
550
+ case "epochMs":
551
+ return now;
552
+ case "epochSeconds":
553
+ return Math.floor(now / 1000);
491
554
  }
492
555
  };
493
556
  // ---------------------------------------------------------------------------
@@ -777,41 +840,41 @@ const makeImpl = (config) => {
777
840
  const put = (input) => new EntityPutImpl((mode, opts) => Effect.gen(function* () {
778
841
  const client = yield* DynamoClient;
779
842
  const { name: tableName } = yield* tableTag;
780
- // Decode input
781
- const decodedInput = yield* Schema.decodeUnknownEffect(schemas.inputDecodeSchema)(input).pipe(Effect.mapError((cause) => new ValidationError({
782
- entityType,
783
- operation: "put.decode",
784
- cause,
785
- })));
786
- // Compose all keys BEFORE ref hydration (ID fields are still present)
787
- const keys = composeAllKeys(decodedInput);
843
+ // Encode user input → wire form. Users typically construct domain
844
+ // values for transforms (DateTime, Redacted, Number) and plain
845
+ // objects for Schema.Class fields. To handle both:
846
+ // 1. Try `encode` directly — the canonical Type → Encoded
847
+ // conversion. Works when the input is fully Type-shaped
848
+ // (which is the case for the transforms that issue #29
849
+ // surfaced: RedactedFromValue, NumberFromString, etc.).
850
+ // 2. On failure, fall back to `decode → encode` — `decode` is
851
+ // forgiving for Schema.Class (lifts plain objects to
852
+ // instances) and the substituted date/Redacted transforms
853
+ // tolerate either form. The subsequent `encode` produces
854
+ // the canonical wire shape.
855
+ const encodedInput = yield* encodeOrDecodeEncode(schemas.inputSchema, input, entityType, "put");
856
+ // Compose all keys from the encoded input (ID fields still present
857
+ // and key composites are wire-format strings/numbers).
858
+ const keys = composeAllKeys(encodedInput);
788
859
  // Hydrate refs: replace ID fields with full entity domain data
789
- const decoded = hasRefs
790
- ? yield* hydrateRefs(decodedInput)
791
- : decodedInput;
792
- // Build the DynamoDB item
793
- const item = { ...decoded };
860
+ const encoded = hasRefs
861
+ ? yield* hydrateRefs(encodedInput)
862
+ : encodedInput;
863
+ // Build the DynamoDB item — already in wire-format after encode
864
+ const item = { ...encoded };
794
865
  // Add system fields. When a timestamp field collides with a
795
866
  // model-declared field, the user may have supplied their own value
796
- // (see `inputDecodeSchema` — optional on collision). Respect their
797
- // value if present; otherwise generate. In the collision path the
798
- // value is a DOMAIN value (DateTime.Utc / Date / ...) so it flows
799
- // through `serializeDateFields` downstream. Non-colliding fields
800
- // get a storage primitive directly (no `serializeDateFields` pass).
867
+ // (optional on collision in `inputSchema`). Respect their value if
868
+ // present (already encoded to wire); otherwise generate a wire
869
+ // primitive directly.
801
870
  if (systemFields.createdAt) {
802
871
  if (item[systemFields.createdAt] === undefined) {
803
- item[systemFields.createdAt] =
804
- systemFields.createdAtCollision && systemFields.createdAtEncoding
805
- ? generateDomainTimestamp(systemFields.createdAtEncoding)
806
- : generateTimestamp(systemFields.createdAtEncoding);
872
+ item[systemFields.createdAt] = generateTimestamp(systemFields.createdAtEncoding);
807
873
  }
808
874
  }
809
875
  if (systemFields.updatedAt) {
810
876
  if (item[systemFields.updatedAt] === undefined) {
811
- item[systemFields.updatedAt] =
812
- systemFields.updatedAtCollision && systemFields.updatedAtEncoding
813
- ? generateDomainTimestamp(systemFields.updatedAtEncoding)
814
- : generateTimestamp(systemFields.updatedAtEncoding);
877
+ item[systemFields.updatedAt] = generateTimestamp(systemFields.updatedAtEncoding);
815
878
  }
816
879
  }
817
880
  if (systemFields.version)
@@ -820,10 +883,20 @@ const makeImpl = (config) => {
820
883
  item.__edd_e__ = entityType;
821
884
  // Apply composed keys
822
885
  Object.assign(item, keys);
823
- // Convert date fields from domain values to storage primitives
824
- serializeDateFields(item);
825
886
  // Rename domain fields to DynamoDB attribute names
826
887
  renameToDynamo(item);
888
+ // Flatten sparse Map fields into per-entry top-level attributes.
889
+ // Wrapped to surface key-validation errors as ValidationError.
890
+ try {
891
+ serializeSparseFields(item);
892
+ }
893
+ catch (e) {
894
+ return yield* new ValidationError({
895
+ entityType,
896
+ operation: "put.sparse",
897
+ cause: e instanceof Error ? e.message : String(e),
898
+ });
899
+ }
827
900
  const marshalledItem = toAttributeMap(item);
828
901
  // Build user condition expression if provided
829
902
  const userCondition = opts.condition
@@ -851,7 +924,7 @@ const makeImpl = (config) => {
851
924
  const sentinelConstraints = [];
852
925
  if (hasUniqueConstraints) {
853
926
  for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
854
- const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, decoded);
927
+ const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, encoded);
855
928
  if (!sentinel)
856
929
  continue;
857
930
  sentinelConstraints.push(constraintName);
@@ -894,7 +967,7 @@ const makeImpl = (config) => {
894
967
  if (opts.condition && reasons[0]?.Code === "ConditionalCheckFailed") {
895
968
  return new ConditionalCheckFailed({
896
969
  entityType,
897
- key: decoded,
970
+ key: encoded,
898
971
  });
899
972
  }
900
973
  // Sentinel items start at index 1, in the order recorded in
@@ -908,7 +981,7 @@ const makeImpl = (config) => {
908
981
  : [];
909
982
  const fieldsRecord = {};
910
983
  for (const f of uniqueFields) {
911
- const v = decoded[f];
984
+ const v = encoded[f];
912
985
  if (v !== undefined && v !== null) {
913
986
  fieldsRecord[f] = KeyComposer.serializeValue(v);
914
987
  }
@@ -940,7 +1013,10 @@ const makeImpl = (config) => {
940
1013
  }
941
1014
  yield* client.putItem(putInput).pipe(Effect.mapError((err) => {
942
1015
  if (opts.condition && isAwsConditionalCheckFailed(err.cause)) {
943
- return new ConditionalCheckFailed({ entityType, key: decoded });
1016
+ return new ConditionalCheckFailed({
1017
+ entityType,
1018
+ key: encoded,
1019
+ });
944
1020
  }
945
1021
  return err;
946
1022
  }));
@@ -1025,17 +1101,13 @@ const makeImpl = (config) => {
1025
1101
  operation: "update.decodeKey",
1026
1102
  cause,
1027
1103
  })));
1028
- // Decode updates
1029
- const decodedUpdates = yield* Schema.decodeUnknownEffect(schemas.updateDecodeSchema)(updates ?? {}).pipe(Effect.mapError((cause) => new ValidationError({
1030
- entityType,
1031
- operation: "update.decodeUpdates",
1032
- cause,
1033
- })));
1104
+ // Encode update payload → wire form (see `put` for the strategy).
1105
+ const encodedUpdates = yield* encodeOrDecodeEncode(schemas.updateSchema, updates ?? {}, entityType, "update");
1034
1106
  // Hydrate refs in updates: for any ${field}Id present, fetch and embed
1035
- let hydratedUpdates = decodedUpdates;
1107
+ let hydratedUpdates = encodedUpdates;
1036
1108
  if (hasRefs) {
1037
- const refsToHydrate = resolvedRefs.filter((ref) => ref.idFieldName in decodedUpdates &&
1038
- decodedUpdates[ref.idFieldName] !==
1109
+ const refsToHydrate = resolvedRefs.filter((ref) => ref.idFieldName in encodedUpdates &&
1110
+ encodedUpdates[ref.idFieldName] !==
1039
1111
  undefined);
1040
1112
  if (refsToHydrate.length > 0) {
1041
1113
  hydratedUpdates = yield* hydrateRefs(hydratedUpdates, refsToHydrate);
@@ -1083,26 +1155,37 @@ const makeImpl = (config) => {
1083
1155
  });
1084
1156
  }
1085
1157
  // Build new item: merge current + updates + rich ops + recompose keys.
1086
- //
1087
- // Pre-serialize domain date values from `hydratedUpdates` (decoded
1088
- // via `fromSelf`, so DateTime.Utc / Date instances) into storage
1089
- // primitives BEFORE merging into `newItem`. `currentRaw` is already
1090
- // storage-primitive (it came straight from `fromAttributeMap`), so
1091
- // we need both halves in the same shape — otherwise the final
1092
- // `toAttributeMap(newItem)` marshals the DateTime class instances
1093
- // as DynamoDB Maps (via AWS SDK's `convertClassInstanceToMap`),
1094
- // which corrupts the write and makes subsequent reads fail to
1095
- // decode.
1096
- const serializedRetainUpdates = {
1097
- ...hydratedUpdates,
1098
- };
1099
- serializeDateFields(serializedRetainUpdates);
1100
- const newItem = {
1158
+ // `currentRaw` uses DynamoDB attribute names (e.g. `dn`, `hd`);
1159
+ // `hydratedUpdates` uses domain names. To avoid the merge leaving
1160
+ // both names (which would later have the rename clobber the user's
1161
+ // value), translate the current item's keys to domain names first
1162
+ // so the spread + assign overlay cleanly.
1163
+ const currentDomainItem = {
1101
1164
  ...currentRaw,
1102
1165
  };
1103
- for (const [attr, val] of Object.entries(serializedRetainUpdates)) {
1104
- if (val !== undefined)
1166
+ renameFromDynamo(currentDomainItem);
1167
+ // Rebuild sparse Map fields from flattened attrs into domain Records.
1168
+ // Done in-place on the renamed domain item so subsequent merge logic
1169
+ // operates on domain shape and re-flattening happens once at the end.
1170
+ deserializeSparseFields(currentDomainItem);
1171
+ const newItem = { ...currentDomainItem };
1172
+ // Apply user updates. For sparse fields, merge bucket-by-bucket
1173
+ // rather than whole-field replace — concurrent writers to disjoint
1174
+ // buckets must coexist (the version CAS protects against same-bucket
1175
+ // races). Non-sparse fields use the existing replace semantics.
1176
+ for (const [attr, val] of Object.entries(hydratedUpdates)) {
1177
+ if (val === undefined)
1178
+ continue;
1179
+ if (hasSparseFields &&
1180
+ attr in sparseFields &&
1181
+ val !== null &&
1182
+ typeof val === "object") {
1183
+ const existing = newItem[attr] ?? {};
1184
+ newItem[attr] = { ...existing, ...val };
1185
+ }
1186
+ else {
1105
1187
  newItem[attr] = val;
1188
+ }
1106
1189
  }
1107
1190
  // Apply rich operations to in-memory item
1108
1191
  if (uState.remove) {
@@ -1110,6 +1193,29 @@ const makeImpl = (config) => {
1110
1193
  delete newItem[attr];
1111
1194
  }
1112
1195
  }
1196
+ // Sparse-map removes: drop named entries from the in-memory Record.
1197
+ if (uState.sparseRemoveEntries) {
1198
+ for (const op of uState.sparseRemoveEntries) {
1199
+ if (!hasSparseFields || !(op.field in sparseFields))
1200
+ continue;
1201
+ const bucket = newItem[op.field];
1202
+ if (!bucket)
1203
+ continue;
1204
+ for (const k of op.keys)
1205
+ delete bucket[k];
1206
+ }
1207
+ }
1208
+ // Sparse-map clear: blow away the entire bucket Record. The retain
1209
+ // path always reads-then-writes the full item, so the version CAS
1210
+ // already gives clearMap atomic semantics for free — no per-bucket
1211
+ // GET dance needed here.
1212
+ if (uState.sparseClearFields) {
1213
+ for (const field of uState.sparseClearFields) {
1214
+ if (!hasSparseFields || !(field in sparseFields))
1215
+ continue;
1216
+ newItem[field] = {};
1217
+ }
1218
+ }
1113
1219
  if (uState.add) {
1114
1220
  for (const [attr, val] of Object.entries(uState.add)) {
1115
1221
  newItem[attr] = (newItem[attr] ?? 0) + val;
@@ -1138,24 +1244,21 @@ const makeImpl = (config) => {
1138
1244
  }
1139
1245
  // Increment version and update timestamp. If the caller supplied a
1140
1246
  // value for `updatedAt` (allowed when the field collides with a
1141
- // model-declared field), respect it; else generate. Read from the
1142
- // pre-serialized map so both branches produce a storage primitive
1143
- // (ISO string / epoch number) — not a domain DateTime instance.
1247
+ // model-declared field), respect it (already wire-form via encode);
1248
+ // else generate a fresh wire primitive.
1144
1249
  const newVersion = currentVersion + 1;
1145
1250
  if (systemFields.version)
1146
1251
  newItem[systemFields.version] = newVersion;
1147
1252
  if (systemFields.updatedAt) {
1148
- const userSupplied = serializedRetainUpdates[systemFields.updatedAt];
1253
+ const userSupplied = hydratedUpdates[systemFields.updatedAt];
1149
1254
  newItem[systemFields.updatedAt] =
1150
1255
  userSupplied !== undefined
1151
1256
  ? userSupplied
1152
1257
  : generateTimestamp(systemFields.updatedAtEncoding);
1153
1258
  }
1154
- // Convert DynamoDB attribute names to domain names for key composition
1155
- // and sentinel value comparison (newItem was built from currentRaw which
1156
- // uses DynamoDB names; composites and unique fields use domain names)
1157
- renameFromDynamo(newItem);
1158
- // Recompose all keys with updated attributes
1259
+ // `newItem` is already in domain names (the merge built it from
1260
+ // `currentDomainItem` + domain-keyed updates) — no rename needed
1261
+ // here. Recompose all keys with the updated attributes.
1159
1262
  const newKeys = composeAllKeys(newItem);
1160
1263
  Object.assign(newItem, newKeys);
1161
1264
  const sentinelRotations = [];
@@ -1182,6 +1285,17 @@ const makeImpl = (config) => {
1182
1285
  }
1183
1286
  // Convert back to DynamoDB attribute names for storage
1184
1287
  renameToDynamo(newItem);
1288
+ // Flatten sparse Map fields into per-entry top-level attributes.
1289
+ try {
1290
+ serializeSparseFields(newItem);
1291
+ }
1292
+ catch (e) {
1293
+ return yield* new ValidationError({
1294
+ entityType,
1295
+ operation: "update.sparse",
1296
+ cause: e instanceof Error ? e.message : String(e),
1297
+ });
1298
+ }
1185
1299
  const marshalledNewItem = toAttributeMap(newItem);
1186
1300
  // Build snapshot of the pre-update state (only when retain is enabled)
1187
1301
  const snapshotItem = isRetainEnabled()
@@ -1320,22 +1434,46 @@ const makeImpl = (config) => {
1320
1434
  const names = {};
1321
1435
  const values = {};
1322
1436
  let counter = 0;
1323
- // Serialize any domain-value date fields in updates to storage primitives.
1324
- // Without this, a `.set({ occurredAt: DateTime.Utc })` would marshal as a
1325
- // class-instance map.
1326
- const serializedUpdates = { ...hydratedUpdates };
1327
- serializeDateFields(serializedUpdates);
1437
+ // `hydratedUpdates` was produced by `Schema.encode(updateSchema)` and
1438
+ // is already in wire-format (ISO string / epoch number / etc.). Use
1439
+ // it directly — no `serializeDateFields` pass required.
1440
+ const encodedUpdatesMap = hydratedUpdates;
1328
1441
  // System-colliding updatedAt is handled by the system-field block below.
1329
- // createdAt and version are already excluded by `updateDecodeSchema`.
1442
+ // createdAt and version are already excluded by `updateSchema`.
1330
1443
  const updateSystemColliders = new Set();
1331
1444
  if (systemFields.updatedAtCollision && systemFields.updatedAt)
1332
1445
  updateSystemColliders.add(systemFields.updatedAt);
1333
1446
  // Add user-provided updates
1334
- for (const [attr, val] of Object.entries(serializedUpdates)) {
1447
+ for (const [attr, val] of Object.entries(encodedUpdatesMap)) {
1335
1448
  if (val === undefined)
1336
1449
  continue;
1337
1450
  if (updateSystemColliders.has(attr))
1338
1451
  continue;
1452
+ // Sparse fields: emit one SET per bucket. Whole-bucket replace
1453
+ // semantics — concurrent writers to disjoint buckets are safe.
1454
+ // `null` is NOT REMOVE — that's footgunny; explicit removal goes
1455
+ // through `.removeEntries(field, keys)`.
1456
+ if (hasSparseFields && attr in sparseFields) {
1457
+ if (val === null || typeof val !== "object")
1458
+ continue;
1459
+ const sparse = sparseFields[attr];
1460
+ for (const [k, v] of Object.entries(val)) {
1461
+ if (typeof k !== "string" || k.length === 0 || k.includes("#")) {
1462
+ return yield* new ValidationError({
1463
+ entityType,
1464
+ operation: "update.sparse",
1465
+ cause: `Sparse map "${attr}": invalid key ${JSON.stringify(k)}`,
1466
+ });
1467
+ }
1468
+ const nameKey = `#u${counter}`;
1469
+ const valKey = `:u${counter}`;
1470
+ names[nameKey] = `${sparse.prefix}#${k}`;
1471
+ values[valKey] = toAttributeValue(v);
1472
+ setClauses.push(`${nameKey} = ${valKey}`);
1473
+ counter++;
1474
+ }
1475
+ continue;
1476
+ }
1339
1477
  const nameKey = `#u${counter}`;
1340
1478
  const valKey = `:u${counter}`;
1341
1479
  names[nameKey] = resolveDbName(attr);
@@ -1343,10 +1481,11 @@ const makeImpl = (config) => {
1343
1481
  setClauses.push(`${nameKey} = ${valKey}`);
1344
1482
  counter++;
1345
1483
  }
1346
- // Add updatedAt timestamp. User-supplied value wins (pre-serialized
1347
- // above); else fall back to a freshly generated storage primitive.
1484
+ // Add updatedAt timestamp. User-supplied value wins (already in wire
1485
+ // form via encode); else fall back to a freshly generated wire
1486
+ // primitive.
1348
1487
  if (systemFields.updatedAt) {
1349
- const userSupplied = serializedUpdates[systemFields.updatedAt];
1488
+ const userSupplied = encodedUpdatesMap[systemFields.updatedAt];
1350
1489
  const nameKey = `#u${counter}`;
1351
1490
  const valKey = `:u${counter}`;
1352
1491
  names[nameKey] = systemFields.updatedAt;
@@ -1521,6 +1660,73 @@ const makeImpl = (config) => {
1521
1660
  deleteClauses.push(`${pathExpr} ${valKey}`);
1522
1661
  }
1523
1662
  }
1663
+ // Sparse-map .removeEntries — explicit REMOVE on `<prefix>#<key>`.
1664
+ // Each key emits one REMOVE clause through ExpressionAttributeNames
1665
+ // (the literal "#" survives via compilePath's raw-segment escape).
1666
+ if (uState.sparseRemoveEntries) {
1667
+ for (const op of uState.sparseRemoveEntries) {
1668
+ if (!hasSparseFields || !(op.field in sparseFields)) {
1669
+ return yield* new ValidationError({
1670
+ entityType,
1671
+ operation: "update.removeEntries",
1672
+ cause: `field "${op.field}" is not configured as a sparse map`,
1673
+ });
1674
+ }
1675
+ const sparse = sparseFields[op.field];
1676
+ for (const k of op.keys) {
1677
+ if (typeof k !== "string" || k.length === 0 || k.includes("#")) {
1678
+ return yield* new ValidationError({
1679
+ entityType,
1680
+ operation: "update.removeEntries",
1681
+ cause: `Sparse map "${op.field}": invalid key ${JSON.stringify(k)}`,
1682
+ });
1683
+ }
1684
+ const nameKey = `#sr${removeClauses.length}`;
1685
+ names[nameKey] = `${sparse.prefix}#${k}`;
1686
+ removeClauses.push(nameKey);
1687
+ }
1688
+ }
1689
+ }
1690
+ // Sparse-map .clearMap — Get-then-Update helper. Reads the current
1691
+ // item with a consistent read to discover which `<prefix>#*` attrs
1692
+ // exist, then folds the resulting REMOVEs into this same UpdateItem.
1693
+ // The version CAS (when configured) provides atomicity; non-versioned
1694
+ // entities are best-effort (a concurrent writer can add a new bucket
1695
+ // between the read and the update — that bucket survives).
1696
+ if (uState.sparseClearFields && uState.sparseClearFields.length > 0) {
1697
+ for (const field of uState.sparseClearFields) {
1698
+ if (!hasSparseFields || !(field in sparseFields)) {
1699
+ return yield* new ValidationError({
1700
+ entityType,
1701
+ operation: "update.clearMap",
1702
+ cause: `field "${field}" is not configured as a sparse map`,
1703
+ });
1704
+ }
1705
+ }
1706
+ // Read once for all clearMap fields combined.
1707
+ const clearGetResult = yield* client.getItem({
1708
+ TableName: tableName,
1709
+ Key: marshalledKey,
1710
+ ConsistentRead: true,
1711
+ });
1712
+ if (clearGetResult.Item) {
1713
+ const clearItemKeys = Object.keys(clearGetResult.Item);
1714
+ for (const field of uState.sparseClearFields) {
1715
+ const sparse = sparseFields[field];
1716
+ const prefixWithDelim = `${sparse.prefix}#`;
1717
+ for (const attrName of clearItemKeys) {
1718
+ if (attrName.startsWith(prefixWithDelim)) {
1719
+ const nameKey = `#sc${removeClauses.length}`;
1720
+ names[nameKey] = attrName;
1721
+ removeClauses.push(nameKey);
1722
+ }
1723
+ }
1724
+ }
1725
+ }
1726
+ // If the item doesn't exist, clearMap is a no-op — the subsequent
1727
+ // UpdateItem will create the row (with whatever other clauses the
1728
+ // builder accumulated) per DynamoDB UpdateItem semantics.
1729
+ }
1524
1730
  const hasAnyUpdate = setClauses.length > 0 ||
1525
1731
  removeClauses.length > 0 ||
1526
1732
  addClauses.length > 0 ||
@@ -1554,13 +1760,16 @@ const makeImpl = (config) => {
1554
1760
  Object.assign(values, uc.values);
1555
1761
  }
1556
1762
  const conditionExpression = condParts.length > 0 ? condParts.join(" AND ") : undefined;
1763
+ // DynamoDB rejects an empty `ExpressionAttributeValues` map. When
1764
+ // the UpdateExpression is REMOVE-only (e.g. clearMap with no other
1765
+ // combinators), `values` may be empty — omit the property entirely.
1557
1766
  const result = yield* client
1558
1767
  .updateItem({
1559
1768
  TableName: tableName,
1560
1769
  Key: marshalledKey,
1561
1770
  UpdateExpression: updateExpression,
1562
1771
  ExpressionAttributeNames: names,
1563
- ExpressionAttributeValues: values,
1772
+ ExpressionAttributeValues: Object.keys(values).length > 0 ? values : undefined,
1564
1773
  ConditionExpression: conditionExpression,
1565
1774
  ReturnValues: returnValuesMap[uState.returnValues ?? "allNew"],
1566
1775
  })
@@ -1774,17 +1983,13 @@ const makeImpl = (config) => {
1774
1983
  const upsert = (input) => new EntityPutImpl((mode, opts) => Effect.gen(function* () {
1775
1984
  const client = yield* DynamoClient;
1776
1985
  const { name: tableName } = yield* tableTag;
1777
- // Decode input
1778
- const decodedInput = yield* Schema.decodeUnknownEffect(schemas.inputDecodeSchema)(input).pipe(Effect.mapError((cause) => new ValidationError({
1779
- entityType,
1780
- operation: "upsert.decode",
1781
- cause,
1782
- })));
1986
+ // Encode user input → wire form (see `put` for strategy).
1987
+ const encodedInput = yield* encodeOrDecodeEncode(schemas.inputSchema, input, entityType, "upsert");
1783
1988
  // Hydrate refs: replace ID fields with full entity domain data
1784
- const decoded = hasRefs
1785
- ? yield* hydrateRefs(decodedInput)
1786
- : decodedInput;
1787
- const item = decoded;
1989
+ const encoded = hasRefs
1990
+ ? yield* hydrateRefs(encodedInput)
1991
+ : encodedInput;
1992
+ const item = encoded;
1788
1993
  // Compose primary key
1789
1994
  const primaryKey = composePrimaryKey(item);
1790
1995
  const marshalledKey = toAttributeMap(primaryKey);
@@ -1804,10 +2009,7 @@ const makeImpl = (config) => {
1804
2009
  systemColliders.add(systemFields.updatedAt);
1805
2010
  if (systemFields.versionCollision && systemFields.version)
1806
2011
  systemColliders.add(systemFields.version);
1807
- // Serialize date fields on the decoded item
1808
- if (hasDateFields) {
1809
- serializeDateFields(item);
1810
- }
2012
+ // `item` is already in wire-form via `Schema.encode(inputSchema)`.
1811
2013
  // All model fields (excluding PK composites, which are in the Key)
1812
2014
  for (const [attr, val] of Object.entries(item)) {
1813
2015
  if (pkComposites.has(attr))
@@ -1816,6 +2018,35 @@ const makeImpl = (config) => {
1816
2018
  continue;
1817
2019
  if (val === undefined)
1818
2020
  continue;
2021
+ // Sparse fields: expand into one SET per bucket. Each bucket
2022
+ // attribute is named `<prefix>#<key>` and goes through
2023
+ // ExpressionAttributeNames as a literal (the `#` survives).
2024
+ if (hasSparseFields && attr in sparseFields) {
2025
+ if (val === null || typeof val !== "object")
2026
+ continue;
2027
+ const sparse = sparseFields[attr];
2028
+ for (const [k, v] of Object.entries(val)) {
2029
+ try {
2030
+ if (typeof k !== "string" || k.length === 0 || k.includes("#")) {
2031
+ throw new Error(`Sparse map "${attr}": invalid key ${JSON.stringify(k)}`);
2032
+ }
2033
+ }
2034
+ catch (e) {
2035
+ return yield* new ValidationError({
2036
+ entityType,
2037
+ operation: "upsert.sparse",
2038
+ cause: e instanceof Error ? e.message : String(e),
2039
+ });
2040
+ }
2041
+ const nameKey = `#u${counter}`;
2042
+ const valKey = `:u${counter}`;
2043
+ names[nameKey] = `${sparse.prefix}#${k}`;
2044
+ values[valKey] = toAttributeValue(v);
2045
+ setClauses.push(`${nameKey} = ${valKey}`);
2046
+ counter++;
2047
+ }
2048
+ continue;
2049
+ }
1819
2050
  const nameKey = `#u${counter}`;
1820
2051
  const valKey = `:u${counter}`;
1821
2052
  names[nameKey] = resolveDbName(attr);
@@ -1919,6 +2150,47 @@ const makeImpl = (config) => {
1919
2150
  const raw = fromAttributeMap(result.Attributes);
1920
2151
  return yield* decodeAs(raw, result.Attributes, mode);
1921
2152
  }), self, input);
2153
+ /**
2154
+ * @internal Compare stored vs attempted `orderBy` to decide whether the CAS
2155
+ * predicate `attribute_not_exists(pk) OR stored < attempted` failed.
2156
+ *
2157
+ * Returns `true` (CAS failed → stale) when `stored >= attempted`.
2158
+ * Returns `false` (CAS held; the user's `.condition()` is the rejecter)
2159
+ * when `stored < attempted` or when `stored` is missing.
2160
+ *
2161
+ * `stored` arrives as a decoded domain value matching the configured schema
2162
+ * for the attribute: a `DateTime.Utc` for `Schema.DateTimeUtc`, a `Date` for
2163
+ * `Schema.Date`, a `number`/`bigint` for numeric schemas, a `string` for
2164
+ * lexicographic comparisons.
2165
+ */
2166
+ const isCasFailure = (stored, attempted) => {
2167
+ if (stored == null)
2168
+ return false;
2169
+ // DateTime.Utc — compare epoch ms.
2170
+ if (DateTime.isDateTime(stored) && DateTime.isDateTime(attempted)) {
2171
+ return DateTime.toEpochMillis(stored) >= DateTime.toEpochMillis(attempted);
2172
+ }
2173
+ // Date — compare epoch ms.
2174
+ if (stored instanceof Date && attempted instanceof Date) {
2175
+ return stored.getTime() >= attempted.getTime();
2176
+ }
2177
+ // Numeric — number or bigint (cast comparable via JS coercion).
2178
+ if (typeof stored === "number" && typeof attempted === "number") {
2179
+ return stored >= attempted;
2180
+ }
2181
+ if (typeof stored === "bigint" && typeof attempted === "bigint") {
2182
+ return stored >= attempted;
2183
+ }
2184
+ // Strings — lexicographic.
2185
+ if (typeof stored === "string" && typeof attempted === "string") {
2186
+ return stored >= attempted;
2187
+ }
2188
+ // Mixed / unknown shapes — fall back to string compare on String(...).
2189
+ // Defensive: return true so we report StaleAppend (the safer default —
2190
+ // never suppress a CAS rejection by mis-classifying it as a user-condition
2191
+ // rejection).
2192
+ return String(stored) >= String(attempted);
2193
+ };
1922
2194
  // ---------------------------------------------------------------------------
1923
2195
  // append operation — time-series primitive (only when `timeSeries` configured)
1924
2196
  //
@@ -1929,13 +2201,25 @@ const makeImpl = (config) => {
1929
2201
  // - Put of event: full decoded input + __edd_e__ + _ttl (if configured),
1930
2202
  // GSI keys stripped, SK replaced with `<currentSk>#e#<orderByValue>`.
1931
2203
  //
1932
- // On TransactionCancelled or ConditionalCheckFailed, we issue a follow-up
1933
- // GetItem on the primary key and return `{ applied: false, reason: "stale",
1934
- // current }`. On success, same follow-up GetItem yields the post-append
1935
- // current. See `docs/designs/timeseries.md` §4.
2204
+ // Concurrency outcome (stale-as-error contract — supersedes
2205
+ // `docs/designs/timeseries.md` §4.7 v1 stale-as-value):
2206
+ //
2207
+ // - Success path (default): on transaction success, issue a follow-up
2208
+ // GetItem and return `{ current }` (decoded model).
2209
+ // - Stale path (default): on TransactionCancelled, issue a follow-up
2210
+ // GetItem to disambiguate. If the stored `orderBy >= attempted`, the CAS
2211
+ // fired → fail with `StaleAppend(current: Option.some)`. Otherwise the
2212
+ // user-supplied `.condition()` rejected the write → fail with
2213
+ // `ConditionalCheckFailed(current: Option.some)`.
2214
+ // - skipFollowUp success path: return `void`.
2215
+ // - skipFollowUp stale path: cannot disambiguate (no GetItem), so fail
2216
+ // with `StaleAppend(current: Option.none)` for both cancellation modes.
2217
+ // Documented as a deliberate trade-off in `guides/timeseries.mdx`.
2218
+ // - TTL-race / row vanished after success: surface as
2219
+ // `ValidationError("append.followUp")`. Undetected on skipFollowUp path.
1936
2220
  // ---------------------------------------------------------------------------
1937
2221
  const timeSeriesConfig = config.timeSeries;
1938
- const append = (input, userCondition) => Effect.gen(function* () {
2222
+ const append = (input, userCondition, skipFollowUp = false) => Effect.gen(function* () {
1939
2223
  if (!timeSeriesConfig) {
1940
2224
  return yield* new ValidationError({
1941
2225
  entityType,
@@ -1948,26 +2232,19 @@ const makeImpl = (config) => {
1948
2232
  const orderByField = timeSeriesConfig.orderBy;
1949
2233
  const ttlDuration = timeSeriesConfig.ttl;
1950
2234
  const appendInputSchema = schemas.appendInputSchema;
1951
- // Decode input via appendInputSchema
1952
- const decodedInput = yield* Schema.decodeUnknownEffect(appendInputSchema)(input).pipe(Effect.mapError((cause) => new ValidationError({
1953
- entityType,
1954
- operation: "append.decode",
1955
- cause,
1956
- })));
1957
- const decoded = decodedInput;
1958
- // Compose current-item primary key (pk + sk derived from PK/SK composites in decoded)
2235
+ // Encode user input via appendInputSchema (see `put` for strategy).
2236
+ const encodedInput = yield* encodeOrDecodeEncode(appendInputSchema, input, entityType, "append");
2237
+ const encoded = encodedInput;
2238
+ // Compose current-item primary key (pk + sk derived from PK/SK composites)
1959
2239
  const primary = config.indexes.primary;
1960
- const pkValue = KeyComposer.composePk(schema, entityType, primary, decoded);
1961
- const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary, decoded);
2240
+ const pkValue = KeyComposer.composePk(schema, entityType, primary, encoded);
2241
+ const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary, encoded);
1962
2242
  const marshalledKey = toAttributeMap({
1963
2243
  [primary.pk.field]: pkValue,
1964
2244
  [primary.sk.field]: currentSk,
1965
2245
  });
1966
- // Prepare domain-serialised values for the item build.
1967
- // Build a shallow copy and apply date serialization (per fieldEncodings)
1968
- // before marshalling. This mirrors the put path.
1969
- const serialisedInput = { ...decoded };
1970
- serializeDateFields(serialisedInput);
2246
+ // `encoded` is already in wire-form via Schema.encode — used directly.
2247
+ const serialisedInput = { ...encoded };
1971
2248
  // ---------- Build UpdateItem (scoped SET + CAS) ----------
1972
2249
  const setClauses = [];
1973
2250
  const names = {};
@@ -1996,7 +2273,7 @@ const makeImpl = (config) => {
1996
2273
  // never change during an append. Non-PK composites in appendInput that
1997
2274
  // overlap a GSI's composites trigger per-GSI resolution via indexPolicy.
1998
2275
  const nonPkAppendFields = {};
1999
- for (const [attr, val] of Object.entries(decoded)) {
2276
+ for (const [attr, val] of Object.entries(encoded)) {
2000
2277
  if (!pkCompositeSet.has(attr)) {
2001
2278
  nonPkAppendFields[attr] = val;
2002
2279
  }
@@ -2031,7 +2308,7 @@ const makeImpl = (config) => {
2031
2308
  },
2032
2309
  };
2033
2310
  }
2034
- const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, policyAwareIndexes, nonPkAppendFields, decoded);
2311
+ const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, policyAwareIndexes, nonPkAppendFields, encoded);
2035
2312
  for (const [field, value] of Object.entries(gsiUpdate.sets)) {
2036
2313
  const nameKey = `#a${counter}`;
2037
2314
  const valKey = `:a${counter}`;
@@ -2091,9 +2368,9 @@ const makeImpl = (config) => {
2091
2368
  const updateExpression = exprParts.join(" ");
2092
2369
  // ---------- Build Put of event item ----------
2093
2370
  const eventSk = KeyComposer.composeEventSk(currentSk, newObValue, schema.casing);
2094
- const eventItem = { ...decoded };
2095
- // Serialise date fields to storage format for the Put
2096
- serializeDateFields(eventItem);
2371
+ // `encoded` is already in wire-form via Schema.encode; no extra
2372
+ // serialisation step is required for the Put.
2373
+ const eventItem = { ...encoded };
2097
2374
  // Rename domain → DB names for the Put (matches put path)
2098
2375
  renameToDynamo(eventItem);
2099
2376
  // pk + sk (event)
@@ -2111,6 +2388,22 @@ const makeImpl = (config) => {
2111
2388
  for (const field of gsiKeyFields()) {
2112
2389
  delete eventItem[field];
2113
2390
  }
2391
+ // Sparse-map fields are aggregate state, not event state. They live on
2392
+ // the current item only — strip from event items entirely. (Same
2393
+ // treatment as enrichment fields outside `appendInput`. By design,
2394
+ // `appendInput` cannot include sparse fields, so this is defensive.)
2395
+ if (hasSparseFields) {
2396
+ for (const fieldName of Object.keys(sparseFields)) {
2397
+ delete eventItem[fieldName];
2398
+ // Also strip any flattened bucket attrs that might have leaked in
2399
+ // via a future code change — defensive cleanup.
2400
+ const prefixWithDelim = `${sparseFields[fieldName].prefix}#`;
2401
+ for (const k of Object.keys(eventItem)) {
2402
+ if (k.startsWith(prefixWithDelim))
2403
+ delete eventItem[k];
2404
+ }
2405
+ }
2406
+ }
2114
2407
  const marshalledEventItem = toAttributeMap(eventItem);
2115
2408
  // ---------- Execute TransactWriteItems ----------
2116
2409
  const transactResult = yield* client
@@ -2138,40 +2431,83 @@ const makeImpl = (config) => {
2138
2431
  onSuccess: () => Effect.succeed({ stale: false }),
2139
2432
  onFailure: (err) => {
2140
2433
  // TransactionCancelled or ConditionalCheckFailed at the transaction
2141
- // level both map to "stale" (can't distinguish CAS from user-cond
2142
- // in v1 — see §12 decision 5).
2434
+ // level both surface here. Disambiguation between CAS and
2435
+ // user-condition happens after the follow-up GetItem (when run).
2143
2436
  if (isAwsTransactionCancelled(err.cause) || isAwsConditionalCheckFailed(err.cause)) {
2144
2437
  return Effect.succeed({ stale: true });
2145
2438
  }
2146
2439
  return Effect.fail(err);
2147
2440
  },
2148
2441
  }));
2149
- // Follow-up GetItem to read the current (post-append on success, winning
2150
- // state on stale). One read is required either way — the contract
2151
- // returns `Model` which demands the full row including enrichment.
2442
+ // -----------------------------------------------------------------
2443
+ // skipFollowUp branch — no GetItem, cannot decode current, cannot
2444
+ // disambiguate CAS vs user-condition cancellation. Both modes
2445
+ // collapse to StaleAppend(current: Option.none) — documented.
2446
+ // -----------------------------------------------------------------
2447
+ if (skipFollowUp) {
2448
+ if (transactResult.stale) {
2449
+ return yield* new StaleAppend({
2450
+ entityType,
2451
+ orderByField,
2452
+ attemptedOrderBy: newObValue,
2453
+ current: Option.none(),
2454
+ });
2455
+ }
2456
+ return undefined;
2457
+ }
2458
+ // -----------------------------------------------------------------
2459
+ // Default branch — one follow-up GetItem either way. Carries:
2460
+ // - Decoded model on success (the post-append current).
2461
+ // - Disambiguation data on cancellation (stored orderBy vs attempted)
2462
+ // plus a model snapshot to attach to the error for the caller.
2463
+ // -----------------------------------------------------------------
2152
2464
  const followUp = yield* client.getItem({
2153
2465
  TableName: tableName,
2154
2466
  Key: marshalledKey,
2155
2467
  });
2156
2468
  if (!followUp.Item) {
2157
- // TTL race or out-of-band delete — surface as a DynamoClientError
2158
- // (the previous state vanished under us). This is NOT a stale result.
2159
- return yield* Effect.fail(new ValidationError({
2469
+ // TTL race or out-of-band delete — the previous state vanished
2470
+ // under us. Distinct from a CAS-stale outcome.
2471
+ return yield* new ValidationError({
2160
2472
  entityType,
2161
2473
  operation: "append.followUp",
2162
2474
  cause: "Current item not found after append — possible TTL race or concurrent delete.",
2163
- }));
2475
+ });
2164
2476
  }
2165
2477
  const rawCurrent = fromAttributeMap(followUp.Item);
2166
2478
  const currentModel = yield* decodeAs(rawCurrent, followUp.Item, "model");
2167
2479
  if (transactResult.stale) {
2168
- return {
2169
- applied: false,
2170
- reason: "stale",
2171
- current: currentModel,
2172
- };
2480
+ // Disambiguate CAS-stale from user-condition rejection. We compare
2481
+ // in the WIRE form (what DynamoDB actually evaluated): `newObValue`
2482
+ // is the encoded value passed to the CAS, and `rawCurrent` carries
2483
+ // the encoded values read back. `decodeAs` mutated `rawCurrent` to
2484
+ // rename DB columns to domain field names, so `rawCurrent[orderByField]`
2485
+ // is the wire-form value under its domain name. Wire-form
2486
+ // comparison matches DynamoDB's evaluation: ISO-8601 strings sort
2487
+ // lexicographically (= chronologically), numbers/bigints are
2488
+ // numeric, strings are lexicographic.
2489
+ const storedOrderBy = rawCurrent[orderByField];
2490
+ const attempted = newObValue;
2491
+ const casFired = isCasFailure(storedOrderBy, attempted);
2492
+ if (!casFired && userCondition !== undefined) {
2493
+ // CAS held; the user's `.condition()` is the only thing that
2494
+ // could have rejected.
2495
+ return yield* new ConditionalCheckFailed({
2496
+ entityType,
2497
+ key: encoded,
2498
+ current: Option.some(currentModel),
2499
+ });
2500
+ }
2501
+ // Either CAS fired, or no user condition was supplied (CAS is the
2502
+ // only possibility) — both map to StaleAppend.
2503
+ return yield* new StaleAppend({
2504
+ entityType,
2505
+ orderByField,
2506
+ attemptedOrderBy: attempted,
2507
+ current: Option.some(currentModel),
2508
+ });
2173
2509
  }
2174
- return { applied: true, current: currentModel };
2510
+ return { current: currentModel };
2175
2511
  });
2176
2512
  // ---------------------------------------------------------------------------
2177
2513
  // history operation — BoundQuery for event items only
@@ -2186,8 +2522,12 @@ const makeImpl = (config) => {
2186
2522
  const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary, decodedKey);
2187
2523
  const prefix = KeyComposer.composeEventSkPrefix(currentSk, schema.casing);
2188
2524
  const decodeHistory = (raw) => {
2525
+ // Sparse fields are aggregate state, not event state — event items DO
2526
+ // NOT carry `<prefix>#*` attributes. `deserializeSparseFields` populates
2527
+ // empty `{}` Records under the domain field names so the historyRecord
2528
+ // schema (which still includes the sparse Record fields) decodes.
2529
+ deserializeSparseFields(raw);
2189
2530
  renameFromDynamo(raw);
2190
- deserializeDateFields(raw);
2191
2531
  return Schema.decodeUnknownEffect(schemas.historyRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
2192
2532
  entityType,
2193
2533
  operation: "history.decode",
@@ -2331,6 +2671,11 @@ const makeImpl = (config) => {
2331
2671
  // (itemSchema would fail because it expects GSI key fields that aren't present)
2332
2672
  if (mode === "native")
2333
2673
  return result.Items[0];
2674
+ // Sparse Map fields: rebuild domain Records from flattened attrs so
2675
+ // soft-deleted items decode correctly. Sparse data is preserved
2676
+ // verbatim across soft-delete (GSI keys are stripped, sparse data is
2677
+ // not).
2678
+ deserializeSparseFields(raw);
2334
2679
  const targetSchema = schemas.deletedRecordSchema;
2335
2680
  return yield* Schema.decodeUnknownEffect(targetSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
2336
2681
  entityType,
@@ -2343,11 +2688,16 @@ const makeImpl = (config) => {
2343
2688
  const primary = config.indexes.primary;
2344
2689
  const pkValue = KeyComposer.composePk(schema, entityType, primary, decodedKey);
2345
2690
  const deletedPrefix = DynamoSchema.composeDeletedKeyPrefix(schema, entityType);
2346
- const decodeDeleted = (raw) => Schema.decodeUnknownEffect(schemas.deletedRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
2347
- entityType,
2348
- operation: "deleted.list.decode",
2349
- cause,
2350
- })));
2691
+ const decodeDeleted = (raw) => {
2692
+ // Sparse fields are domain data and are preserved across soft-delete.
2693
+ // Rebuild Records from flattened attrs before schema decode.
2694
+ deserializeSparseFields(raw);
2695
+ return Schema.decodeUnknownEffect(schemas.deletedRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
2696
+ entityType,
2697
+ operation: "deleted.list.decode",
2698
+ cause,
2699
+ })));
2700
+ };
2351
2701
  return Query.make({
2352
2702
  tableName: "",
2353
2703
  indexName: undefined,
@@ -2602,7 +2952,9 @@ const makeImpl = (config) => {
2602
2952
  // ---------------------------------------------------------------------------
2603
2953
  // Expression combinators: condition, filter, select
2604
2954
  // ---------------------------------------------------------------------------
2605
- const entityPathBuilder = createPathBuilder([], resolveDbName);
2955
+ // Sparse-field map used by createPathBuilder to wire `.entry(key)` accessors.
2956
+ const sparseFieldsForPath = hasSparseFields ? sparseFields : undefined;
2957
+ const entityPathBuilder = createPathBuilder([], resolveDbName, sparseFieldsForPath);
2606
2958
  const entityConditionOps = createConditionOps();
2607
2959
  /**
2608
2960
  * Build a condition expression combinator from callback or shorthand.
@@ -2658,9 +3010,9 @@ const makeImpl = (config) => {
2658
3010
  identifier: resolvedIdentifier,
2659
3011
  timeSeries: config.timeSeries,
2660
3012
  _resolvedRefs: resolvedRefs,
2661
- /** @internal Full decode pipeline: rename + date deser + schema decode. Used by Batch/Aggregate. */
3013
+ /** @internal Full decode pipeline: rename + schema decode. Used by Batch/Aggregate. */
2662
3014
  _decodeRecord: decodeRecord,
2663
- _serializeDateFields: serializeDateFields,
3015
+ _serializeSparseFields: serializeSparseFields,
2664
3016
  _attachPrototype: attachPrototype,
2665
3017
  _configure: (injectedSchema, injectedTableTag) => {
2666
3018
  schema = injectedSchema;
@@ -2728,10 +3080,15 @@ const makeImpl = (config) => {
2728
3080
  export const bind = (entity) => Effect.gen(function* () {
2729
3081
  const ctx = yield* Effect.context();
2730
3082
  const provide = (effect) => Effect.provide(effect, ctx);
3083
+ // Sparse-field map (if any) — extracted from the Entity's configured model
3084
+ // so the PathBuilder used by `.condition((t, ops) => ...)` exposes
3085
+ // `.entry(key)` accessors on sparse-map fields.
3086
+ const boundSparseFields = getSparseFields(entity.model);
3087
+ const boundSparseFieldsForPath = Object.keys(boundSparseFields).length > 0 ? boundSparseFields : undefined;
2731
3088
  // Shared config for the bound-CRUD builders — pre-resolved services plus
2732
3089
  // a typed PathBuilder/ConditionOps so `.condition((t, ops) => ...)` works.
2733
3090
  const boundCrudConfig = {
2734
- pathBuilder: createPathBuilder(),
3091
+ pathBuilder: createPathBuilder(undefined, undefined, boundSparseFieldsForPath),
2735
3092
  conditionOps: createConditionOps(),
2736
3093
  provide,
2737
3094
  };
@@ -2775,7 +3132,13 @@ export const bind = (entity) => Effect.gen(function* () {
2775
3132
  },
2776
3133
  // Time-series (no-ops when entity is not configured; runtime check in
2777
3134
  // the entity-level `append` returns a ValidationError).
2778
- append: (input, condition) => provide(entity.append(input, condition)),
3135
+ append: (input) => {
3136
+ const appendCfg = {
3137
+ ...boundCrudConfig,
3138
+ run: (opts) => provide(entity.append(opts.input, opts.condition, opts.skipFollowUp)),
3139
+ };
3140
+ return makeBoundAppend(input, appendCfg);
3141
+ },
2779
3142
  history: (key) => {
2780
3143
  const q = entity.history(key);
2781
3144
  const pathBuilder = createPathBuilder();