effect-dynamodb 1.4.0 → 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 (42) hide show
  1. package/dist/DynamoModel.d.ts +50 -2
  2. package/dist/DynamoModel.d.ts.map +1 -1
  3. package/dist/DynamoModel.js +73 -1
  4. package/dist/DynamoModel.js.map +1 -1
  5. package/dist/Entity.d.ts +47 -18
  6. package/dist/Entity.d.ts.map +1 -1
  7. package/dist/Entity.js +451 -36
  8. package/dist/Entity.js.map +1 -1
  9. package/dist/Errors.d.ts +48 -1
  10. package/dist/Errors.d.ts.map +1 -1
  11. package/dist/Errors.js +38 -1
  12. package/dist/Errors.js.map +1 -1
  13. package/dist/Marshaller.d.ts +28 -0
  14. package/dist/Marshaller.d.ts.map +1 -1
  15. package/dist/Marshaller.js +116 -0
  16. package/dist/Marshaller.js.map +1 -1
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/internal/BoundCrud.d.ts +68 -0
  22. package/dist/internal/BoundCrud.d.ts.map +1 -1
  23. package/dist/internal/BoundCrud.js +42 -1
  24. package/dist/internal/BoundCrud.js.map +1 -1
  25. package/dist/internal/EntityCombinators.d.ts +28 -0
  26. package/dist/internal/EntityCombinators.d.ts.map +1 -1
  27. package/dist/internal/EntityCombinators.js +40 -0
  28. package/dist/internal/EntityCombinators.js.map +1 -1
  29. package/dist/internal/EntityOps.d.ts +18 -0
  30. package/dist/internal/EntityOps.d.ts.map +1 -1
  31. package/dist/internal/EntityOps.js +2 -0
  32. package/dist/internal/EntityOps.js.map +1 -1
  33. package/dist/internal/EntityTypes.d.ts +7 -12
  34. package/dist/internal/EntityTypes.d.ts.map +1 -1
  35. package/dist/internal/PathBuilder.d.ts +29 -4
  36. package/dist/internal/PathBuilder.d.ts.map +1 -1
  37. package/dist/internal/PathBuilder.js +47 -6
  38. package/dist/internal/PathBuilder.js.map +1 -1
  39. package/dist/internal/TransactableOps.d.ts.map +1 -1
  40. package/dist/internal/TransactableOps.js +12 -0
  41. package/dist/internal/TransactableOps.js.map +1 -1
  42. package/package.json +1 -1
package/dist/Entity.js CHANGED
@@ -6,24 +6,24 @@
6
6
  * carries derived schemas for 7 type extractors (Model, Record, Input, Update,
7
7
  * Key, Item, Marshalled).
8
8
  */
9
- import { 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 { 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 { fromAttributeMap, 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
28
  import { allCompositeAttributes, allKeyFieldNames, buildDerivedSchemas, buildFieldEncodings, getFields, primaryKeyComposites, resolveSystemFields, resolveUniqueFields, validateNoTransformOverride, } from "./internal/EntitySchemas.js";
29
29
  // ---------------------------------------------------------------------------
@@ -362,6 +362,64 @@ const makeImpl = (config) => {
362
362
  // encodings are NOT in `fieldEncodings`; `generateTimestamp` produces wire
363
363
  // primitives directly for non-colliding system fields.
364
364
  // ---------------------------------------------------------------------------
365
+ // Sparse Map fields (storedAs: 'sparse') — validation + resolution
366
+ // ---------------------------------------------------------------------------
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
+ }
386
+ }
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
+ }
395
+ }
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
+ }
420
+ }
421
+ }
422
+ // ---------------------------------------------------------------------------
365
423
  // Field renaming: domain name → DynamoDB attribute name (from ConfiguredModel)
366
424
  // ---------------------------------------------------------------------------
367
425
  const fieldRenames = {}; // domain → dynamo
@@ -400,6 +458,29 @@ const makeImpl = (config) => {
400
458
  };
401
459
  /** Resolve a domain field name to its DynamoDB attribute name. */
402
460
  const resolveDbName = (domainName) => fieldRenames[domainName] ?? domainName;
