effect-dynamodb 1.4.0 → 1.6.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 (49) hide show
  1. package/dist/DynamoModel.d.ts +98 -6
  2. package/dist/DynamoModel.d.ts.map +1 -1
  3. package/dist/DynamoModel.js +110 -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 +568 -46
  8. package/dist/Entity.js.map +1 -1
  9. package/dist/Errors.d.ts +91 -1
  10. package/dist/Errors.d.ts.map +1 -1
  11. package/dist/Errors.js +68 -1
  12. package/dist/Errors.js.map +1 -1
  13. package/dist/KeyComposer.d.ts +61 -13
  14. package/dist/KeyComposer.d.ts.map +1 -1
  15. package/dist/KeyComposer.js +157 -24
  16. package/dist/KeyComposer.js.map +1 -1
  17. package/dist/Marshaller.d.ts +28 -0
  18. package/dist/Marshaller.d.ts.map +1 -1
  19. package/dist/Marshaller.js +116 -0
  20. package/dist/Marshaller.js.map +1 -1
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/internal/BoundCrud.d.ts +68 -0
  26. package/dist/internal/BoundCrud.d.ts.map +1 -1
  27. package/dist/internal/BoundCrud.js +42 -1
  28. package/dist/internal/BoundCrud.js.map +1 -1
  29. package/dist/internal/EntityCombinators.d.ts +28 -0
  30. package/dist/internal/EntityCombinators.d.ts.map +1 -1
  31. package/dist/internal/EntityCombinators.js +40 -0
  32. package/dist/internal/EntityCombinators.js.map +1 -1
  33. package/dist/internal/EntityOps.d.ts +18 -0
  34. package/dist/internal/EntityOps.d.ts.map +1 -1
  35. package/dist/internal/EntityOps.js +2 -0
  36. package/dist/internal/EntityOps.js.map +1 -1
  37. package/dist/internal/EntitySchemas.d.ts.map +1 -1
  38. package/dist/internal/EntitySchemas.js +7 -2
  39. package/dist/internal/EntitySchemas.js.map +1 -1
  40. package/dist/internal/EntityTypes.d.ts +19 -14
  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.map +1 -1
  47. package/dist/internal/TransactableOps.js +12 -0
  48. package/dist/internal/TransactableOps.js.map +1 -1
  49. 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, CompositeKeyHoleError, 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,33 @@ 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.
1176
+ //
1177
+ // null and undefined collapse: both signal explicit clear under
1178
+ // indexPolicy v2 (DESIGN.md §7). For the in-memory item we DELETE
1179
+ // the attribute (mirrors a DynamoDB REMOVE), so subsequent decode
1180
+ // sees the field as absent rather than as a typed null that the
1181
+ // model schema may not permit.
1070
1182
  for (const [attr, val] of Object.entries(hydratedUpdates)) {
1071
- if (val !== undefined)
1183
+ if (val === null || val === undefined) {
1184
+ delete newItem[attr];
1185
+ continue;
1186
+ }
1187
+ if (hasSparseFields && attr in sparseFields && typeof val === "object") {
1188
+ const existing = newItem[attr] ?? {};
1189
+ newItem[attr] = { ...existing, ...val };
1190
+ }
1191
+ else {
1072
1192
  newItem[attr] = val;
1193
+ }
1073
1194
  }
1074
1195
  // Apply rich operations to in-memory item
1075
1196
  if (uState.remove) {
@@ -1077,6 +1198,29 @@ const makeImpl = (config) => {
1077
1198
  delete newItem[attr];
1078
1199
  }
1079
1200
  }
