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.
- package/dist/DynamoModel.d.ts +98 -6
- package/dist/DynamoModel.d.ts.map +1 -1
- package/dist/DynamoModel.js +110 -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 +568 -46
- package/dist/Entity.js.map +1 -1
- package/dist/Errors.d.ts +91 -1
- package/dist/Errors.d.ts.map +1 -1
- package/dist/Errors.js +68 -1
- package/dist/Errors.js.map +1 -1
- package/dist/KeyComposer.d.ts +61 -13
- package/dist/KeyComposer.d.ts.map +1 -1
- package/dist/KeyComposer.js +157 -24
- package/dist/KeyComposer.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/EntitySchemas.d.ts.map +1 -1
- package/dist/internal/EntitySchemas.js +7 -2
- package/dist/internal/EntitySchemas.js.map +1 -1
- package/dist/internal/EntityTypes.d.ts +19 -14
- 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, 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
|
|
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
|
-
|
|
1124
|
-
|
|
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
|
|
1368
|
-
// REMOVEs when an Entity.remove() targets a GSI
|
|
1369
|
-
// See DESIGN.md §7 Policy-Aware GSI Composition.
|
|
1370
|
-
|
|
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
|
-
//
|
|
1890
|
-
//
|
|
1891
|
-
//
|
|
1892
|
-
//
|
|
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
|
-
|
|
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
|
|
2092
|
-
//
|
|
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
|
-
//
|
|
2100
|
-
//
|
|
2101
|
-
//
|
|
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 —
|
|
2108
|
-
//
|
|
2109
|
-
return yield*
|
|
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
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
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 {
|
|
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) =>
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
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
|
-
|
|
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
|
|
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();
|