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.
- package/dist/DynamoModel.d.ts +50 -2
- package/dist/DynamoModel.d.ts.map +1 -1
- package/dist/DynamoModel.js +73 -1
- package/dist/DynamoModel.js.map +1 -1
- package/dist/Entity.d.ts +47 -18
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +451 -36
- package/dist/Entity.js.map +1 -1
- package/dist/Errors.d.ts +48 -1
- package/dist/Errors.d.ts.map +1 -1
- package/dist/Errors.js +38 -1
- package/dist/Errors.js.map +1 -1
- package/dist/Marshaller.d.ts +28 -0
- package/dist/Marshaller.d.ts.map +1 -1
- package/dist/Marshaller.js +116 -0
- package/dist/Marshaller.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/BoundCrud.d.ts +68 -0
- package/dist/internal/BoundCrud.d.ts.map +1 -1
- package/dist/internal/BoundCrud.js +42 -1
- package/dist/internal/BoundCrud.js.map +1 -1
- package/dist/internal/EntityCombinators.d.ts +28 -0
- package/dist/internal/EntityCombinators.d.ts.map +1 -1
- package/dist/internal/EntityCombinators.js +40 -0
- package/dist/internal/EntityCombinators.js.map +1 -1
- package/dist/internal/EntityOps.d.ts +18 -0
- package/dist/internal/EntityOps.d.ts.map +1 -1
- package/dist/internal/EntityOps.js +2 -0
- package/dist/internal/EntityOps.js.map +1 -1
- package/dist/internal/EntityTypes.d.ts +7 -12
- package/dist/internal/EntityTypes.d.ts.map +1 -1
- package/dist/internal/PathBuilder.d.ts +29 -4
- package/dist/internal/PathBuilder.d.ts.map +1 -1
- package/dist/internal/PathBuilder.js +47 -6
- package/dist/internal/PathBuilder.js.map +1 -1
- package/dist/internal/TransactableOps.d.ts.map +1 -1
- package/dist/internal/TransactableOps.js +12 -0
- package/dist/internal/TransactableOps.js.map +1 -1
- 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
|
|
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
|
-
//
|
|
1890
|
-
//
|
|
1891
|
-
//
|
|
1892
|
-
//
|
|
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
|
|
2092
|
-
//
|
|
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
|
-
//
|
|
2100
|
-
//
|
|
2101
|
-
//
|
|
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 —
|
|
2108
|
-
//
|
|
2109
|
-
return yield*
|
|
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
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
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 {
|
|
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) =>
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
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
|
-
|
|
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
|
|
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();
|