1201
+ // Sparse-map removes: drop named entries from the in-memory Record.
1202
+ if (uState.sparseRemoveEntries) {
1203
+ for (const op of uState.sparseRemoveEntries) {
1204
+ if (!hasSparseFields || !(op.field in sparseFields))
1205
+ continue;
1206
+ const bucket = newItem[op.field];
1207
+ if (!bucket)
1208
+ continue;
1209
+ for (const k of op.keys)
1210
+ delete bucket[k];
1211
+ }
1212
+ }
1213
+ // Sparse-map clear: blow away the entire bucket Record. The retain
1214
+ // path always reads-then-writes the full item, so the version CAS
1215
+ // already gives clearMap atomic semantics for free — no per-bucket
1216
+ // GET dance needed here.
1217
+ if (uState.sparseClearFields) {
1218
+ for (const field of uState.sparseClearFields) {
1219
+ if (!hasSparseFields || !(field in sparseFields))
1220
+ continue;
1221
+ newItem[field] = {};
1222
+ }
1223
+ }
1080
1224
  if (uState.add) {
1081
1225
  for (const [attr, val] of Object.entries(uState.add)) {
1082
1226
  newItem[attr] = (newItem[attr] ?? 0) + val;
@@ -1120,8 +1264,57 @@ const makeImpl = (config) => {
1120
1264
  // `newItem` is already in domain names (the merge built it from
1121
1265
  // `currentDomainItem` + domain-keyed updates) — no rename needed
1122
1266
  // here. Recompose all keys with the updated attributes.
1123
- const newKeys = composeAllKeys(newItem);
1124
- Object.assign(newItem, newKeys);
1267
+ //
1268
+ // Primary key always recomposes from `newItem` (Put-style).
1269
+ const primaryKeyMap = composePrimaryKey(newItem);
1270
+ Object.assign(newItem, primaryKeyMap);
1271
+ // GSI keys: route through the policy-aware composer so explicit
1272
+ // clears (`set({ attr: null | undefined })`) and `Entity.remove`
1273
+ // cascades match the v2 semantics applied on the standard update
1274
+ // path. `newItem` carries stored values for any composite the
1275
+ // user did not touch — passing it as `keyRecord` lets the
1276
+ // composer treat omissions as no-ops (the half stays untouched
1277
+ // because the stored value is present), while still firing the
1278
+ // explicit-clear cascade and the SK preserve-truncation rule.
1279
+ // See DESIGN.md §7 + §7.6.
1280
+ const retainRemovedSet = uState.remove ? new Set(uState.remove) : undefined;
1281
+ try {
1282
+ const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, allIndexes, hydratedUpdates, newItem, { removedSet: retainRemovedSet });
1283
+ for (const [field, value] of Object.entries(gsiUpdate.sets)) {
1284
+ newItem[field] = value;
1285
+ }
1286
+ for (const field of gsiUpdate.removes) {
1287
+ delete newItem[field];
1288
+ }
1289
+ // Untouched GSIs (no policy + no payload + no cascade): retain
1290
+ // path semantics are Put-style — recompose from `newItem` and
1291
+ // drop both keys when any composite is missing. Matches the
1292
+ // existing `composeAllKeys` behavior for those GSIs.
1293
+ const addressed = new Set([
1294
+ ...Object.keys(gsiUpdate.sets),
1295
+ ...gsiUpdate.removes,
1296
+ ]);
1297
+ for (const [indexName, indexDef] of Object.entries(allIndexes)) {
1298
+ if (indexName === "primary")
1299
+ continue;
1300
+ if (addressed.has(indexDef.pk.field) || addressed.has(indexDef.sk.field))
1301
+ continue;
1302
+ const keys = KeyComposer.tryComposeIndexKeys(schema, entityType, entityVersion, indexDef, newItem);
1303
+ if (keys) {
1304
+ Object.assign(newItem, keys);
1305
+ }
1306
+ else {
1307
+ delete newItem[indexDef.pk.field];
1308
+ delete newItem[indexDef.sk.field];
1309
+ }
1310
+ }
1311
+ }
1312
+ catch (e) {
1313
+ if (e instanceof CompositeKeyHoleError) {
1314
+ return yield* e;
1315
+ }
1316
+ throw e;
1317
+ }
1125
1318
  const sentinelRotations = [];
1126
1319
  if (touchesUniqueFields) {
1127
1320
  const currentRawRecord = currentRaw;
@@ -1146,6 +1339,17 @@ const makeImpl = (config) => {
1146
1339
  }
1147
1340
  // Convert back to DynamoDB attribute names for storage
1148
1341
  renameToDynamo(newItem);
1342
+ // Flatten sparse Map fields into per-entry top-level attributes.
1343
+ try {
1344
+ serializeSparseFields(newItem);
1345
+ }
1346
+ catch (e) {
1347
+ return yield* new ValidationError({
1348
+ entityType,
1349
+ operation: "update.sparse",
1350
+ cause: e instanceof Error ? e.message : String(e),
1351
+ });
1352
+ }
1149
1353
  const marshalledNewItem = toAttributeMap(newItem);
1150
1354
  // Build snapshot of the pre-update state (only when retain is enabled)
1151
1355
  const snapshotItem = isRetainEnabled()
@@ -1293,12 +1497,50 @@ const makeImpl = (config) => {
1293
1497
  const updateSystemColliders = new Set();
1294
1498
  if (systemFields.updatedAtCollision && systemFields.updatedAt)
1295
1499
  updateSystemColliders.add(systemFields.updatedAt);
1296
- // Add user-provided updates
1500
+ // Add user-provided updates. Buffered REMOVE clauses for explicit
1501
+ // clears land in `removeClauses` below — under indexPolicy v2,
1502
+ // `set({ attr: null | undefined })` means "remove this attribute
1503
+ // now" (the same intent as `Entity.remove([attr])`). The two paths
1504
+ // differ only in syntax; both surface in the UpdateExpression as
1505
+ // REMOVE clauses, and both cascade through the policy-aware GSI
1506
+ // composer (clearedSet auto-derived from null/undefined values).
1507
+ // See DESIGN.md §7 for the three-way payload classification.
1508
+ const explicitClears = [];
1297
1509
  for (const [attr, val] of Object.entries(encodedUpdatesMap)) {
1298
- if (val === undefined)
1299
- continue;
1300
1510
  if (updateSystemColliders.has(attr))
1301
1511
  continue;
1512
+ if (val === null || val === undefined) {
1513
+ // Sparse fields: clearing a sparse field with null is a no-op
1514
+ // here — the `.removeEntries` API is the explicit per-key
1515
+ // remove. Skip to avoid REMOVE'ing the whole prefix erroneously.
1516
+ if (hasSparseFields && attr in sparseFields)
1517
+ continue;
1518
+ explicitClears.push(attr);
1519
+ continue;
1520
+ }
1521
+ // Sparse fields: emit one SET per bucket. Whole-bucket replace
1522
+ // semantics — concurrent writers to disjoint buckets are safe.
1523
+ if (hasSparseFields && attr in sparseFields) {
1524
+ if (typeof val !== "object")
1525
+ continue;
1526
+ const sparse = sparseFields[attr];
1527
+ for (const [k, v] of Object.entries(val)) {
1528
+ if (typeof k !== "string" || k.length === 0 || k.includes("#")) {
1529
+ return yield* new ValidationError({
1530
+ entityType,
1531
+ operation: "update.sparse",
1532
+ cause: `Sparse map "${attr}": invalid key ${JSON.stringify(k)}`,
1533
+ });
1534
+ }
1535
+ const nameKey = `#u${counter}`;
1536
+ const valKey = `:u${counter}`;
1537
+ names[nameKey] = `${sparse.prefix}#${k}`;
1538
+ values[valKey] = toAttributeValue(v);
1539
+ setClauses.push(`${nameKey} = ${valKey}`);
1540
+ counter++;
1541
+ }
1542
+ continue;
1543
+ }
1302
1544
  const nameKey = `#u${counter}`;
1303
1545
  const valKey = `:u${counter}`;
1304
1546
  names[nameKey] = resolveDbName(attr);
@@ -1355,6 +1597,16 @@ const makeImpl = (config) => {
1355
1597
  }
1356
1598
  }
1357
1599
  // REMOVE
1600
+ // Two channels feed REMOVE clauses:
1601
+ // 1. `Entity.remove([attr])` cascade — drops the GSI for any
1602
+ // containing composite. Tracked in `removedSet` so the
1603
+ // policy-aware composer can apply the cascade rule.
1604
+ // 2. Explicit clears in the user payload (`set({ attr: null })`).
1605
+ // These also REMOVE the attribute, but they do NOT cascade to
1606
+ // the whole GSI — instead, the policy-aware composer's
1607
+ // three-way classification consumes them via the auto-derived
1608
+ // clearedSet (preserve → truncate, sparse → drop). This
1609
+ // distinction is what makes hierarchical SK pruning work.
1358
1610
  const removedSet = uState.remove ? new Set(uState.remove) : undefined;
1359
1611
  if (uState.remove) {
1360
1612
  for (const attr of uState.remove) {
@@ -1363,11 +1615,30 @@ const makeImpl = (config) => {
1363
1615
  removeClauses.push(nameKey);
1364
1616
  }
1365
1617
  }
1618
+ for (const attr of explicitClears) {
1619
+ const nameKey = `#r${removeClauses.length}`;
1620
+ names[nameKey] = resolveDbName(attr);
1621
+ removeClauses.push(nameKey);
1622
+ }
1366
1623
  // Policy-aware GSI key composition. One call covers SETs (full
1367
- // recompose or half-wise), sparse dropout REMOVEs, and cascade
1368
- // REMOVEs when an Entity.remove() targets a GSI composite.
1369
- // See DESIGN.md §7 Policy-Aware GSI Composition.
1370
- const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, allIndexes, hydratedUpdates, decodedKey, { removedSet });
1624
+ // recompose, half-wise, or SK truncation), sparse dropout REMOVEs,
1625
+ // and cascade REMOVEs when an Entity.remove() targets a GSI
1626
+ // composite. See DESIGN.md §7 Policy-Aware GSI Composition.
1627
+ //
1628
+ // The composer throws CompositeKeyHoleError (EDD-9024) when the
1629
+ // payload describes a hole pattern (a cleared composite at SK
1630
+ // position i with a present composite at j > i). Catch it
1631
+ // synchronously and surface as a typed Effect failure.
1632
+ let gsiUpdate;
1633
+ try {
1634
+ gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, allIndexes, hydratedUpdates, decodedKey, { removedSet });
1635
+ }
1636
+ catch (e) {
1637
+ if (e instanceof CompositeKeyHoleError) {
1638
+ return yield* e;
1639
+ }
1640
+ throw e;
1641
+ }
1371
1642
  for (const [field, value] of Object.entries(gsiUpdate.sets)) {
1372
1643
  const nameKey = `#u${counter}`;
1373
1644
  const valKey = `:u${counter}`;
@@ -1485,6 +1756,73 @@ const makeImpl = (config) => {
1485
1756
  deleteClauses.push(`${pathExpr} ${valKey}`);
1486
1757
  }
1487
1758
  }
1759
+ // Sparse-map .removeEntries — explicit REMOVE on `<prefix>#<key>`.
1760
+ // Each key emits one REMOVE clause through ExpressionAttributeNames
1761
+ // (the literal "#" survives via compilePath's raw-segment escape).
1762
+ if (uState.sparseRemoveEntries) {
1763
+ for (const op of uState.sparseRemoveEntries) {
1764
+ if (!hasSparseFields || !(op.field in sparseFields)) {
1765
+ return yield* new ValidationError({
1766
+ entityType,
1767
+ operation: "update.removeEntries",
1768
+ cause: `field "${op.field}" is not configured as a sparse map`,
1769
+ });
1770
+ }
1771
+ const sparse = sparseFields[op.field];
1772
+ for (const k of op.keys) {
1773
+ if (typeof k !== "string" || k.length === 0 || k.includes("#")) {
1774
+ return yield* new ValidationError({
1775
+ entityType,
1776
+ operation: "update.removeEntries",
1777
+ cause: `Sparse map "${op.field}": invalid key ${JSON.stringify(k)}`,
1778
+ });
1779
+ }
1780
+ const nameKey = `#sr${removeClauses.length}`;
1781
+ names[nameKey] = `${sparse.prefix}#${k}`;
1782
+ removeClauses.push(nameKey);
1783
+ }
1784
+ }
1785
+ }
1786
+ // Sparse-map .clearMap — Get-then-Update helper. Reads the current
1787
+ // item with a consistent read to discover which `<prefix>#*` attrs
1788
+ // exist, then folds the resulting REMOVEs into this same UpdateItem.
1789
+ // The version CAS (when configured) provides atomicity; non-versioned
1790
+ // entities are best-effort (a concurrent writer can add a new bucket
1791
+ // between the read and the update — that bucket survives).
1792
+ if (uState.sparseClearFields && uState.sparseClearFields.length > 0) {
1793
+ for (const field of uState.sparseClearFields) {
1794
+ if (!hasSparseFields || !(field in sparseFields)) {
1795
+ return yield* new ValidationError({
1796
+ entityType,
1797
+ operation: "update.clearMap",
1798
+ cause: `field "${field}" is not configured as a sparse map`,
1799
+ });
1800
+ }
1801
+ }
1802
+ // Read once for all clearMap fields combined.
1803
+ const clearGetResult = yield* client.getItem({
1804
+ TableName: tableName,
1805
+ Key: marshalledKey,
1806
+ ConsistentRead: true,
1807
+ });
1808
+ if (clearGetResult.Item) {
1809
+ const clearItemKeys = Object.keys(clearGetResult.Item);
1810
+ for (const field of uState.sparseClearFields) {
1811
+ const sparse = sparseFields[field];
1812
+ const prefixWithDelim = `${sparse.prefix}#`;
1813
+ for (const attrName of clearItemKeys) {
1814
+ if (attrName.startsWith(prefixWithDelim)) {
1815
+ const nameKey = `#sc${removeClauses.length}`;
1816
+ names[nameKey] = attrName;
1817
+ removeClauses.push(nameKey);
1818
+ }
1819
+ }
1820
+ }
1821
+ }
1822
+ // If the item doesn't exist, clearMap is a no-op — the subsequent
1823
+ // UpdateItem will create the row (with whatever other clauses the
1824
+ // builder accumulated) per DynamoDB UpdateItem semantics.
1825
+ }
1488
1826
  const hasAnyUpdate = setClauses.length > 0 ||
1489
1827
  removeClauses.length > 0 ||
1490
1828
  addClauses.length > 0 ||
@@ -1518,13 +1856,16 @@ const makeImpl = (config) => {
1518
1856
  Object.assign(values, uc.values);
1519
1857
  }
1520
1858
  const conditionExpression = condParts.length > 0 ? condParts.join(" AND ") : undefined;
1859
+ // DynamoDB rejects an empty `ExpressionAttributeValues` map. When
1860
+ // the UpdateExpression is REMOVE-only (e.g. clearMap with no other
1861
+ // combinators), `values` may be empty — omit the property entirely.
1521
1862
  const result = yield* client
1522
1863
  .updateItem({
1523
1864
  TableName: tableName,
1524
1865
  Key: marshalledKey,
1525
1866
  UpdateExpression: updateExpression,
1526
1867
  ExpressionAttributeNames: names,
1527
- ExpressionAttributeValues: values,
1868
+ ExpressionAttributeValues: Object.keys(values).length > 0 ? values : undefined,
1528
1869
  ConditionExpression: conditionExpression,
1529
1870
  ReturnValues: returnValuesMap[uState.returnValues ?? "allNew"],
1530
1871
  })
@@ -1773,6 +2114,35 @@ const makeImpl = (config) => {
1773
2114
  continue;
1774
2115
  if (val === undefined)
1775
2116
  continue;
2117
+ // Sparse fields: expand into one SET per bucket. Each bucket
2118
+ // attribute is named `<prefix>#<key>` and goes through
2119
+ // ExpressionAttributeNames as a literal (the `#` survives).
2120
+ if (hasSparseFields && attr in sparseFields) {
2121
+ if (val === null || typeof val !== "object")
2122
+ continue;
2123
+ const sparse = sparseFields[attr];
2124
+ for (const [k, v] of Object.entries(val)) {
2125
+ try {
2126
+ if (typeof k !== "string" || k.length === 0 || k.includes("#")) {
2127
+ throw new Error(`Sparse map "${attr}": invalid key ${JSON.stringify(k)}`);
2128
+ }
2129
+ }
2130
+ catch (e) {
2131
+ return yield* new ValidationError({
2132
+ entityType,
2133
+ operation: "upsert.sparse",
2134
+ cause: e instanceof Error ? e.message : String(e),
2135
+ });
2136
+ }
2137
+ const nameKey = `#u${counter}`;
2138
+ const valKey = `:u${counter}`;
2139
+ names[nameKey] = `${sparse.prefix}#${k}`;
2140
+ values[valKey] = toAttributeValue(v);
2141
+ setClauses.push(`${nameKey} = ${valKey}`);
2142
+ counter++;
2143
+ }
2144
+ continue;
2145
+ }
1776
2146
  const nameKey = `#u${counter}`;
1777
2147
  const valKey = `:u${counter}`;
1778
2148
  names[nameKey] = resolveDbName(attr);
@@ -1876,6 +2246,47 @@ const makeImpl = (config) => {
1876
2246
  const raw = fromAttributeMap(result.Attributes);
1877
2247
  return yield* decodeAs(raw, result.Attributes, mode);
1878
2248
  }), self, input);
2249
+ /**
2250
+ * @internal Compare stored vs attempted `orderBy` to decide whether the CAS
2251
+ * predicate `attribute_not_exists(pk) OR stored < attempted` failed.
2252
+ *
2253
+ * Returns `true` (CAS failed → stale) when `stored >= attempted`.
2254
+ * Returns `false` (CAS held; the user's `.condition()` is the rejecter)
2255
+ * when `stored < attempted` or when `stored` is missing.
2256
+ *
2257
+ * `stored` arrives as a decoded domain value matching the configured schema
2258
+ * for the attribute: a `DateTime.Utc` for `Schema.DateTimeUtc`, a `Date` for
2259
+ * `Schema.Date`, a `number`/`bigint` for numeric schemas, a `string` for
2260
+ * lexicographic comparisons.
2261
+ */
2262
+ const isCasFailure = (stored, attempted) => {
2263
+ if (stored == null)
2264
+ return false;
2265
+ // DateTime.Utc — compare epoch ms.
2266
+ if (DateTime.isDateTime(stored) && DateTime.isDateTime(attempted)) {
2267
+ return DateTime.toEpochMillis(stored) >= DateTime.toEpochMillis(attempted);
2268
+ }
2269
+ // Date — compare epoch ms.
2270
+ if (stored instanceof Date && attempted instanceof Date) {
2271
+ return stored.getTime() >= attempted.getTime();
2272
+ }
2273
+ // Numeric — number or bigint (cast comparable via JS coercion).
2274
+ if (typeof stored === "number" && typeof attempted === "number") {
2275
+ return stored >= attempted;
2276
+ }
2277
+ if (typeof stored === "bigint" && typeof attempted === "bigint") {
2278
+ return stored >= attempted;
2279
+ }
2280
+ // Strings — lexicographic.
2281
+ if (typeof stored === "string" && typeof attempted === "string") {
2282
+ return stored >= attempted;
2283
+ }
2284
+ // Mixed / unknown shapes — fall back to string compare on String(...).
2285
+ // Defensive: return true so we report StaleAppend (the safer default —
2286
+ // never suppress a CAS rejection by mis-classifying it as a user-condition
2287
+ // rejection).
2288
+ return String(stored) >= String(attempted);
2289
+ };
1879
2290
  // ---------------------------------------------------------------------------
1880
2291
  // append operation — time-series primitive (only when `timeSeries` configured)
1881
2292
  //
@@ -1886,13 +2297,25 @@ const makeImpl = (config) => {
1886
2297
  // - Put of event: full decoded input + __edd_e__ + _ttl (if configured),
1887
2298
  // GSI keys stripped, SK replaced with `<currentSk>#e#<orderByValue>`.
1888
2299
  //
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.
2300
+ // Concurrency outcome (stale-as-error contract — supersedes
2301
+ // `docs/designs/timeseries.md` §4.7 v1 stale-as-value):
2302
+ //
2303
+ // - Success path (default): on transaction success, issue a follow-up
2304
+ // GetItem and return `{ current }` (decoded model).
2305
+ // - Stale path (default): on TransactionCancelled, issue a follow-up
2306
+ // GetItem to disambiguate. If the stored `orderBy >= attempted`, the CAS
2307
+ // fired → fail with `StaleAppend(current: Option.some)`. Otherwise the
2308
+ // user-supplied `.condition()` rejected the write → fail with
2309
+ // `ConditionalCheckFailed(current: Option.some)`.
2310
+ // - skipFollowUp success path: return `void`.
2311
+ // - skipFollowUp stale path: cannot disambiguate (no GetItem), so fail
2312
+ // with `StaleAppend(current: Option.none)` for both cancellation modes.
2313
+ // Documented as a deliberate trade-off in `guides/timeseries.mdx`.
2314
+ // - TTL-race / row vanished after success: surface as
2315
+ // `ValidationError("append.followUp")`. Undetected on skipFollowUp path.
1893
2316
  // ---------------------------------------------------------------------------
1894
2317
  const timeSeriesConfig = config.timeSeries;
1895
- const append = (input, userCondition) => Effect.gen(function* () {
2318
+ const append = (input, userCondition, skipFollowUp = false) => Effect.gen(function* () {
1896
2319
  if (!timeSeriesConfig) {
1897
2320
  return yield* new ValidationError({
1898
2321
  entityType,
@@ -1981,7 +2404,18 @@ const makeImpl = (config) => {
1981
2404
  },
1982
2405
  };
1983
2406
  }
1984
- const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, policyAwareIndexes, nonPkAppendFields, encoded);
2407
+ // The composer can throw CompositeKeyHoleError (EDD-9024) on a hole
2408
+ // pattern. Surface it as a typed Effect failure.
2409
+ let gsiUpdate;
2410
+ try {
2411
+ gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, policyAwareIndexes, nonPkAppendFields, encoded);
2412
+ }
2413
+ catch (e) {
2414
+ if (e instanceof CompositeKeyHoleError) {
2415
+ return yield* e;
2416
+ }
2417
+ throw e;
2418
+ }
1985
2419
  for (const [field, value] of Object.entries(gsiUpdate.sets)) {
1986
2420
  const nameKey = `#a${counter}`;
1987
2421
  const valKey = `:a${counter}`;
@@ -2061,6 +2495,22 @@ const makeImpl = (config) => {
2061
2495
  for (const field of gsiKeyFields()) {
2062
2496
  delete eventItem[field];
2063
2497
  }
2498
+ // Sparse-map fields are aggregate state, not event state. They live on
2499
+ // the current item only — strip from event items entirely. (Same
2500
+ // treatment as enrichment fields outside `appendInput`. By design,
2501
+ // `appendInput` cannot include sparse fields, so this is defensive.)
2502
+ if (hasSparseFields) {
2503
+ for (const fieldName of Object.keys(sparseFields)) {
2504
+ delete eventItem[fieldName];
2505
+ // Also strip any flattened bucket attrs that might have leaked in
2506
+ // via a future code change — defensive cleanup.
2507
+ const prefixWithDelim = `${sparseFields[fieldName].prefix}#`;
2508
+ for (const k of Object.keys(eventItem)) {
2509
+ if (k.startsWith(prefixWithDelim))
2510
+ delete eventItem[k];
2511
+ }
2512
+ }
2513
+ }
2064
2514
  const marshalledEventItem = toAttributeMap(eventItem);
2065
2515
  // ---------- Execute TransactWriteItems ----------
2066
2516
  const transactResult = yield* client
@@ -2088,40 +2538,83 @@ const makeImpl = (config) => {
2088
2538
  onSuccess: () => Effect.succeed({ stale: false }),
2089
2539
  onFailure: (err) => {
2090
2540
  // 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).
2541
+ // level both surface here. Disambiguation between CAS and
2542
+ // user-condition happens after the follow-up GetItem (when run).
2093
2543
  if (isAwsTransactionCancelled(err.cause) || isAwsConditionalCheckFailed(err.cause)) {
2094
2544
  return Effect.succeed({ stale: true });
2095
2545
  }
2096
2546
  return Effect.fail(err);
2097
2547
  },
2098
2548
  }));
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.
2549
+ // -----------------------------------------------------------------
2550
+ // skipFollowUp branch — no GetItem, cannot decode current, cannot
2551
+ // disambiguate CAS vs user-condition cancellation. Both modes
2552
+ // collapse to StaleAppend(current: Option.none) — documented.
2553
+ // -----------------------------------------------------------------
2554
+ if (skipFollowUp) {
2555
+ if (transactResult.stale) {
2556
+ return yield* new StaleAppend({
2557
+ entityType,
2558
+ orderByField,
2559
+ attemptedOrderBy: newObValue,
2560
+ current: Option.none(),
2561
+ });
2562
+ }
2563
+ return undefined;
2564
+ }
2565
+ // -----------------------------------------------------------------
2566
+ // Default branch — one follow-up GetItem either way. Carries:
2567
+ // - Decoded model on success (the post-append current).
2568
+ // - Disambiguation data on cancellation (stored orderBy vs attempted)
2569
+ // plus a model snapshot to attach to the error for the caller.
2570
+ // -----------------------------------------------------------------
2102
2571
  const followUp = yield* client.getItem({
2103
2572
  TableName: tableName,
2104
2573
  Key: marshalledKey,
2105
2574
  });
2106
2575
  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({
2576
+ // TTL race or out-of-band delete — the previous state vanished
2577
+ // under us. Distinct from a CAS-stale outcome.
2578
+ return yield* new ValidationError({
2110
2579
  entityType,
2111
2580
  operation: "append.followUp",
2112
2581
  cause: "Current item not found after append — possible TTL race or concurrent delete.",
2113
- }));
2582
+ });
2114
2583
  }
2115
2584
  const rawCurrent = fromAttributeMap(followUp.Item);
2116
2585
  const currentModel = yield* decodeAs(rawCurrent, followUp.Item, "model");
2117
2586
  if (transactResult.stale) {
2118
- return {
2119
- applied: false,
2120
- reason: "stale",
2121
- current: currentModel,
2122
- };
2587
+ // Disambiguate CAS-stale from user-condition rejection. We compare
2588
+ // in the WIRE form (what DynamoDB actually evaluated): `newObValue`
2589
+ // is the encoded value passed to the CAS, and `rawCurrent` carries
2590
+ // the encoded values read back. `decodeAs` mutated `rawCurrent` to
2591
+ // rename DB columns to domain field names, so `rawCurrent[orderByField]`
2592
+ // is the wire-form value under its domain name. Wire-form
2593
+ // comparison matches DynamoDB's evaluation: ISO-8601 strings sort
2594
+ // lexicographically (= chronologically), numbers/bigints are
2595
+ // numeric, strings are lexicographic.
2596
+ const storedOrderBy = rawCurrent[orderByField];
2597
+ const attempted = newObValue;
2598
+ const casFired = isCasFailure(storedOrderBy, attempted);
2599
+ if (!casFired && userCondition !== undefined) {
2600
+ // CAS held; the user's `.condition()` is the only thing that
2601
+ // could have rejected.
2602
+ return yield* new ConditionalCheckFailed({
2603
+ entityType,
2604
+ key: encoded,
2605
+ current: Option.some(currentModel),
2606
+ });
2607
+ }
2608
+ // Either CAS fired, or no user condition was supplied (CAS is the
2609
+ // only possibility) — both map to StaleAppend.
2610
+ return yield* new StaleAppend({
2611
+ entityType,
2612
+ orderByField,
2613
+ attemptedOrderBy: attempted,
2614
+ current: Option.some(currentModel),
2615
+ });
2123
2616
  }
2124
- return { applied: true, current: currentModel };
2617
+ return { current: currentModel };
2125
2618
  });
2126
2619
  // ---------------------------------------------------------------------------
2127
2620
  // history operation — BoundQuery for event items only
@@ -2136,6 +2629,11 @@ const makeImpl = (config) => {
2136
2629
  const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary, decodedKey);
2137
2630
  const prefix = KeyComposer.composeEventSkPrefix(currentSk, schema.casing);
2138
2631
  const decodeHistory = (raw) => {
2632
+ // Sparse fields are aggregate state, not event state — event items DO
2633
+ // NOT carry `<prefix>#*` attributes. `deserializeSparseFields` populates
2634
+ // empty `{}` Records under the domain field names so the historyRecord
2635
+ // schema (which still includes the sparse Record fields) decodes.
2636
+ deserializeSparseFields(raw);
2139
2637
  renameFromDynamo(raw);
2140
2638
  return Schema.decodeUnknownEffect(schemas.historyRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
2141
2639
  entityType,
@@ -2280,6 +2778,11 @@ const makeImpl = (config) => {
2280
2778
  // (itemSchema would fail because it expects GSI key fields that aren't present)
2281
2779
  if (mode === "native")
2282
2780
  return result.Items[0];
2781
+ // Sparse Map fields: rebuild domain Records from flattened attrs so
2782
+ // soft-deleted items decode correctly. Sparse data is preserved
2783
+ // verbatim across soft-delete (GSI keys are stripped, sparse data is
2784
+ // not).
2785
+ deserializeSparseFields(raw);
2283
2786
  const targetSchema = schemas.deletedRecordSchema;
2284
2787
  return yield* Schema.decodeUnknownEffect(targetSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
2285
2788
  entityType,
@@ -2292,11 +2795,16 @@ const makeImpl = (config) => {
2292
2795
  const primary = config.indexes.primary;
2293
2796
  const pkValue = KeyComposer.composePk(schema, entityType, primary, decodedKey);
2294
2797
  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
- })));
2798
+ const decodeDeleted = (raw) => {
2799
+ // Sparse fields are domain data and are preserved across soft-delete.
2800
+ // Rebuild Records from flattened attrs before schema decode.
2801
+ deserializeSparseFields(raw);
2802
+ return Schema.decodeUnknownEffect(schemas.deletedRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
2803
+ entityType,
2804
+ operation: "deleted.list.decode",
2805
+ cause,
2806
+ })));
2807
+ };
2300
2808
  return Query.make({
2301
2809
  tableName: "",
2302
2810
  indexName: undefined,
@@ -2551,7 +3059,9 @@ const makeImpl = (config) => {
2551
3059
  // ---------------------------------------------------------------------------
2552
3060
  // Expression combinators: condition, filter, select
2553
3061
  // ---------------------------------------------------------------------------
2554
- const entityPathBuilder = createPathBuilder([], resolveDbName);
3062
+ // Sparse-field map used by createPathBuilder to wire `.entry(key)` accessors.
3063
+ const sparseFieldsForPath = hasSparseFields ? sparseFields : undefined;
3064
+ const entityPathBuilder = createPathBuilder([], resolveDbName, sparseFieldsForPath);
2555
3065
  const entityConditionOps = createConditionOps();
2556
3066
  /**
2557
3067
  * Build a condition expression combinator from callback or shorthand.
@@ -2609,6 +3119,7 @@ const makeImpl = (config) => {
2609
3119
  _resolvedRefs: resolvedRefs,
2610
3120
  /** @internal Full decode pipeline: rename + schema decode. Used by Batch/Aggregate. */
2611
3121
  _decodeRecord: decodeRecord,
3122
+ _serializeSparseFields: serializeSparseFields,
2612
3123
  _attachPrototype: attachPrototype,
2613
3124
  _configure: (injectedSchema, injectedTableTag) => {
2614
3125
  schema = injectedSchema;
@@ -2676,10 +3187,15 @@ const makeImpl = (config) => {
2676
3187
  export const bind = (entity) => Effect.gen(function* () {
2677
3188
  const ctx = yield* Effect.context();
2678
3189
  const provide = (effect) => Effect.provide(effect, ctx);
3190
+ // Sparse-field map (if any) — extracted from the Entity's configured model
3191
+ // so the PathBuilder used by `.condition((t, ops) => ...)` exposes
3192
+ // `.entry(key)` accessors on sparse-map fields.
3193
+ const boundSparseFields = getSparseFields(entity.model);
3194
+ const boundSparseFieldsForPath = Object.keys(boundSparseFields).length > 0 ? boundSparseFields : undefined;
2679
3195
  // Shared config for the bound-CRUD builders — pre-resolved services plus
2680
3196
  // a typed PathBuilder/ConditionOps so `.condition((t, ops) => ...)` works.
2681
3197
  const boundCrudConfig = {
2682
- pathBuilder: createPathBuilder(),
3198
+ pathBuilder: createPathBuilder(undefined, undefined, boundSparseFieldsForPath),
2683
3199
  conditionOps: createConditionOps(),
2684
3200
  provide,
2685
3201
  };
@@ -2723,7 +3239,13 @@ export const bind = (entity) => Effect.gen(function* () {
2723
3239
  },
2724
3240
  // Time-series (no-ops when entity is not configured; runtime check in
2725
3241
  // the entity-level `append` returns a ValidationError).
2726
- append: (input, condition) => provide(entity.append(input, condition)),
3242
+ append: (input) => {
3243
+ const appendCfg = {
3244
+ ...boundCrudConfig,
3245
+ run: (opts) => provide(entity.append(opts.input, opts.condition, opts.skipFollowUp)),
3246
+ };
3247
+ return makeBoundAppend(input, appendCfg);
3248
+ },
2727
3249
  history: (key) => {
2728
3250
  const q = entity.history(key);
2729
3251
  const pathBuilder = createPathBuilder();