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/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
- * `lookup` overrides the field-value reader. The update path passes a lookup that
48
- * falls back from the domain field name to the renamed DB column name, since
49
- * `currentRaw` (read from DynamoDB) carries DB names rather than domain names.
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, lookup) => {
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 = get(source, f);
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, item);
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 currentRawRecord = currentRaw;
1683
+ const currentRawDomain = toDomainView(currentRaw);
1609
1684
  for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
1610
- const oldSentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, currentRawRecord, (s, f) => s[f] ?? s[resolveDbName(f)]);
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, raw);
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, raw);
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(restoredItem);
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(restoredItem));
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 snapshotItem = buildSnapshotItem(deletedRaw, currentVersion, primary.pk.field, primary.sk.field, ttlAttrName, now);
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, restoredItem);
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: "attribute_not_exists(#pk)",
3414
- ExpressionAttributeNames: { "#pk": primary.pk.field },
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 = restoredItem[f];
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, entityItem);
3651
+ const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, entityItemDomain);
3541
3652
  if (!sentinel)
3542
3653
  continue;
3543
3654
  allItems.push(toAttributeMap({