effect-dynamodb 1.19.1 → 1.20.1
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/Aggregate.d.ts +23 -0
- package/dist/Aggregate.d.ts.map +1 -1
- package/dist/Aggregate.js +29 -7
- package/dist/Aggregate.js.map +1 -1
- package/dist/Entity.d.ts +5 -3
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +133 -22
- package/dist/Entity.js.map +1 -1
- package/dist/Query.d.ts +47 -1
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +140 -19
- package/dist/Query.js.map +1 -1
- package/dist/internal/BoundQuery.d.ts +32 -3
- package/dist/internal/BoundQuery.d.ts.map +1 -1
- package/dist/internal/BoundQuery.js +4 -0
- package/dist/internal/BoundQuery.js.map +1 -1
- package/package.json +2 -2
package/dist/Entity.js
CHANGED
|
@@ -44,9 +44,11 @@ import { asModel, condition as conditionCombinator, expectedVersion, set, } from
|
|
|
44
44
|
* records can coexist with the field unset and an entity write doesn't synthesize
|
|
45
45
|
* a literal `"undefined"` collision key.
|
|
46
46
|
*
|
|
47
|
-
* `
|
|
48
|
-
*
|
|
49
|
-
* `
|
|
47
|
+
* `source` is read by **domain** field name — a constraint declares domain
|
|
48
|
+
* fields, so a row that came off the wire (keyed by stored attribute name) MUST
|
|
49
|
+
* be passed through `toDomainView` first. Reading a renamed field off a raw row
|
|
50
|
+
* yields `undefined`, which the sparse rule then reads as "constraint unset" and
|
|
51
|
+
* silently skips the sentinel — the delete/purge/restore leak behind #127.
|
|
50
52
|
*/
|
|
51
53
|
/**
|
|
52
54
|
* TTL helpers — pure implementations live in `@effect-dynamodb/schema/Entity`,
|
|
@@ -55,13 +57,12 @@ import { asModel, condition as conditionCombinator, expectedVersion, set, } from
|
|
|
55
57
|
*/
|
|
56
58
|
import { buildEntityDefinition, normalizeTtlSeconds, resolveUniqueTtl, } from "@effect-dynamodb/schema/Entity.js";
|
|
57
59
|
export { normalizeTtlSeconds };
|
|
58
|
-
const composeUniqueSentinel = (schema, entityType, constraintName, constraintDef, source
|
|
60
|
+
const composeUniqueSentinel = (schema, entityType, constraintName, constraintDef, source) => {
|
|
59
61
|
const fields = resolveUniqueFields(constraintDef);
|
|
60
|
-
const get = lookup ?? ((s, f) => s[f]);
|
|
61
62
|
const serialized = [];
|
|
62
63
|
const fieldsRecord = {};
|
|
63
64
|
for (const f of fields) {
|
|
64
|
-
const raw =
|
|
65
|
+
const raw = source[f];
|
|
65
66
|
if (raw === undefined || raw === null)
|
|
66
67
|
return undefined;
|
|
67
68
|
const s = KeyComposer.serializeValue(raw);
|
|
@@ -298,6 +299,27 @@ const makeImpl = (config, precomputedData) => {
|
|
|
298
299
|
return;
|
|
299
300
|
decodeSparseFields(raw, sparseFields);
|
|
300
301
|
};
|
|
302
|
+
/**
|
|
303
|
+
* A DOMAIN-keyed VIEW of a row that came off the wire.
|
|
304
|
+
*
|
|
305
|
+
* Stored rows are keyed by ATTRIBUTE name, but everything that reads a row by
|
|
306
|
+
* field name — key composition (`composeAllKeys`, `keyForm`), vector-partition
|
|
307
|
+
* recomposition, unique-sentinel composition — is written against DOMAIN
|
|
308
|
+
* names. Under a `DynamoModel.configure(model, { id: { field: "widgetId" } })`
|
|
309
|
+
* rename the two differ, and reading the wrong one yields `undefined`: key
|
|
310
|
+
* composition throws (`Missing composite attribute "id" in record`) and
|
|
311
|
+
* sentinel composition silently treats the constraint as unset (#127).
|
|
312
|
+
*
|
|
313
|
+
* Returns a shallow COPY — the caller's row stays attribute-keyed, so the item
|
|
314
|
+
* actually written back (tombstone, restored item, snapshot) is unaffected and
|
|
315
|
+
* `decodeAs` can still do its own in-place rename on it. Callers that decode
|
|
316
|
+
* the row and discard it rename in place instead (see `decodeRecord`).
|
|
317
|
+
*/
|
|
318
|
+
const toDomainView = (raw) => {
|
|
319
|
+
const view = { ...raw };
|
|
320
|
+
renameFromDynamo(view);
|
|
321
|
+
return view;
|
|
322
|
+
};
|
|
301
323
|
// ---------------------------------------------------------------------------
|
|
302
324
|
// Helpers
|
|
303
325
|
// ---------------------------------------------------------------------------
|
|
@@ -495,6 +517,43 @@ const makeImpl = (config, precomputedData) => {
|
|
|
495
517
|
ConditionExpression: "attribute_not_exists(#sentinel_pk)",
|
|
496
518
|
ExpressionAttributeNames: { "#sentinel_pk": config.indexes.primary.pk.field },
|
|
497
519
|
});
|
|
520
|
+
/**
|
|
521
|
+
* The RESTORE-time sentinel guard.
|
|
522
|
+
*
|
|
523
|
+
* `restore` re-establishes one sentinel per satisfiable constraint. With
|
|
524
|
+
* `preserveUnique` off the delete released them, so `attribute_not_exists` is
|
|
525
|
+
* exactly right: the row is gone and anyone may have taken the value since.
|
|
526
|
+
*
|
|
527
|
+
* With `softDelete: { preserveUnique: true }` the delete deliberately KEPT the
|
|
528
|
+
* reservation, so `attribute_not_exists` can never hold and every restore of a
|
|
529
|
+
* constrained entity was cancelled and reported as a `UniqueConstraintViolation`
|
|
530
|
+
* against its own reservation — `preserveUnique` made `restore` impossible.
|
|
531
|
+
* The guard must instead let the row re-claim the sentinel it still owns while
|
|
532
|
+
* still refusing one somebody else holds, which is what the stored
|
|
533
|
+
* `_entity_pk`/`_entity_sk` back-pointer is for: they name the item the
|
|
534
|
+
* reservation belongs to, and they are the same values this Put writes.
|
|
535
|
+
*/
|
|
536
|
+
const restoreSentinelGuard = (entityPk, entitySk) => {
|
|
537
|
+
const pkField = config.indexes.primary.pk.field;
|
|
538
|
+
if (!preserveUnique()) {
|
|
539
|
+
return {
|
|
540
|
+
ConditionExpression: "attribute_not_exists(#pk)",
|
|
541
|
+
ExpressionAttributeNames: { "#pk": pkField },
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
ConditionExpression: "attribute_not_exists(#pk) OR (#epk = :epk AND #esk = :esk)",
|
|
546
|
+
ExpressionAttributeNames: {
|
|
547
|
+
"#pk": pkField,
|
|
548
|
+
"#epk": "_entity_pk",
|
|
549
|
+
"#esk": "_entity_sk",
|
|
550
|
+
},
|
|
551
|
+
ExpressionAttributeValues: {
|
|
552
|
+
":epk": toAttributeValue(entityPk),
|
|
553
|
+
":esk": toAttributeValue(entitySk),
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
};
|
|
498
557
|
/** Collect all key field names (pk, sk, gsi*pk, gsi*sk) */
|
|
499
558
|
const gsiKeyFields = () => {
|
|
500
559
|
const fields = [];
|
|
@@ -775,6 +834,15 @@ const makeImpl = (config, precomputedData) => {
|
|
|
775
834
|
for (const field of vectorKeyFields()) {
|
|
776
835
|
delete snapshot[field];
|
|
777
836
|
}
|
|
837
|
+
// And the soft-delete STASH. It is not an indexed attribute, so nothing
|
|
838
|
+
// above touches it, but `restore` builds its snapshot from the tombstone —
|
|
839
|
+
// which is exactly where the stash lives — so without this the `#v#N` row
|
|
840
|
+
// ends up carrying the full embedding blob that the delete-time snapshot at
|
|
841
|
+
// the same SK never had. A snapshot never re-enters the index, so it has no
|
|
842
|
+
// use for a stashed embedding either.
|
|
843
|
+
for (const [, definition] of vectorIndexEntries) {
|
|
844
|
+
delete snapshot[definition.stashField];
|
|
845
|
+
}
|
|
778
846
|
// Replace SK with version SK
|
|
779
847
|
snapshot[tableSkField] = DynamoSchema.composeVersionKey(schema, entityType, version);
|
|
780
848
|
// Add optional TTL
|
|
@@ -809,14 +877,21 @@ const makeImpl = (config, precomputedData) => {
|
|
|
809
877
|
* Mirrors the emission in `put`'s own transact path so the two cannot drift:
|
|
810
878
|
* one sentinel per satisfiable constraint (sparse — a constraint whose fields
|
|
811
879
|
* are unset produces none), then the v1 snapshot.
|
|
880
|
+
*
|
|
881
|
+
* `item` arrives ALREADY renamed to stored attribute names (`_renameToDynamo`
|
|
882
|
+
* runs inside `validateAndBuildPutItem`, so the snapshot inherits the shape
|
|
883
|
+
* that was written), hence the `toDomainView` for sentinel composition — the
|
|
884
|
+
* constraint names domain fields. `put`'s own path composes from `encoded`,
|
|
885
|
+
* which is domain-keyed before the rename.
|
|
812
886
|
*/
|
|
813
887
|
const buildPutSideItems = (item, now, ttlAttrName) => {
|
|
814
888
|
const out = [];
|
|
815
889
|
const pkField = config.indexes.primary.pk.field;
|
|
816
890
|
const skField = config.indexes.primary.sk.field;
|
|
891
|
+
const itemDomain = toDomainView(item);
|
|
817
892
|
if (config.unique != null) {
|
|
818
893
|
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
819
|
-
const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef,
|
|
894
|
+
const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, itemDomain);
|
|
820
895
|
// Sparse: a constraint whose composing fields are unset never had a
|
|
821
896
|
// sentinel, so writing one would reserve `undefined` for everybody.
|
|
822
897
|
if (!sentinel)
|
|
@@ -1605,9 +1680,9 @@ const makeImpl = (config, precomputedData) => {
|
|
|
1605
1680
|
}
|
|
1606
1681
|
const sentinelRotations = [];
|
|
1607
1682
|
if (touchesUniqueFields) {
|
|
1608
|
-
const
|
|
1683
|
+
const currentRawDomain = toDomainView(currentRaw);
|
|
1609
1684
|
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
1610
|
-
const oldSentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef,
|
|
1685
|
+
const oldSentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, currentRawDomain);
|
|
1611
1686
|
const newSentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, newItem);
|
|
1612
1687
|
if (!oldSentinel && !newSentinel)
|
|
1613
1688
|
continue;
|
|
@@ -2352,9 +2427,13 @@ const makeImpl = (config, precomputedData) => {
|
|
|
2352
2427
|
}
|
|
2353
2428
|
// Delete sentinels if not preserving unique. Sparse — fields that were
|
|
2354
2429
|
// unset on the live item never had a sentinel, so nothing to delete.
|
|
2430
|
+
// The stored row is attribute-keyed; the constraint names domain
|
|
2431
|
+
// fields, so compose from the domain view — otherwise a renamed
|
|
2432
|
+
// field reads `undefined` and the sentinel is orphaned (#127).
|
|
2355
2433
|
if (hasUniqueConstraints && !preserveUnique()) {
|
|
2434
|
+
const rawDomain = toDomainView(raw);
|
|
2356
2435
|
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
2357
|
-
const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef,
|
|
2436
|
+
const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, rawDomain);
|
|
2358
2437
|
if (!sentinel)
|
|
2359
2438
|
continue;
|
|
2360
2439
|
transactItems.push({
|
|
@@ -2403,9 +2482,11 @@ const makeImpl = (config, precomputedData) => {
|
|
|
2403
2482
|
}
|
|
2404
2483
|
transactItems.push({ Delete: entityDelete });
|
|
2405
2484
|
// Delete sentinels (sparse — skip constraints whose fields were unset
|
|
2406
|
-
// on the live item; no sentinel was ever written for those)
|
|
2485
|
+
// on the live item; no sentinel was ever written for those). Domain
|
|
2486
|
+
// view: the stored row is attribute-keyed, the constraint is not (#127).
|
|
2487
|
+
const rawDomain = toDomainView(raw);
|
|
2407
2488
|
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
2408
|
-
const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef,
|
|
2489
|
+
const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, rawDomain);
|
|
2409
2490
|
if (!sentinel)
|
|
2410
2491
|
continue;
|
|
2411
2492
|
transactItems.push({
|
|
@@ -3264,6 +3345,11 @@ const makeImpl = (config, precomputedData) => {
|
|
|
3264
3345
|
// verbatim across soft-delete (GSI keys are stripped, sparse data is
|
|
3265
3346
|
// not).
|
|
3266
3347
|
deserializeSparseFields(raw);
|
|
3348
|
+
// `deletedRecordSchema` is keyed by DOMAIN field name, exactly like
|
|
3349
|
+
// `recordSchema` — so the same rename `decodeRecord` does applies here
|
|
3350
|
+
// (#127). Without it a `field:`-renamed attribute decodes as a missing
|
|
3351
|
+
// key and every soft-deleted row of such an entity is unreadable.
|
|
3352
|
+
renameFromDynamo(raw);
|
|
3267
3353
|
const targetSchema = schemas.deletedRecordSchema;
|
|
3268
3354
|
return yield* Schema.decodeUnknownEffect(targetSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
|
|
3269
3355
|
entityType,
|
|
@@ -3280,6 +3366,8 @@ const makeImpl = (config, precomputedData) => {
|
|
|
3280
3366
|
// Sparse fields are domain data and are preserved across soft-delete.
|
|
3281
3367
|
// Rebuild Records from flattened attrs before schema decode.
|
|
3282
3368
|
deserializeSparseFields(raw);
|
|
3369
|
+
// Then attribute → domain names, same order as `decodeRecord` (#127).
|
|
3370
|
+
renameFromDynamo(raw);
|
|
3283
3371
|
return Schema.decodeUnknownEffect(schemas.deletedRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
|
|
3284
3372
|
entityType,
|
|
3285
3373
|
operation: "deleted.list.decode",
|
|
@@ -3351,8 +3439,14 @@ const makeImpl = (config, precomputedData) => {
|
|
|
3351
3439
|
restoredItem[systemFields.version] = newVersion;
|
|
3352
3440
|
if (systemFields.updatedAt)
|
|
3353
3441
|
restoredItem[systemFields.updatedAt] = generateTimestamp(systemFields.updatedAtEncoding, now);
|
|
3442
|
+
// Domain-keyed VIEW of the tombstone, taken once and used for every
|
|
3443
|
+
// read-by-field-name below: key composition, vector-partition
|
|
3444
|
+
// recomposition and unique-sentinel composition. `restoredItem` itself
|
|
3445
|
+
// stays attribute-keyed — it is the row being written back, and
|
|
3446
|
+
// `decodeAs` renames it in place at the end (#127).
|
|
3447
|
+
const restoredDomain = toDomainView(restoredItem);
|
|
3354
3448
|
// Recompose all keys (original SK, GSI keys)
|
|
3355
|
-
const restoredKeys = composeAllKeys(
|
|
3449
|
+
const restoredKeys = composeAllKeys(restoredDomain);
|
|
3356
3450
|
Object.assign(restoredItem, restoredKeys);
|
|
3357
3451
|
// Un-stash the embedding and recompose the vector partition value —
|
|
3358
3452
|
// restoring never costs an Embedder call. See `DESIGN.md §14`.
|
|
@@ -3364,7 +3458,7 @@ const makeImpl = (config, precomputedData) => {
|
|
|
3364
3458
|
}
|
|
3365
3459
|
if (restoredItem[definition.vectorField] === undefined)
|
|
3366
3460
|
continue;
|
|
3367
|
-
const partition = KeyComposer.tryComposeVectorPartition(schema, entityType, definition, keyForm(
|
|
3461
|
+
const partition = KeyComposer.tryComposeVectorPartition(schema, entityType, definition, keyForm(restoredDomain));
|
|
3368
3462
|
if (partition !== undefined)
|
|
3369
3463
|
restoredItem[definition.partitionField] = partition;
|
|
3370
3464
|
}
|
|
@@ -3381,9 +3475,20 @@ const makeImpl = (config, precomputedData) => {
|
|
|
3381
3475
|
Item: marshalledRestoredItem,
|
|
3382
3476
|
},
|
|
3383
3477
|
});
|
|
3384
|
-
// Version snapshot if retain enabled
|
|
3478
|
+
// Version snapshot if retain enabled. The source is the TOMBSTONE, so
|
|
3479
|
+
// the soft-delete markers have to come off first: `buildSnapshotItem`
|
|
3480
|
+
// only ever overrides the TTL attribute when `versioned.ttl` is set, so
|
|
3481
|
+
// a `softDelete: { ttl }` expiry rode along and the snapshot of a
|
|
3482
|
+
// version that is very much still live expired an hour later — while
|
|
3483
|
+
// `deletedAt` made it look like a tombstone. This Put lands on the same
|
|
3484
|
+
// `#v#<version>` SK the delete-time snapshot used (same version — the
|
|
3485
|
+
// restored item is version + 1), so a dirty item here REPLACED the
|
|
3486
|
+
// clean snapshot the delete wrote.
|
|
3385
3487
|
if (isRetainEnabled()) {
|
|
3386
|
-
const
|
|
3488
|
+
const snapshotSource = { ...deletedRaw };
|
|
3489
|
+
delete snapshotSource.deletedAt;
|
|
3490
|
+
delete snapshotSource[ttlAttrName];
|
|
3491
|
+
const snapshotItem = buildSnapshotItem(snapshotSource, currentVersion, primary.pk.field, primary.sk.field, ttlAttrName, now);
|
|
3387
3492
|
transactItems.push({
|
|
3388
3493
|
Put: {
|
|
3389
3494
|
TableName: tableName,
|
|
@@ -3396,10 +3501,11 @@ const makeImpl = (config, precomputedData) => {
|
|
|
3396
3501
|
const sentinelConstraints = [];
|
|
3397
3502
|
if (config.unique && Object.keys(config.unique).length > 0) {
|
|
3398
3503
|
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
3399
|
-
const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef,
|
|
3504
|
+
const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, restoredDomain);
|
|
3400
3505
|
if (!sentinel)
|
|
3401
3506
|
continue;
|
|
3402
3507
|
sentinelConstraints.push(constraintName);
|
|
3508
|
+
const guard = restoreSentinelGuard(restoredKeys[primary.pk.field], restoredKeys[primary.sk.field]);
|
|
3403
3509
|
transactItems.push({
|
|
3404
3510
|
Put: {
|
|
3405
3511
|
TableName: tableName,
|
|
@@ -3410,8 +3516,11 @@ const makeImpl = (config, precomputedData) => {
|
|
|
3410
3516
|
_entity_pk: restoredKeys[primary.pk.field],
|
|
3411
3517
|
_entity_sk: restoredKeys[primary.sk.field],
|
|
3412
3518
|
}),
|
|
3413
|
-
ConditionExpression:
|
|
3414
|
-
ExpressionAttributeNames:
|
|
3519
|
+
ConditionExpression: guard.ConditionExpression,
|
|
3520
|
+
ExpressionAttributeNames: guard.ExpressionAttributeNames,
|
|
3521
|
+
...(guard.ExpressionAttributeValues
|
|
3522
|
+
? { ExpressionAttributeValues: guard.ExpressionAttributeValues }
|
|
3523
|
+
: {}),
|
|
3415
3524
|
},
|
|
3416
3525
|
});
|
|
3417
3526
|
}
|
|
@@ -3435,7 +3544,7 @@ const makeImpl = (config, precomputedData) => {
|
|
|
3435
3544
|
const uniqueFields = constraintDef ? resolveUniqueFields(constraintDef) : [];
|
|
3436
3545
|
const fieldsRecord = {};
|
|
3437
3546
|
for (const f of uniqueFields) {
|
|
3438
|
-
const v =
|
|
3547
|
+
const v = restoredDomain[f];
|
|
3439
3548
|
if (v !== undefined && v !== null) {
|
|
3440
3549
|
fieldsRecord[f] = KeyComposer.serializeValue(v);
|
|
3441
3550
|
}
|
|
@@ -3535,9 +3644,11 @@ const makeImpl = (config, precomputedData) => {
|
|
|
3535
3644
|
}
|
|
3536
3645
|
if (entityItem) {
|
|
3537
3646
|
// Add sentinel keys to delete list (sparse — fields that were unset
|
|
3538
|
-
// never had a sentinel written, so nothing to enqueue)
|
|
3647
|
+
// never had a sentinel written, so nothing to enqueue). The stored
|
|
3648
|
+
// row is attribute-keyed, the constraint is not (#127).
|
|
3649
|
+
const entityItemDomain = toDomainView(entityItem);
|
|
3539
3650
|
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
3540
|
-
const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef,
|
|
3651
|
+
const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, entityItemDomain);
|
|
3541
3652
|
if (!sentinel)
|
|
3542
3653
|
continue;
|
|
3543
3654
|
allItems.push(toAttributeMap({
|