461
+ /**
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).
468
+ */
469
+ const serializeSparseFields = (item) => {
470
+ if (!hasSparseFields)
471
+ return;
472
+ encodeSparseFields(item, sparseFields);
473
+ };
474
+ /**
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.
478
+ */
479
+ const deserializeSparseFields = (raw) => {
480
+ if (!hasSparseFields)
481
+ return;
482
+ decodeSparseFields(raw, sparseFields);
483
+ };
403
484
  // ---------------------------------------------------------------------------
404
485
  // Helpers
405
486
  // ---------------------------------------------------------------------------
@@ -414,6 +495,7 @@ const makeImpl = (config) => {
414
495
  /** Attach the model class prototype to a decoded plain object (when model is Schema.Class). */
415
496
  const attachPrototype = (decoded) => isSchemaClass ? Object.assign(Object.create(rawModel.prototype), decoded) : decoded;
416
497
  const decodeRecord = (raw) => {
498
+ deserializeSparseFields(raw);
417
499
  renameFromDynamo(raw);
418
500
  return Schema.decodeUnknownEffect(schemas.recordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
419
501
  entityType,
@@ -425,6 +507,10 @@ const makeImpl = (config) => {
425
507
  const decodeAs = (raw, marshalled, mode) => {
426
508
  if (mode === "native")
427
509
  return Effect.succeed(marshalled);
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);
428
514
  // Rename DynamoDB attributes back to domain field names. Substituted
429
515
  // schemas handle wire→domain conversion (date primitives, Redacted, etc.).
430
516
  renameFromDynamo(raw);
@@ -799,6 +885,18 @@ const makeImpl = (config) => {
799
885
  Object.assign(item, keys);
800
886
  // Rename domain fields to DynamoDB attribute names
801
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
+ }
802
900
  const marshalledItem = toAttributeMap(item);
803
901
  // Build user condition expression if provided
804
902
  const userCondition = opts.condition
@@ -1066,10 +1164,28 @@ const makeImpl = (config) => {
1066
1164
  ...currentRaw,
1067
1165
  };
1068
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);
1069
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.
1070
1176
  for (const [attr, val] of Object.entries(hydratedUpdates)) {
1071
- if (val !== undefined)
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 {
1072
1187
  newItem[attr] = val;
1188
+ }
1073
1189
  }
1074
1190
  // Apply rich operations to in-memory item
1075
1191
  if (uState.remove) {
@@ -1077,6 +1193,29 @@ const makeImpl = (config) => {
1077
1193
  delete newItem[attr];
1078
1194
  }
1079
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
+ }
1080
1219
  if (uState.add) {
1081
1220
  for (const [attr, val] of Object.entries(uState.add)) {
1082
1221
  newItem[attr] = (newItem[attr] ?? 0) + val;
@@ -1146,6 +1285,17 @@ const makeImpl = (config) => {
1146
1285
  }
1147
1286
  // Convert back to DynamoDB attribute names for storage
1148
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
+ }
1149
1299
  const marshalledNewItem = toAttributeMap(newItem);
1150
1300
  // Build snapshot of the pre-update state (only when retain is enabled)
1151
1301
  const snapshotItem = isRetainEnabled()
@@ -1299,6 +1449,31 @@ const makeImpl = (config) => {
1299
1449
  continue;
1300
1450
  if (updateSystemColliders.has(attr))
1301
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
+ }
1302
1477
  const nameKey = `#u${counter}`;
1303
1478
  const valKey = `:u${counter}`;
1304
1479
  names[nameKey] = resolveDbName(attr);
@@ -1485,6 +1660,73 @@ const makeImpl = (config) => {
1485
1660
  deleteClauses.push(`${pathExpr} ${valKey}`);
1486
1661
  }
1487
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
+ }
1488
1730
  const hasAnyUpdate = setClauses.length > 0 ||
1489
1731
  removeClauses.length > 0 ||
1490
1732
  addClauses.length > 0 ||
@@ -1518,13 +1760,16 @@ const makeImpl = (config) => {
1518
1760
  Object.assign(values, uc.values);
1519
1761
  }
1520
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.
1521
1766
  const result = yield* client
1522
1767
  .updateItem({
1523
1768
  TableName: tableName,
1524
1769
  Key: marshalledKey,
1525
1770
  UpdateExpression: updateExpression,
1526
1771
  ExpressionAttributeNames: names,
1527
- ExpressionAttributeValues: values,
1772
+ ExpressionAttributeValues: Object.keys(values).length > 0 ? values : undefined,
1528
1773
  ConditionExpression: conditionExpression,
1529
1774
  ReturnValues: returnValuesMap[uState.returnValues ?? "allNew"],
1530
1775
  })
@@ -1773,6 +2018,35 @@ const makeImpl = (config) => {
1773
2018
  continue;
1774
2019
  if (val === undefined)
1775
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
+ }
1776
2050
  const nameKey = `#u${counter}`;
1777
2051
  const valKey = `:u${counter}`;
1778
2052
  names[nameKey] = resolveDbName(attr);
@@ -1876,6 +2150,47 @@ const makeImpl = (config) => {
1876
2150
  const raw = fromAttributeMap(result.Attributes);
1877
2151
  return yield* decodeAs(raw, result.Attributes, mode);
1878
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
+ };
1879
2194
  // ---------------------------------------------------------------------------
1880
2195
  // append operation — time-series primitive (only when `timeSeries` configured)
1881
2196
  //
@@ -1886,13 +2201,25 @@ const makeImpl = (config) => {
1886
2201
  // - Put of event: full decoded input + __edd_e__ + _ttl (if configured),
1887
2202
  // GSI keys stripped, SK replaced with `<currentSk>#e#<orderByValue>`.
1888
2203
  //
1889
- // On TransactionCancelled or ConditionalCheckFailed, we issue a follow-up
1890
- // GetItem on the primary key and return `{ applied: false, reason: "stale",
1891
- // current }`. On success, same follow-up GetItem yields the post-append
1892
- // 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.
1893
2220
  // ---------------------------------------------------------------------------
1894
2221
  const timeSeriesConfig = config.timeSeries;
1895
- const append = (input, userCondition) => Effect.gen(function* () {
2222
+ const append = (input, userCondition, skipFollowUp = false) => Effect.gen(function* () {
1896
2223
  if (!timeSeriesConfig) {
1897
2224
  return yield* new ValidationError({
1898
2225
  entityType,
@@ -2061,6 +2388,22 @@ const makeImpl = (config) => {
2061
2388
  for (const field of gsiKeyFields()) {
2062
2389
  delete eventItem[field];
2063
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
+ }
2064
2407
  const marshalledEventItem = toAttributeMap(eventItem);
2065
2408
  // ---------- Execute TransactWriteItems ----------
2066
2409
  const transactResult = yield* client
@@ -2088,40 +2431,83 @@ const makeImpl = (config) => {
2088
2431
  onSuccess: () => Effect.succeed({ stale: false }),
2089
2432
  onFailure: (err) => {
2090
2433
  // TransactionCancelled or ConditionalCheckFailed at the transaction
2091
- // level both map to "stale" (can't distinguish CAS from user-cond
2092
- // 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).
2093
2436
  if (isAwsTransactionCancelled(err.cause) || isAwsConditionalCheckFailed(err.cause)) {
2094
2437
  return Effect.succeed({ stale: true });
2095
2438
  }
2096
2439
  return Effect.fail(err);
2097
2440
  },
2098
2441
  }));
2099
- // Follow-up GetItem to read the current (post-append on success, winning
2100
- // state on stale). One read is required either way — the contract
2101
- // 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
+ // -----------------------------------------------------------------
2102
2464
  const followUp = yield* client.getItem({
2103
2465
  TableName: tableName,
2104
2466
  Key: marshalledKey,
2105
2467
  });
2106
2468
  if (!followUp.Item) {
2107
- // TTL race or out-of-band delete — surface as a DynamoClientError
2108
- // (the previous state vanished under us). This is NOT a stale result.
2109
- 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({
2110
2472
  entityType,
2111
2473
  operation: "append.followUp",
2112
2474
  cause: "Current item not found after append — possible TTL race or concurrent delete.",
2113
- }));
2475
+ });
2114
2476
  }
2115
2477
  const rawCurrent = fromAttributeMap(followUp.Item);
2116
2478
  const currentModel = yield* decodeAs(rawCurrent, followUp.Item, "model");
2117
2479
  if (transactResult.stale) {
2118
- return {
2119
- applied: false,
2120
- reason: "stale",
2121
- current: currentModel,
2122
- };
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
+ });
2123
2509
  }
2124
- return { applied: true, current: currentModel };
2510
+ return { current: currentModel };
2125
2511
  });
2126
2512
  // ---------------------------------------------------------------------------
2127
2513
  // history operation — BoundQuery for event items only
@@ -2136,6 +2522,11 @@ const makeImpl = (config) => {
2136
2522
  const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary, decodedKey);
2137
2523
  const prefix = KeyComposer.composeEventSkPrefix(currentSk, schema.casing);
2138
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);
2139
2530
  renameFromDynamo(raw);
2140
2531
  return Schema.decodeUnknownEffect(schemas.historyRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
2141
2532
  entityType,
@@ -2280,6 +2671,11 @@ const makeImpl = (config) => {
2280
2671
  // (itemSchema would fail because it expects GSI key fields that aren't present)
2281
2672
  if (mode === "native")
2282
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);
2283
2679
  const targetSchema = schemas.deletedRecordSchema;
2284
2680
  return yield* Schema.decodeUnknownEffect(targetSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
2285
2681
  entityType,
@@ -2292,11 +2688,16 @@ const makeImpl = (config) => {
2292
2688
  const primary = config.indexes.primary;
2293
2689
  const pkValue = KeyComposer.composePk(schema, entityType, primary, decodedKey);
2294
2690
  const deletedPrefix = DynamoSchema.composeDeletedKeyPrefix(schema, entityType);
2295
- const decodeDeleted = (raw) => Schema.decodeUnknownEffect(schemas.deletedRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
2296
- entityType,
2297
- operation: "deleted.list.decode",
2298
- cause,
2299
- })));
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
+ };
2300
2701
  return Query.make({
2301
2702
  tableName: "",
2302
2703
  indexName: undefined,
@@ -2551,7 +2952,9 @@ const makeImpl = (config) => {
2551
2952
  // ---------------------------------------------------------------------------
2552
2953
  // Expression combinators: condition, filter, select
2553
2954
  // ---------------------------------------------------------------------------
2554
- 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);
2555
2958
  const entityConditionOps = createConditionOps();
2556
2959
  /**
2557
2960
  * Build a condition expression combinator from callback or shorthand.
@@ -2609,6 +3012,7 @@ const makeImpl = (config) => {
2609
3012
  _resolvedRefs: resolvedRefs,
2610
3013
  /** @internal Full decode pipeline: rename + schema decode. Used by Batch/Aggregate. */
2611
3014
  _decodeRecord: decodeRecord,
3015
+ _serializeSparseFields: serializeSparseFields,
2612
3016
  _attachPrototype: attachPrototype,
2613
3017
  _configure: (injectedSchema, injectedTableTag) => {
2614
3018
  schema = injectedSchema;
@@ -2676,10 +3080,15 @@ const makeImpl = (config) => {
2676
3080
  export const bind = (entity) => Effect.gen(function* () {
2677
3081
  const ctx = yield* Effect.context();
2678
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;
2679
3088
  // Shared config for the bound-CRUD builders — pre-resolved services plus
2680
3089
  // a typed PathBuilder/ConditionOps so `.condition((t, ops) => ...)` works.
2681
3090
  const boundCrudConfig = {
2682
- pathBuilder: createPathBuilder(),
3091
+ pathBuilder: createPathBuilder(undefined, undefined, boundSparseFieldsForPath),
2683
3092
  conditionOps: createConditionOps(),
2684
3093
  provide,
2685
3094
  };
@@ -2723,7 +3132,13 @@ export const bind = (entity) => Effect.gen(function* () {
2723
3132
  },
2724
3133
  // Time-series (no-ops when entity is not configured; runtime check in
2725
3134
  // the entity-level `append` returns a ValidationError).
2726
- 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
+ },
2727
3142
  history: (key) => {
2728
3143
  const q = entity.history(key);
2729
3144
  const pathBuilder = createPathBuilder();