effect-dynamodb 1.22.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/Aggregate.d.ts +9 -4
  2. package/dist/Aggregate.d.ts.map +1 -1
  3. package/dist/Aggregate.js +644 -219
  4. package/dist/Aggregate.js.map +1 -1
  5. package/dist/Batch.d.ts +7 -0
  6. package/dist/Batch.d.ts.map +1 -1
  7. package/dist/Batch.js +132 -7
  8. package/dist/Batch.js.map +1 -1
  9. package/dist/Collection.d.ts.map +1 -1
  10. package/dist/Collection.js +13 -31
  11. package/dist/Collection.js.map +1 -1
  12. package/dist/DynamoClient.d.ts +40 -3
  13. package/dist/DynamoClient.d.ts.map +1 -1
  14. package/dist/DynamoClient.js +70 -19
  15. package/dist/DynamoClient.js.map +1 -1
  16. package/dist/Entity.d.ts +138 -55
  17. package/dist/Entity.d.ts.map +1 -1
  18. package/dist/Entity.js +3061 -626
  19. package/dist/Entity.js.map +1 -1
  20. package/dist/EventStore.d.ts +15 -6
  21. package/dist/EventStore.d.ts.map +1 -1
  22. package/dist/EventStore.js +169 -148
  23. package/dist/EventStore.js.map +1 -1
  24. package/dist/Query.d.ts +75 -3
  25. package/dist/Query.d.ts.map +1 -1
  26. package/dist/Query.js +228 -30
  27. package/dist/Query.js.map +1 -1
  28. package/dist/Transaction.d.ts +13 -2
  29. package/dist/Transaction.d.ts.map +1 -1
  30. package/dist/Transaction.js +48 -38
  31. package/dist/Transaction.js.map +1 -1
  32. package/dist/index.d.ts +3 -3
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/internal/BoundCrud.d.ts +31 -20
  37. package/dist/internal/BoundCrud.d.ts.map +1 -1
  38. package/dist/internal/BoundCrud.js +1 -1
  39. package/dist/internal/BoundCrud.js.map +1 -1
  40. package/dist/internal/BoundQuery.d.ts +47 -2
  41. package/dist/internal/BoundQuery.d.ts.map +1 -1
  42. package/dist/internal/BoundQuery.js +182 -18
  43. package/dist/internal/BoundQuery.js.map +1 -1
  44. package/dist/internal/EntityCombinators.d.ts +32 -10
  45. package/dist/internal/EntityCombinators.d.ts.map +1 -1
  46. package/dist/internal/EntityCombinators.js +16 -7
  47. package/dist/internal/EntityCombinators.js.map +1 -1
  48. package/dist/internal/EntityOps.d.ts +44 -10
  49. package/dist/internal/EntityOps.d.ts.map +1 -1
  50. package/dist/internal/EntityOps.js +48 -5
  51. package/dist/internal/EntityOps.js.map +1 -1
  52. package/dist/internal/Expr.d.ts +23 -0
  53. package/dist/internal/Expr.d.ts.map +1 -1
  54. package/dist/internal/Expr.js +84 -9
  55. package/dist/internal/Expr.js.map +1 -1
  56. package/dist/internal/ItemSize.d.ts +38 -0
  57. package/dist/internal/ItemSize.d.ts.map +1 -0
  58. package/dist/internal/ItemSize.js +62 -0
  59. package/dist/internal/ItemSize.js.map +1 -0
  60. package/dist/internal/TransactWriteOps.d.ts +112 -19
  61. package/dist/internal/TransactWriteOps.d.ts.map +1 -1
  62. package/dist/internal/TransactWriteOps.js +210 -33
  63. package/dist/internal/TransactWriteOps.js.map +1 -1
  64. package/dist/internal/TransactableOps.d.ts +33 -14
  65. package/dist/internal/TransactableOps.d.ts.map +1 -1
  66. package/dist/internal/TransactableOps.js +46 -19
  67. package/dist/internal/TransactableOps.js.map +1 -1
  68. package/package.json +2 -2
package/dist/Entity.js CHANGED
@@ -6,31 +6,31 @@
6
6
  * carries derived schemas for 7 type extractors (Model, Record, Input, Update,
7
7
  * Key, Item, Marshalled).
8
8
  */
9
- import { getIdentifierField, getSparseFields, isRefField, } from "@effect-dynamodb/schema/DynamoModel.js";
9
+ import { getIdentifierField, getSparseFields, isConfiguredModel, isRefField, } from "@effect-dynamodb/schema/DynamoModel.js";
10
10
  import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
11
11
  import { Embedder } from "@effect-dynamodb/schema/Embedder.js";
12
- import { CascadePartialFailure, ConditionalCheckFailed, EmbeddingError, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled, OptimisticLockError, RefNotFound, StaleAppend, TransactionOverflow, UniqueConstraintViolation, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
12
+ import { CascadePartialFailure, ConcurrentModification, ConditionalCheckFailed, DeleteAppliedButUnreadable, EmbeddingError, ItemNotDeleted, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled, OptimisticLockError, RefNotFound, StaleAppend, TransactionOverflow, UniqueConstraintViolation, UpdateAppliedButUnreadable, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
13
13
  import { makeCompositeKeyForm, toCompositeKeyRecord, } from "@effect-dynamodb/schema/internal/CompositeCodec.js";
14
14
  import { makeDefaultCrypto } from "@effect-dynamodb/schema/internal/DefaultCrypto.js";
15
15
  import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
16
16
  import { normalizeGsiConfig } from "@effect-dynamodb/schema/KeyComposer.js";
17
17
  import { deriveSourceText } from "@effect-dynamodb/schema/VectorIndex.js";
18
- import { Context, Crypto, DateTime, Effect, Option, Schema, Stream } from "effect";
18
+ import { Cause, Context, Crypto, Data, DateTime, Effect, Equal, Option, Schema, Stream, } from "effect";
19
19
  import { DynamoClient } from "./DynamoClient.js";
20
20
  import { isBoundOp, makeBoundAppend, makeBoundDelete, makeBoundGet, makeBoundPut, makeBoundUpdate, } from "./internal/BoundCrud.js";
21
- import { BoundQueryImpl, } from "./internal/BoundQuery.js";
22
- import { compileExpr, createConditionOps, isExpr, parseShorthand, parseSimpleShorthand, } from "./internal/Expr.js";
21
+ import { BoundQueryImpl, entityNaming, } from "./internal/BoundQuery.js";
22
+ import { compileExpr, createConditionOps, emptyPartProblem, isExpr, nonEmptyCondition, parseShorthand, parseSimpleShorthand, toExpr, } from "./internal/Expr.js";
23
23
  import { compilePath, createPathBuilder } from "./internal/PathBuilder.js";
24
- import { generateTimestampPrimitive } from "./internal/TransactableOps.js";
24
+ import { freshIncarnationToken, generateTimestampPrimitive, INCARNATION_TOKEN, UNSENTINELED_DEFAULTS, } from "./internal/TransactableOps.js";
25
25
  import { decodeSparseFields, encodeSparseFields, fromAttributeMap, toAttributeMap, toAttributeValue, } from "./Marshaller.js";
26
26
  import * as Query from "./Query.js";
27
27
  import { filterExpr, selectPaths } from "./Query.js";
28
28
  import { resolveTtlAttributeName } from "./Table.js";
29
29
  export { EntityDeleteImpl, EntityDeleteTypeId, EntityGetImpl, EntityOpTypeId, EntityPutImpl, EntityUpdateImpl, EntityUpdateTypeId, emptyUpdateState, returnValuesMap, } from "./internal/EntityOps.js";
30
30
  import * as Projection from "@effect-dynamodb/schema/Projection.js";
31
- import { EntityDeleteImpl, EntityDeleteTypeId, EntityGetImpl, EntityOpTypeId, EntityPutImpl, EntityUpdateImpl, emptyUpdateState, returnValuesMap, } from "./internal/EntityOps.js";
31
+ import { EntityDeleteImpl, EntityDeleteTypeId, EntityGetImpl, EntityOpTypeId, EntityPutImpl, EntityUpdateImpl, emptyUpdateState, withGuard, } from "./internal/EntityOps.js";
32
32
  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";
33
- import { allCompositeAttributes, allKeyFieldNames, getSchemaFields, primaryKeyComposites, resolveUniqueFields, } from "@effect-dynamodb/schema/internal/EntitySchemas.js";
33
+ import { allCompositeAttributes, allKeyFieldNames, getSchemaFields, makePathValueEncoder, primaryKeyComposites, resolveUniqueFields, } from "@effect-dynamodb/schema/internal/EntitySchemas.js";
34
34
  import { asModel, condition as conditionCombinator, expectedVersion, set, } from "./internal/EntityCombinators.js";
35
35
  // ---------------------------------------------------------------------------
36
36
  // Sparse-aware unique sentinel composition
@@ -59,6 +59,13 @@ import { buildEntityDefinition, normalizeTtlSeconds, resolveUniqueTtl, } from "@
59
59
  export { normalizeTtlSeconds };
60
60
  const composeUniqueSentinel = (schema, entityType, constraintName, constraintDef, source) => {
61
61
  const fields = resolveUniqueFields(constraintDef);
62
+ // A field holding only a default has no sentinel (#133).
63
+ const defaulted = source[UNSENTINELED_DEFAULTS];
64
+ const defaultedFields = defaulted instanceof Set
65
+ ? defaulted
66
+ : new Set(Array.isArray(defaulted) ? defaulted : []);
67
+ if (fields.some((f) => defaultedFields.has(f)))
68
+ return undefined;
62
69
  const serialized = [];
63
70
  const fieldsRecord = {};
64
71
  for (const f of fields) {
@@ -118,6 +125,142 @@ const keyFormFor = (target, record) => {
118
125
  });
119
126
  return toCompositeKeyRecord(form, record);
120
127
  };
128
+ /**
129
+ * @internal A plain update of a complete item found no item: the caller
130
+ * creates it instead (`updateOrCreate`). Never escapes the update operation.
131
+ */
132
+ class MissingForCreate extends Data.TaggedError("MissingForCreate") {
133
+ }
134
+ /** DynamoDB's limits on one expression: its length, and its operators + functions. */
135
+ const EXPRESSION_LIMIT = 4096;
136
+ const OPERATOR_LIMIT = 300;
137
+ /**
138
+ * The tokens of a compiled expression. Names and values are placeholders
139
+ * (`#…`, `:…`), so no operator can hide inside one.
140
+ */
141
+ const EXPRESSION_TOKEN = /#\w+|:\w+|<>|<=|>=|[=<>()+\-,.[\]]|[A-Za-z_]\w*|\d+/g;
142
+ /**
143
+ * The operators DynamoDB counts against an expression's 300 (#133), measured
144
+ * against DynamoDB: in a condition, each comparison (`=`, `<>`, `<`, `<=`,
145
+ * `>`, `>=`), `AND` / `OR` / `NOT`, `BETWEEN` (whose own `AND` is part of it,
146
+ * not another operator), `IN`, and each function; in an update, each `+` /
147
+ * `-` and each function (`if_not_exists`, `list_append`) — a `SET` clause's
148
+ * `=` is not an operator.
149
+ */
150
+ /** @internal */
151
+ export const countOperators = (expression, kind) => {
152
+ const tokens = expression.match(EXPRESSION_TOKEN) ?? [];
153
+ let count = 0;
154
+ let betweens = 0;
155
+ for (const [i, token] of tokens.entries()) {
156
+ const word = token.toUpperCase();
157
+ const isFunction = /^[A-Za-z_]\w*$/.test(token) && tokens[i + 1] === "(";
158
+ if (kind === "update") {
159
+ if (token === "+" || token === "-" || isFunction)
160
+ count++;
161
+ continue;
162
+ }
163
+ if (isFunction)
164
+ count++;
165
+ else if (["=", "<>", "<", "<=", ">", ">="].includes(token))
166
+ count++;
167
+ else if (word === "BETWEEN") {
168
+ count++;
169
+ betweens++;
170
+ }
171
+ else if (word === "AND" && betweens > 0)
172
+ betweens--;
173
+ else if (word === "AND" || word === "OR" || word === "NOT" || word === "IN")
174
+ count++;
175
+ }
176
+ return count;
177
+ };
178
+ /** What DynamoDB counts against an expression's limits. */
179
+ const expressionCost = (expression, kind = "condition") => expression === undefined
180
+ ? { length: 0, operators: 0 }
181
+ : { length: expression.length, operators: countOperators(expression, kind) };
182
+ const expressionFits = (expression, kind = "condition") => {
183
+ const cost = expressionCost(expression, kind);
184
+ return cost.length <= EXPRESSION_LIMIT && cost.operators <= OPERATOR_LIMIT;
185
+ };
186
+ const bytesKey = (bytes) => Array.from(bytes).join(",");
187
+ /** Structural equality of two marshalled values (sets compare as sets). */
188
+ const attributeValueEquals = (a, b) => {
189
+ if (a === undefined || b === undefined)
190
+ return a === b;
191
+ const [kind] = Object.keys(a);
192
+ if (kind === undefined || Object.keys(b)[0] !== kind)
193
+ return false;
194
+ const x = a[kind];
195
+ const y = b[kind];
196
+ switch (kind) {
197
+ case "L": {
198
+ const xs = x;
199
+ const ys = y;
200
+ return xs.length === ys.length && xs.every((v, i) => attributeValueEquals(v, ys[i]));
201
+ }
202
+ case "M": {
203
+ const xm = x;
204
+ const ym = y;
205
+ const keys = Object.keys(xm);
206
+ return (keys.length === Object.keys(ym).length &&
207
+ keys.every((k) => attributeValueEquals(xm[k], ym[k])));
208
+ }
209
+ case "SS":
210
+ case "NS": {
211
+ const xs = new Set(x);
212
+ const ys = y;
213
+ return xs.size === new Set(ys).size && ys.every((v) => xs.has(v));
214
+ }
215
+ case "B":
216
+ return bytesKey(x) === bytesKey(y);
217
+ case "BS": {
218
+ const xs = new Set(x.map(bytesKey));
219
+ const ys = y.map(bytesKey);
220
+ return xs.size === new Set(ys).size && ys.every((v) => xs.has(v));
221
+ }
222
+ default:
223
+ return x === y;
224
+ }
225
+ };
226
+ /**
227
+ * The top-level attributes an UpdateExpression writes (SET / REMOVE / ADD /
228
+ * DELETE targets), resolved through its attribute names.
229
+ */
230
+ const expressionTargets = (expression, names) => {
231
+ const targets = new Set();
232
+ const sections = expression.split(/\b(SET|REMOVE|ADD|DELETE)\b/).slice(1);
233
+ for (let i = 0; i + 1 < sections.length; i += 2) {
234
+ const action = sections[i];
235
+ const body = sections[i + 1];
236
+ // Split on commas outside parentheses (`list_append(#a, :b)`).
237
+ const items = [];
238
+ let depth = 0;
239
+ let current = "";
240
+ for (const ch of body) {
241
+ if (ch === "(")
242
+ depth++;
243
+ if (ch === ")")
244
+ depth--;
245
+ if (ch === "," && depth === 0) {
246
+ items.push(current);
247
+ current = "";
248
+ }
249
+ else
250
+ current += ch;
251
+ }
252
+ items.push(current);
253
+ for (const item of items) {
254
+ const trimmed = item.trim();
255
+ if (trimmed === "")
256
+ continue;
257
+ const path = action === "SET" ? trimmed.split("=")[0].trim() : trimmed.split(/\s+/)[0];
258
+ const head = path.split(/[.[]/)[0];
259
+ targets.add(head.startsWith("#") ? (names[head] ?? head) : head);
260
+ }
261
+ }
262
+ return targets;
263
+ };
121
264
  const encodeOrDecodeEncode = (schema, input, entityType, operation) => Schema.encodeUnknownEffect(schema)(input).pipe(Effect.catch((primaryCause) => Schema.decodeUnknownEffect(schema)(input).pipe(Effect.flatMap((decoded) => Schema.encodeUnknownEffect(schema)(decoded)),
122
265
  // On fallback failure, surface the original encode error — its
123
266
  // message is keyed on the caller's input shape, which is what the
@@ -150,6 +293,28 @@ const checkTransactionLimit = (entityType, operation, items) => items.length > T
150
293
  limit: TRANSACTION_LIMIT,
151
294
  }))
152
295
  : Effect.void;
296
+ /** Attempts a guarded put makes before it reports a lost race (#133). */
297
+ const GUARDED_PUT_ATTEMPTS = 3;
298
+ /**
299
+ * @internal A delete's item was deleted between its read and its write, with
300
+ * nothing asserted about it: read again, never reported (#133).
301
+ */
302
+ class DeletedConcurrently extends Data.TaggedError("DeletedConcurrently") {
303
+ }
304
+ /**
305
+ * The `ConcurrentModification`s that report a sentinel release whose
306
+ * reservation changed hands between the ownership read and the write (#133),
307
+ * as opposed to a change of the item itself. Nothing was written, and the
308
+ * write is still valid against a fresh read — so an `upsert` plans it again,
309
+ * as a put does. Carried across the copies that fill in `current`.
310
+ */
311
+ const releaseRaces = new WeakSet();
312
+ /** `next`, a copy of `previous`, marked as a release race if `previous` was. */
313
+ const keepReleaseRace = (previous, next) => {
314
+ if (releaseRaces.has(previous))
315
+ releaseRaces.add(next);
316
+ return next;
317
+ };
153
318
  /**
154
319
  * Create an Entity definition.
155
320
  *
@@ -266,12 +431,287 @@ const makeImpl = (config, precomputedData) => {
266
431
  // the runtime Entity carries, so the cast is sound.
267
432
  const data = precomputedData ?? buildEntityDefinition(config);
268
433
  const { systemFields, schemas, hasRefs, immutableFields, resolvedIdentifier, generatedIdField, generatedIdVersion, entityType, entityVersion, sparseFields, hasSparseFields, renameToDynamo, renameFromDynamo, resolveDbName, rawModel, isSchemaClass, hasHiddenFields, } = data;
434
+ // Encoders for path-addressed update values (`pathSet`, `pathAppend`, …) and
435
+ // record-based `append`, which bypass the update schema (#133).
436
+ const pathValues = makePathValueEncoder(data.modelFields, schemas.writeModelSchema.fields,
437
+ // A `DynamoModel.ref` field's own schema is opaque; paths under it follow
438
+ // the model it is denormalised from.
439
+ Object.fromEntries(data.resolvedRefs
440
+ .filter((ref) => ref.refEntity?.model !== undefined)
441
+ .map((ref) => {
442
+ const model = ref.refEntity.model;
443
+ return [
444
+ ref.fieldName,
445
+ isConfiguredModel(model) ? model.model : model,
446
+ ];
447
+ })));
448
+ /**
449
+ * Encode a path-addressed update value and check it against the write schema
450
+ * at its path — container checks included (#133). `undefined` issue = valid.
451
+ */
452
+ const encodePathValue = (segments, value, kind) => {
453
+ if (kind === "value") {
454
+ const encoded = pathValues.value(segments, value);
455
+ return { encoded, issue: pathValues.validate(segments, encoded) };
456
+ }
457
+ const encoded = pathValues.elements(segments, value);
458
+ return { encoded, issue: pathValues.validateElements(segments, encoded) };
459
+ };
460
+ const hasPathOps = (uState) => [
461
+ uState.pathSets,
462
+ uState.pathRemoves,
463
+ uState.pathAdds,
464
+ uState.pathSubtracts,
465
+ uState.pathAppends,
466
+ uState.pathPrepends,
467
+ uState.pathIfNotExists,
468
+ uState.pathDeletes,
469
+ ].some((ops) => ops !== undefined && ops.length > 0);
269
470
  // resolvedRefs carries the actual ref-target entity objects; at runtime they
270
471
  // are operational Entities (for runtime-authored refs) so write-time hydration
271
472
  // can call their CRUD ops. The pure bundle widens refEntity to EntityDefinition.
272
473
  const resolvedRefs = data.resolvedRefs;
273
474
  // allIndexes is mutable: _injectIndex adds collection-owned GSIs after make().
274
475
  let allIndexes = { ...data.initialIndexes };
476
+ /** Composites of the secondary indexes (`allIndexes` grows after make()). */
477
+ const indexCompositeFields = () => new Set(Object.entries(allIndexes)
478
+ .filter(([name]) => name !== "primary")
479
+ .flatMap(([, def]) => [...def.pk.composite, ...def.sk.composite]));
480
+ /**
481
+ * Why `field` feeds a key or a unique sentinel — values the library derives
482
+ * from the field at write time — or `undefined` when it feeds neither.
483
+ */
484
+ const derivedFrom = (field) => {
485
+ const primary = allIndexes.primary;
486
+ if (primary && [...primary.pk.composite, ...primary.sk.composite].includes(field)) {
487
+ return "a primary-key composite";
488
+ }
489
+ for (const [name, def] of Object.entries(allIndexes)) {
490
+ if (name === "primary")
491
+ continue;
492
+ if (def.pk.composite.includes(field) || def.sk.composite.includes(field)) {
493
+ return `a composite of index "${name}"`;
494
+ }
495
+ }
496
+ for (const [name, def] of Object.entries((config.unique ?? {}))) {
497
+ if (resolveUniqueFields(def).includes(field))
498
+ return `a field of unique constraint "${name}"`;
499
+ }
500
+ return undefined;
501
+ };
502
+ /**
503
+ * Path operations on a field that feeds a key or a unique sentinel (#133).
504
+ *
505
+ * DynamoDB evaluates a path operation, so compiling one on such a field would
506
+ * change the attribute while its index keys and sentinel stay as they were.
507
+ * A top-level `pathSet` of a value / `pathRemove` IS `.set()` / `.remove()`
508
+ * of that field, and `pathAdd` / `pathSubtract` of a number IS `.add()` /
509
+ * `.subtract()`: they are rewritten into those record operations, which go
510
+ * through the key composer and the sentinel rotation. Anything whose result
511
+ * only DynamoDB knows (a copy from another attribute, `if_not_exists`, list
512
+ * and set operations) is refused, as is a path below such a field, a
513
+ * primary-key composite (it identifies the item), an immutable field, and a
514
+ * field targeted twice. Returns the rewritten state or the refusal.
515
+ */
516
+ const normalizeDerivedPathOps = (uState) => {
517
+ if (!hasPathOps(uState))
518
+ return uState;
519
+ const updates = {
520
+ ...(uState.updates ?? {}),
521
+ };
522
+ const remove = [...(uState.remove ?? [])];
523
+ const add = { ...uState.add };
524
+ const subtract = { ...uState.subtract };
525
+ const targeted = new Set([
526
+ ...Object.keys(updates),
527
+ ...remove,
528
+ ...Object.keys(add),
529
+ ...Object.keys(subtract),
530
+ ...Object.keys(uState.append ?? {}),
531
+ ...Object.keys(uState.deleteFromSet ?? {}),
532
+ ]);
533
+ let refusal;
534
+ let rewritten = false;
535
+ const shown = (segments) => JSON.stringify(segments.map((s, i) => (typeof s === "number" ? `[${s}]` : i === 0 ? s : `.${s}`)).join(""));
536
+ // The field a path operation addresses when it feeds a key or sentinel,
537
+ // "free" when it feeds neither, or "refused" (recorded) for the shapes
538
+ // that can never be rewritten.
539
+ const claim = (op, segments) => {
540
+ const first = segments[0];
541
+ if (typeof first !== "string")
542
+ return "free";
543
+ const head = first;
544
+ const reason = derivedFrom(head);
545
+ if (reason === undefined)
546
+ return "free";
547
+ if (segments.length > 1) {
548
+ refusal ??=
549
+ `${op} on ${shown(segments)}: "${head}" is ${reason}, so a path below it cannot be ` +
550
+ `updated. Write the whole value with .set({ ${head} }).`;
551
+ }
552
+ else if (reason === "a primary-key composite") {
553
+ refusal ??=
554
+ `${op} on "${head}": "${head}" is a primary-key composite — it identifies the item ` +
555
+ "and cannot be changed by an update.";
556
+ }
557
+ else if (immutableFields.has(head)) {
558
+ refusal ??= `${op} on "${head}": "${head}" is immutable and cannot be changed by an update.`;
559
+ }
560
+ else if (targeted.has(head)) {
561
+ refusal ??=
562
+ `${op} on "${head}": "${head}" is ${reason} and is already targeted by another ` +
563
+ "operation in this update. Give it one operation.";
564
+ }
565
+ else {
566
+ targeted.add(head);
567
+ return { field: head, reason };
568
+ }
569
+ return "refused";
570
+ };
571
+ const serverSide = (op, field, reason) => {
572
+ refusal ??=
573
+ `${op} on "${field}": "${field}" is ${reason}. DynamoDB computes this operation's ` +
574
+ `result at write time, so the key or sentinel derived from "${field}" could not be ` +
575
+ `recomposed to match. Read the item and write the new value with .set({ ${field} }).`;
576
+ };
577
+ const keep = (ops, segmentsOf, name, rewrite) => {
578
+ if (ops === undefined)
579
+ return undefined;
580
+ const kept = [];
581
+ for (const op of ops) {
582
+ const claimed = claim(name, segmentsOf(op));
583
+ if (claimed === "free")
584
+ kept.push(op);
585
+ if (typeof claimed === "string")
586
+ continue;
587
+ rewritten = true;
588
+ rewrite(op, claimed.field, claimed.reason);
589
+ }
590
+ return kept;
591
+ };
592
+ const pathSets = keep(uState.pathSets, (op) => op.segments, "pathSet", (op, field, reason) => {
593
+ if (op.isPath)
594
+ serverSide("pathSet (a copy of another attribute)", field, reason);
595
+ else
596
+ updates[field] = op.value;
597
+ });
598
+ const pathRemoves = keep(uState.pathRemoves, (segments) => segments, "pathRemove", (_, field) => {
599
+ remove.push(field);
600
+ });
601
+ const pathAdds = keep(uState.pathAdds, (op) => op.segments, "pathAdd", (op, field, reason) => {
602
+ if (typeof op.value === "number")
603
+ add[field] = op.value;
604
+ else
605
+ serverSide("pathAdd (to a set)", field, reason);
606
+ });
607
+ const pathSubtracts = keep(uState.pathSubtracts, (op) => op.segments, "pathSubtract", (op, field, reason) => {
608
+ if (!op.isPath && typeof op.value === "number")
609
+ subtract[field] = op.value;
610
+ else
611
+ serverSide("pathSubtract (of another attribute)", field, reason);
612
+ });
613
+ const refuseAll = (name) => (_, field, reason) => serverSide(name, field, reason);
614
+ const pathAppends = keep(uState.pathAppends, (op) => op.segments, "pathAppend", refuseAll("pathAppend"));
615
+ const pathPrepends = keep(uState.pathPrepends, (op) => op.segments, "pathPrepend", refuseAll("pathPrepend"));
616
+ const pathIfNotExists = keep(uState.pathIfNotExists, (op) => op.segments, "pathIfNotExists", refuseAll("pathIfNotExists"));
617
+ const pathDeletes = keep(uState.pathDeletes, (op) => op.segments, "pathDelete", refuseAll("pathDelete"));
618
+ if (refusal !== undefined)
619
+ return refusal;
620
+ if (!rewritten)
621
+ return uState;
622
+ return {
623
+ ...uState,
624
+ updates: Object.keys(updates).length > 0 ? updates : uState.updates,
625
+ remove: remove.length > 0 ? remove : uState.remove,
626
+ add: Object.keys(add).length > 0 ? add : uState.add,
627
+ subtract: Object.keys(subtract).length > 0 ? subtract : uState.subtract,
628
+ pathSets,
629
+ pathRemoves,
630
+ pathAdds,
631
+ pathSubtracts,
632
+ pathAppends,
633
+ pathPrepends,
634
+ pathIfNotExists,
635
+ pathDeletes,
636
+ };
637
+ };
638
+ /**
639
+ * A `.set()` of a primary-key composite with a value other than the key's
640
+ * (#133): it identifies the item and cannot change. It was silently dropped;
641
+ * the update types already exclude it. The key's own value is a no-op, so a
642
+ * spread record keeps working.
643
+ */
644
+ const refusedRecordSet = (updates, key) => {
645
+ if (typeof updates !== "object" || updates === null)
646
+ return undefined;
647
+ const record = updates;
648
+ const keyRecord = (key ?? {});
649
+ const primary = allIndexes.primary;
650
+ for (const field of primary ? [...primary.pk.composite, ...primary.sk.composite] : []) {
651
+ if (record[field] === undefined || Equal.equals(record[field], keyRecord[field]))
652
+ continue;
653
+ return (`.set() of "${field}": "${field}" is a primary-key composite — it identifies the item ` +
654
+ "and cannot be changed by an update.");
655
+ }
656
+ return undefined;
657
+ };
658
+ /**
659
+ * The immutable fields a `.set()` names, in stored form (#133). An update may
660
+ * restate an immutable field's value — a spread record does — but never
661
+ * change it: each one is checked against the item (read, or a condition on
662
+ * the write) and a different value is refused.
663
+ */
664
+ const assertedImmutables = (updates) => {
665
+ if (typeof updates !== "object" || updates === null)
666
+ return [];
667
+ const record = updates;
668
+ const out = [];
669
+ for (const field of immutableFields) {
670
+ if (record[field] === undefined)
671
+ continue;
672
+ const encoded = encodePathValue([field], record[field], "value");
673
+ if (encoded.issue !== undefined) {
674
+ return `.set() of immutable "${field}": ${String(encoded.issue)}`;
675
+ }
676
+ out.push({ field, attr: resolveDbName(field), value: toAttributeValue(encoded.encoded) });
677
+ }
678
+ return out;
679
+ };
680
+ const immutableMismatch = (asserted, stored) => {
681
+ const changed = asserted.find(({ attr, value }) => !attributeValueEquals(stored[attr], value));
682
+ return changed === undefined
683
+ ? undefined
684
+ : `.set() of "${changed.field}": "${changed.field}" is immutable and the update's value ` +
685
+ "differs from the stored one.";
686
+ };
687
+ /**
688
+ * Whether an update can safely create the item it names (#133): its payload
689
+ * supplies every required model field and every primary-key composite, so
690
+ * the item it writes decodes. Anything less on a missing item would leave a
691
+ * partial row behind — those updates require the item to exist.
692
+ */
693
+ const completeUpsertPayload = (updates) => {
694
+ if (typeof updates !== "object" || updates === null)
695
+ return false;
696
+ const record = updates;
697
+ const present = (field) => record[field] !== undefined && record[field] !== null;
698
+ const primary = allIndexes.primary;
699
+ if (primary && ![...primary.pk.composite, ...primary.sk.composite].every(present))
700
+ return false;
701
+ for (const [field, fieldSchema] of Object.entries(data.modelFields)) {
702
+ const ast = fieldSchema.ast;
703
+ if (ast.context?.isOptional === true)
704
+ continue;
705
+ // A decoding default (optional on the stored side): `put` may omit it too.
706
+ if (ast.encoding?.[ast.encoding.length - 1]?.to.context?.isOptional === true)
707
+ continue;
708
+ const ref = resolvedRefs.find((r) => r.fieldName === field);
709
+ if (present(field) || (ref !== undefined && present(ref.idFieldName)))
710
+ continue;
711
+ return false;
712
+ }
713
+ return true;
714
+ };
275
715
  // schema + tableTag are injected via _configure() when the entity is registered
276
716
  // on a Table and bound through DynamoClient.make(); captured by operation closures.
277
717
  let schema;
@@ -430,9 +870,115 @@ const makeImpl = (config, precomputedData) => {
430
870
  // put error channel.
431
871
  Effect.orDie);
432
872
  };
873
+ /**
874
+ * The model fields with a decoding default (`Schema.withDecodingDefault` and
875
+ * kin: optional on the stored side, required on the domain side).
876
+ */
877
+ const defaultedFields = Object.entries(data.modelFields).filter(([, field]) => {
878
+ const ast = field.ast;
879
+ return (ast.context?.isOptional !== true &&
880
+ ast.encoding?.[ast.encoding.length - 1]?.to.context?.isOptional === true);
881
+ });
882
+ /**
883
+ * Fill each omitted decoding-default field that an INDEX key derives from —
884
+ * a primary-key, GSI or LSI composite — with its default (#133), so the item
885
+ * stores it, its keys compose from it, and the item, its keys and its decoded
886
+ * read all agree. Left out, the item had no index keys while its decoded
887
+ * record named a value the index is keyed by. A default never creates a
888
+ * unique sentinel (`unsentineledDefaults`); other defaulted fields keep
889
+ * their contract: not stored, defaulted on read.
890
+ */
891
+ /** The defaulted fields an index key derives from — materialised when omitted. */
892
+ const indexedDefaultFields = () => {
893
+ const composites = new Set(Object.values(allIndexes).flatMap((def) => [...def.pk.composite, ...def.sk.composite]));
894
+ return new Set(defaultedFields.map(([name]) => name).filter((name) => composites.has(name)));
895
+ };
896
+ /**
897
+ * The unique-constraint fields a write's input omits but stores a default
898
+ * for (an index composite): the item lists them in `__edd_d__`, and no
899
+ * sentinel is composed from them — a default never creates a sentinel.
900
+ */
901
+ const unsentineledDefaults = (input) => {
902
+ if (config.unique == null || typeof input !== "object" || input === null)
903
+ return [];
904
+ const record = input;
905
+ const unique = new Set(Object.values(config.unique).flatMap((def) => [...resolveUniqueFields(def)]));
906
+ return [...indexedDefaultFields()].filter((name) => unique.has(name) && record[name] === undefined);
907
+ };
908
+ /**
909
+ * An update's `.remove()` of a defaulted index composite: the field would
910
+ * read back as its default while its index keys were dropped. It becomes a
911
+ * `.set()` of the default instead (#133) — stored, keys recomposed from it —
912
+ * and the fields so defaulted are returned (a unique one then holds only a
913
+ * default: `__edd_d__`).
914
+ */
915
+ const rematerializeRemovedDefaults = (uState) => {
916
+ const indexed = indexedDefaultFields();
917
+ const removed = (uState.remove ?? []).filter((field) => indexed.has(field));
918
+ if (removed.length === 0)
919
+ return Effect.succeed({ state: uState, defaulted: [] });
920
+ const fields = defaultedFields.filter(([name]) => removed.includes(name));
921
+ return Schema.decodeUnknownEffect(Schema.Struct(Object.fromEntries(fields)))({}).pipe(Effect.map((defaults) => ({
922
+ state: {
923
+ ...uState,
924
+ remove: (uState.remove ?? []).filter((field) => !removed.includes(field)),
925
+ updates: {
926
+ ...(uState.updates ?? {}),
927
+ ...defaults,
928
+ },
929
+ },
930
+ defaulted: removed,
931
+ })), Effect.mapError((cause) => new ValidationError({ entityType, operation: "update.default", cause })));
932
+ };
933
+ const fillDecodingDefaults = (input) => {
934
+ if (defaultedFields.length === 0 || typeof input !== "object" || input === null) {
935
+ return Effect.succeed(input);
936
+ }
937
+ const record = input;
938
+ const keyed = indexedDefaultFields();
939
+ const missing = defaultedFields.filter(([name]) => keyed.has(name) && record[name] === undefined);
940
+ if (missing.length === 0)
941
+ return Effect.succeed(input);
942
+ return Schema.decodeUnknownEffect(Schema.Struct(Object.fromEntries(missing)))({}).pipe(Effect.map((defaults) => ({ ...record, ...defaults })), Effect.mapError((cause) => new ValidationError({ entityType, operation: "put.default", cause })));
943
+ };
944
+ /** Whether `input` omits a defaulted field an index key derives from (see `fillDecodingDefaults`). */
945
+ const omitsIndexedDefault = (input) => {
946
+ if (defaultedFields.length === 0 || typeof input !== "object" || input === null)
947
+ return false;
948
+ const record = input;
949
+ return [...indexedDefaultFields()].some((name) => record[name] === undefined);
950
+ };
433
951
  /** Attach the model class prototype to a decoded plain object (when model is Schema.Class). */
434
952
  const attachPrototype = (decoded) => isSchemaClass ? Object.assign(Object.create(rawModel.prototype), decoded) : decoded;
953
+ /**
954
+ * An item of a versioned entity with an incarnation token but no version
955
+ * (#133). A versioned entity stamps the token when it creates an item, so
956
+ * only an item written before the entity was `versioned` may lack a version
957
+ * — and it lacks the token too. Token without version: the version was
958
+ * removed outside the library. Reading it as version 0 would let the next
959
+ * update rewrite the item's history, so every read and write refuses it.
960
+ */
961
+ const versionCorruption = (item, operation) => systemFields.version &&
962
+ item !== undefined &&
963
+ item[systemFields.version] === undefined &&
964
+ item[INCARNATION_TOKEN] !== undefined
965
+ ? new ValidationError({
966
+ entityType,
967
+ operation,
968
+ cause: `The ${entityType} item has an incarnation token (${INCARNATION_TOKEN}) but no ` +
969
+ `"${systemFields.version}" attribute: its version was removed outside the library. ` +
970
+ "It is refused rather than read as version 0, which would let an update overwrite " +
971
+ "its version history. Restore the version attribute.",
972
+ })
973
+ : undefined;
974
+ const checkVersion = (item, operation) => {
975
+ const corruption = versionCorruption(item, operation);
976
+ return corruption === undefined ? Effect.void : Effect.fail(corruption);
977
+ };
435
978
  const decodeRecord = (raw) => {
979
+ const corruption = versionCorruption(raw, "decode");
980
+ if (corruption !== undefined)
981
+ return Effect.fail(corruption);
436
982
  deserializeSparseFields(raw);
437
983
  renameFromDynamo(raw);
438
984
  return Schema.decodeUnknownEffect(schemas.recordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
@@ -443,6 +989,9 @@ const makeImpl = (config, precomputedData) => {
443
989
  };
444
990
  /** Decode a raw item using the schema selected by mode */
445
991
  const decodeAs = (raw, marshalled, mode) => {
992
+ const corruption = versionCorruption(raw, "decode");
993
+ if (corruption !== undefined)
994
+ return Effect.fail(corruption);
446
995
  if (mode === "native")
447
996
  return Effect.succeed(marshalled);
448
997
  // Collect flattened sparse-map attributes back into the domain Record.
@@ -816,6 +1365,570 @@ const makeImpl = (config, precomputedData) => {
816
1365
  }
817
1366
  return { sets, removes };
818
1367
  });
1368
+ // ---------------------------------------------------------------------------
1369
+ // Update concurrency and return values (#133)
1370
+ // ---------------------------------------------------------------------------
1371
+ /**
1372
+ * What proves an item is the same INCARNATION as the one read — not a
1373
+ * deleted-and-recreated item at the same version: a hidden per-incarnation
1374
+ * token, `__edd_i__`, a random UUID set when the item is created (put,
1375
+ * create, upsert, batch / transaction put). `createdAt` would not do: two
1376
+ * creates in the same millisecond, or a caller-supplied value, repeat it.
1377
+ * Only versioned entities need it — their version CAS is what it completes.
1378
+ */
1379
+ const incarnationAttr = systemFields.version ? INCARNATION_TOKEN : undefined;
1380
+ const stampsIncarnation = incarnationAttr === INCARNATION_TOKEN;
1381
+ const freshIncarnation = freshIncarnationToken;
1382
+ /**
1383
+ * The condition (and its names / values) proving the stored item is still
1384
+ * the incarnation `read` — `undefined` when the entity needs no proof.
1385
+ */
1386
+ const incarnationGuard = (read, names, values) => {
1387
+ if (incarnationAttr === undefined)
1388
+ return undefined;
1389
+ names["#inc"] = incarnationAttr;
1390
+ const value = read[incarnationAttr];
1391
+ if (value === undefined)
1392
+ return "attribute_not_exists(#inc)";
1393
+ values[":inc"] = value;
1394
+ return "#inc = :inc";
1395
+ };
1396
+ /**
1397
+ * The version an item is stored at: `undefined` for no item, 0 for an item
1398
+ * written before the entity was `versioned` (it has no version attribute;
1399
+ * real versions start at 1).
1400
+ */
1401
+ const storedVersionOf = (item) => {
1402
+ if (item === undefined || !systemFields.version)
1403
+ return undefined;
1404
+ const attr = item[systemFields.version];
1405
+ return attr !== undefined ? Number(attr.N) : 0;
1406
+ };
1407
+ /**
1408
+ * A version-snapshot Put that never overwrites history (#133). The
1409
+ * `v#<version>` row may already exist only as a snapshot of the SAME state —
1410
+ * a retain `put` writes `v#0000001` and the first update snapshots that same
1411
+ * version 1 state again; a restore re-writes the delete-time snapshot — so
1412
+ * the row is replaced only when it holds the same version, the same
1413
+ * incarnation and (with timestamps) the same `updatedAt`. Any other row is a
1414
+ * different history, and the write is refused.
1415
+ */
1416
+ const snapshotPut = (tableName, snapshotItem) => {
1417
+ const names = { "#snap": config.indexes.primary.pk.field };
1418
+ const values = {};
1419
+ const same = [];
1420
+ if (systemFields.version) {
1421
+ const version = snapshotItem[systemFields.version];
1422
+ same.push(versionIs(typeof version === "number" ? version : 0, names, values, "#snapVer", ":snapVer"));
1423
+ }
1424
+ const sameAs = (attr, tag) => {
1425
+ names[`#${tag}`] = attr;
1426
+ const value = snapshotItem[attr];
1427
+ if (value === undefined) {
1428
+ same.push(`attribute_not_exists(#${tag})`);
1429
+ }
1430
+ else {
1431
+ values[`:${tag}`] = toAttributeValue(value);
1432
+ same.push(`#${tag} = :${tag}`);
1433
+ }
1434
+ };
1435
+ sameAs(INCARNATION_TOKEN, "snapInc");
1436
+ if (systemFields.updatedAt)
1437
+ sameAs(systemFields.updatedAt, "snapUpd");
1438
+ return {
1439
+ TableName: tableName,
1440
+ Item: toAttributeMap(snapshotItem),
1441
+ ConditionExpression: `attribute_not_exists(#snap) OR (${same.join(" AND ")})`,
1442
+ ExpressionAttributeNames: names,
1443
+ ...(Object.keys(values).length > 0 && { ExpressionAttributeValues: values }),
1444
+ };
1445
+ };
1446
+ /** A version snapshot Put refused: that version's history row already exists. */
1447
+ const historyConflict = (version, operation) => new ValidationError({
1448
+ entityType,
1449
+ operation,
1450
+ cause: `The ${entityType} version ${version} snapshot already exists: the item's version is ` +
1451
+ "inconsistent with its history, and history is never overwritten. Nothing was written.",
1452
+ });
1453
+ /**
1454
+ * The condition that the stored version is `version` — for version 0 (an
1455
+ * item written before the entity was `versioned`), that it has none (#133).
1456
+ */
1457
+ const versionIs = (version, names, values, name, value) => {
1458
+ names[name] = systemFields.version;
1459
+ if (version === 0)
1460
+ return `attribute_not_exists(${name})`;
1461
+ values[value] = toAttributeValue(version);
1462
+ return `${name} = ${value}`;
1463
+ };
1464
+ /**
1465
+ * Which predicate rejected a version-conditioned (or user-conditioned)
1466
+ * write. The write asked for `ReturnValuesOnConditionCheckFailure: "ALL_OLD"`,
1467
+ * so DynamoDB hands back the item as stored: a version other than the one
1468
+ * the write was conditioned on — or another incarnation at that version — is
1469
+ * a lost race; otherwise the user's `.condition()` rejected it. No item back
1470
+ * means the item is gone — a race when the write was version-conditioned.
1471
+ */
1472
+ const conditionRejection = (key, stored, cas, userCondition) => {
1473
+ const storedVersion = storedVersionOf(stored);
1474
+ const otherIncarnation = cas?.read !== undefined &&
1475
+ incarnationAttr !== undefined &&
1476
+ !attributeValueEquals(stored?.[incarnationAttr], cas.read[incarnationAttr]);
1477
+ if ((cas !== undefined && (storedVersion !== cas.version || otherIncarnation)) ||
1478
+ !userCondition) {
1479
+ return new OptimisticLockError({
1480
+ entityType,
1481
+ key,
1482
+ expectedVersion: cas?.version ?? -1,
1483
+ actualVersion: storedVersion ?? -1,
1484
+ });
1485
+ }
1486
+ return new ConditionalCheckFailed({ entityType, key });
1487
+ };
1488
+ /**
1489
+ * A condition the library sends — its own guard plus the user's
1490
+ * `.condition()` — that exceeds DynamoDB's limits on one expression (4 KB,
1491
+ * 300 operators and functions): refused BEFORE writing (#133).
1492
+ */
1493
+ const oversizedCondition = (operation, condition, user) => {
1494
+ if (condition === undefined || expressionFits(condition))
1495
+ return undefined;
1496
+ const own = expressionCost(user);
1497
+ return new ValidationError({
1498
+ entityType,
1499
+ operation,
1500
+ cause: `The ${operation}'s condition is ${expressionCost(condition).length} characters / ` +
1501
+ `${expressionCost(condition).operators} operators — beyond DynamoDB's limits on one ` +
1502
+ `expression (${EXPRESSION_LIMIT} characters, ${OPERATOR_LIMIT} operators). The ` +
1503
+ `.condition() alone is ${own.length} characters / ${own.operators} operators, and ` +
1504
+ "the library's own guard needs room beside it: simplify the condition.",
1505
+ });
1506
+ };
1507
+ /** The domain field a stored attribute holds (`storedAs` renames reversed). */
1508
+ const domainNameOf = (attr) => Object.keys(data.modelFields).find((field) => resolveDbName(field) === attr) ?? attr;
1509
+ /**
1510
+ * The condition a read-then-write DELETE carries (#133): the item is still
1511
+ * what the tombstone, snapshot and sentinel deletes were derived from.
1512
+ * Versioned: its version and incarnation. Otherwise: it exists and every
1513
+ * attribute in `dependsOn` still holds the value read (absent ones still
1514
+ * absent). `"item"` means the whole item — a tombstone copies all of it —
1515
+ * so every stored attribute is guarded, plus every attribute the entity
1516
+ * knows of that was absent; an item too wide for that falls back to the
1517
+ * strongest guard within DynamoDB's expression limit (never a refusal).
1518
+ */
1519
+ const deleteGuard = (read, dependsOn,
1520
+ /** The rest of the condition, ANDed with the guard (the user's, …). */
1521
+ others) => {
1522
+ // Whether a guard fits beside `others` within DynamoDB's real limits.
1523
+ const fits = (guard) => expressionFits(others === undefined || others === "" ? guard : `${guard} AND (${others})`);
1524
+ const names = {};
1525
+ const values = {};
1526
+ const parts = [];
1527
+ const pkField = config.indexes.primary.pk.field;
1528
+ const skField = config.indexes.primary.sk.field;
1529
+ if (systemFields.version) {
1530
+ names["#dver"] = systemFields.version;
1531
+ // An item written before the entity was `versioned` has no version.
1532
+ const readVersion = read[systemFields.version];
1533
+ if (readVersion === undefined) {
1534
+ parts.push("attribute_not_exists(#dver)");
1535
+ }
1536
+ else {
1537
+ values[":dver"] = readVersion;
1538
+ parts.push("#dver = :dver");
1539
+ }
1540
+ const incarnation = incarnationGuard(read, names, values);
1541
+ if (incarnation !== undefined)
1542
+ parts.push(incarnation);
1543
+ const expression = parts.join(" AND ");
1544
+ if (!fits(expression))
1545
+ return tooLargeBeside(expression);
1546
+ return { expression, names, values, inputs: [] };
1547
+ }
1548
+ names["#dpk"] = pkField;
1549
+ parts.push("attribute_exists(#dpk)");
1550
+ if (!fits(parts.join(" AND ")))
1551
+ return tooLargeBeside(parts.join(" AND "));
1552
+ const attrs = dependsOn === "item"
1553
+ ? [
1554
+ ...new Set([
1555
+ ...Object.keys(read),
1556
+ ...Object.keys(data.modelFields).map(resolveDbName),
1557
+ ...[systemFields.createdAt, systemFields.updatedAt].filter((f) => typeof f === "string"),
1558
+ ...gsiKeyFields(),
1559
+ ...vectorKeyFields(),
1560
+ ]),
1561
+ ].filter((attr) => attr !== pkField && attr !== skField)
1562
+ : dependsOn;
1563
+ const guardOf = (attr, i) => {
1564
+ names[`#dg${i}`] = attr;
1565
+ if (read[attr] === undefined)
1566
+ return `attribute_not_exists(#dg${i})`;
1567
+ values[`:dg${i}`] = read[attr];
1568
+ return `#dg${i} = :dg${i}`;
1569
+ };
1570
+ const full = [...parts, ...attrs.map(guardOf)].join(" AND ");
1571
+ if (fits(full))
1572
+ return { expression: full, names, values, inputs: attrs };
1573
+ // Too wide to guard attribute by attribute (a sparse map with hundreds of
1574
+ // entries): the strongest guard that fits. Every library write sets
1575
+ // `updatedAt`, so with timestamps it alone detects any library update;
1576
+ // then as many attributes as fit, those the sentinels derive from first,
1577
+ // then the model's own fields, then the rest.
1578
+ for (const key of Object.keys(names))
1579
+ if (key.startsWith("#dg"))
1580
+ delete names[key];
1581
+ for (const key of Object.keys(values))
1582
+ if (key.startsWith(":dg"))
1583
+ delete values[key];
1584
+ const uniqueAttrs = Object.values(config.unique ?? {}).flatMap((def) => resolveUniqueFields(def).map(resolveDbName));
1585
+ const modelAttrs = Object.keys(data.modelFields).map(resolveDbName);
1586
+ const ordered = [
1587
+ ...new Set([
1588
+ ...(systemFields.updatedAt ? [systemFields.updatedAt] : []),
1589
+ ...uniqueAttrs,
1590
+ ...modelAttrs,
1591
+ ...attrs.slice().sort(),
1592
+ ]),
1593
+ ].filter((attr) => attrs.includes(attr));
1594
+ const inputs = [];
1595
+ let expression = parts.join(" AND ");
1596
+ for (const attr of ordered) {
1597
+ const clause = guardOf(attr, inputs.length);
1598
+ if (!fits(`${expression} AND ${clause}`)) {
1599
+ delete names[`#dg${inputs.length}`];
1600
+ delete values[`:dg${inputs.length}`];
1601
+ continue;
1602
+ }
1603
+ expression = `${expression} AND ${clause}`;
1604
+ inputs.push(attr);
1605
+ }
1606
+ return { expression, names, values, inputs };
1607
+ function tooLargeBeside(guard) {
1608
+ return (oversizedCondition("write", others === undefined || others === "" ? guard : `${guard} AND (${others})`, others) ?? new ValidationError({ entityType, operation: "write", cause: "condition too large" }));
1609
+ }
1610
+ };
1611
+ /** Which predicate rejected a guarded delete — from the stored item (ALL_OLD). */
1612
+ const deleteRejection = (key, read, inputs, stored, userCondition) => {
1613
+ if (stored === undefined)
1614
+ return new ItemNotFound({ entityType, key });
1615
+ if (systemFields.version) {
1616
+ return conditionRejection(key, stored, { version: storedVersionOf(read), read }, userCondition);
1617
+ }
1618
+ const changed = inputs.filter((attr) => !attributeValueEquals(stored[attr], read[attr]));
1619
+ if (changed.length === 0 && userCondition)
1620
+ return new ConditionalCheckFailed({ entityType, key });
1621
+ return new ConcurrentModification({
1622
+ entityType,
1623
+ key,
1624
+ attributes: changed.map(domainNameOf),
1625
+ current: Option.none(),
1626
+ });
1627
+ };
1628
+ /** Fill a `ConcurrentModification`'s `current` with the stored item, decoded. */
1629
+ const withCurrentItem = (effect, stored) => effect.pipe(Effect.catchIf((e) => e instanceof ConcurrentModification, (e) => Effect.gen(function* () {
1630
+ const item = stored();
1631
+ const current = item === undefined
1632
+ ? Option.none()
1633
+ : yield* Effect.option(decodeAs(fromAttributeMap(item), item, "model"));
1634
+ return yield* keepReleaseRace(e, new ConcurrentModification({ ...e, current }));
1635
+ })));
1636
+ /**
1637
+ * The item a retain path update wrote, read back after its transaction. The
1638
+ * write is proven by version AND incarnation: within one incarnation a
1639
+ * version is written exactly once, so the live item is ours while it holds
1640
+ * our version and incarnation; once a later write replaced it, the
1641
+ * `v#<ourVersion>` snapshot that write took is ours — if it carries our
1642
+ * incarnation (a snapshot left by an earlier incarnation of the item does
1643
+ * not). It is restored to item shape: what a snapshot strips (index and
1644
+ * vector attributes) or adds (its sort key and TTL) comes from the item we
1645
+ * replaced plus this update's own writes. Without a proven post-image the
1646
+ * update still WAS applied: {@link UpdateAppliedButUnreadable}.
1647
+ */
1648
+ const readRetainPostImage = (tableName, marshalledKey, replaced, ourVersion, ourIncarnation, derivedSets, derivedRemoves, ttlAttrName, encodedKey) => Effect.gen(function* () {
1649
+ const client = yield* DynamoClient;
1650
+ const ours = (item) => item !== undefined &&
1651
+ (incarnationAttr === undefined ||
1652
+ attributeValueEquals(item[incarnationAttr], ourIncarnation));
1653
+ const current = (yield* client.getItem({
1654
+ TableName: tableName,
1655
+ Key: marshalledKey,
1656
+ ConsistentRead: true,
1657
+ })).Item;
1658
+ if (storedVersionOf(current) === ourVersion && ours(current))
1659
+ return current;
1660
+ const skField = config.indexes.primary.sk.field;
1661
+ const snapshot = (yield* client.getItem({
1662
+ TableName: tableName,
1663
+ Key: {
1664
+ ...marshalledKey,
1665
+ [skField]: toAttributeValue(DynamoSchema.composeVersionKey(schema, entityType, ourVersion, historyKeyOptions(marshalledKey[skField]?.S))),
1666
+ },
1667
+ ConsistentRead: true,
1668
+ })).Item;
1669
+ if (!ours(snapshot)) {
1670
+ return yield* new UpdateAppliedButUnreadable({
1671
+ entityType,
1672
+ key: encodedKey,
1673
+ version: ourVersion,
1674
+ reason: current === undefined
1675
+ ? "the item was deleted before it could be read back"
1676
+ : `the item moved past version ${ourVersion}, and no snapshot of this write ` +
1677
+ "remains to read it from",
1678
+ });
1679
+ }
1680
+ const post = { ...snapshot };
1681
+ post[skField] = marshalledKey[skField];
1682
+ const restored = [
1683
+ ...gsiKeyFields(),
1684
+ ...vectorKeyFields(),
1685
+ ...vectorIndexEntries.map(([, definition]) => definition.stashField),
1686
+ ...(retainTtl() ? [ttlAttrName] : []),
1687
+ ];
1688
+ for (const field of restored) {
1689
+ delete post[field];
1690
+ if (replaced[field] !== undefined)
1691
+ post[field] = replaced[field];
1692
+ }
1693
+ Object.assign(post, derivedSets);
1694
+ for (const field of derivedRemoves)
1695
+ delete post[field];
1696
+ return post;
1697
+ });
1698
+ /**
1699
+ * Decode only `attributes` of a stored image (`updatedOld` / `updatedNew`):
1700
+ * a partial — the fields present, each through its own schema.
1701
+ */
1702
+ const decodePartial = (image, attributes, mode) => {
1703
+ const corruption = versionCorruption(image, "decode");
1704
+ if (corruption !== undefined)
1705
+ return Effect.fail(corruption);
1706
+ const projected = {};
1707
+ for (const [attr, value] of Object.entries(image ?? {})) {
1708
+ if (attributes.has(attr))
1709
+ projected[attr] = value;
1710
+ }
1711
+ if (mode === "native")
1712
+ return Effect.succeed(projected);
1713
+ const raw = fromAttributeMap(projected);
1714
+ deserializeSparseFields(raw);
1715
+ renameFromDynamo(raw);
1716
+ const target = mode === "model"
1717
+ ? schemas.modelSchema
1718
+ : mode === "record"
1719
+ ? schemas.recordSchema
1720
+ : schemas.itemSchema;
1721
+ const fields = target.fields;
1722
+ const present = Object.fromEntries(Object.entries(fields).filter(([name]) => name in raw));
1723
+ return Schema.decodeUnknownEffect(Schema.Struct(present))(raw).pipe(Effect.mapError((cause) => new ValidationError({ entityType, operation: "decode", cause })));
1724
+ };
1725
+ /**
1726
+ * The value an update returns for its `returnValues` mode, from the item
1727
+ * before (`old`) and after (`next`) it, and the attributes it wrote:
1728
+ * `none` → `undefined`; `allOld` / `allNew` → the whole item; `updatedOld` /
1729
+ * `updatedNew` → just the written attributes, decoded as a partial.
1730
+ */
1731
+ const updateResult = (rv, old, next, written, mode, encodedKey) => {
1732
+ switch (rv) {
1733
+ case "none":
1734
+ return Effect.succeed(undefined);
1735
+ case "updatedOld":
1736
+ return decodePartial(old, written, mode);
1737
+ case "updatedNew":
1738
+ return decodePartial(next, written, mode);
1739
+ default: {
1740
+ const image = rv === "allOld" ? old : next;
1741
+ if (image === undefined)
1742
+ return Effect.fail(new ItemNotFound({ entityType, key: encodedKey }));
1743
+ return decodeAs(fromAttributeMap(image), image, mode);
1744
+ }
1745
+ }
1746
+ };
1747
+ /**
1748
+ * The history keys (version snapshots, soft-delete tombstones) of the item
1749
+ * whose live sort key is `liveSk` (#133). An entity whose primary sort key
1750
+ * has composites holds several items per partition, so each item's history
1751
+ * keys carry its segment — the composite part of `liveSk` — and items never
1752
+ * share a version sequence, a snapshot or a tombstone. Without sort key
1753
+ * composites there is one item per partition and no segment: the keys are
1754
+ * byte-identical to the partition-wide ones every earlier release wrote.
1755
+ */
1756
+ const historyKeyOptions = (liveSk) => {
1757
+ if (typeof liveSk !== "string")
1758
+ return undefined;
1759
+ const item = KeyComposer.composeHistoryItemSegment(schema, entityType, entityVersion, config.indexes.primary, liveSk);
1760
+ return item === undefined ? undefined : { item };
1761
+ };
1762
+ /**
1763
+ * See {@link Entity._liveRows}. A row is dropped only when it is positively
1764
+ * history: its sort key is not the one its own composites compose (or they
1765
+ * don't compose) AND it has the layout of a snapshot (`#v#…<version>`), a
1766
+ * tombstone (`#deleted#…<timestamp>`) or a time-series event (`#e#` under
1767
+ * the live key). Any other row is kept and decoded as before — including
1768
+ * rows an earlier release keyed differently (an unpadded number composite),
1769
+ * which the current composer can't reproduce.
1770
+ */
1771
+ const liveRows = () => {
1772
+ if (!isRetainEnabled() && !isSoftDeleteEnabled() && config.timeSeries === undefined) {
1773
+ return undefined;
1774
+ }
1775
+ const primary = config.indexes.primary;
1776
+ const skField = primary.sk.field;
1777
+ const compositeAttrs = primary.sk.composite.map(resolveDbName);
1778
+ const versions = DynamoSchema.composeVersionKeyPrefix(schema, entityType);
1779
+ const tombstones = DynamoSchema.composeDeletedKeyPrefix(schema, entityType);
1780
+ const bare = KeyComposer.composeSk(schema, entityType, entityVersion, { ...primary, sk: { ...primary.sk, composite: [] } }, keyForm({}));
1781
+ const eventMarker = KeyComposer.composeEventSkPrefix("", schema.casing);
1782
+ const historyShaped = (sk) => (isRetainEnabled() &&
1783
+ sk.startsWith(versions) &&
1784
+ /^(?:.*#)?\d{7,}$/.test(sk.slice(versions.length))) ||
1785
+ (isSoftDeleteEnabled() &&
1786
+ sk.startsWith(tombstones) &&
1787
+ /^(?:.*#)?\d{4}-\d\d-\d\dT[^#]*$/.test(sk.slice(tombstones.length))) ||
1788
+ (config.timeSeries !== undefined &&
1789
+ sk.startsWith(bare) &&
1790
+ sk.slice(bare.length).includes(eventMarker));
1791
+ return {
1792
+ isLive: (row) => {
1793
+ const sk = row[skField]?.S;
1794
+ if (sk === undefined)
1795
+ return true;
1796
+ // Only the composites are unmarshalled: the row may be large.
1797
+ const composites = {};
1798
+ for (const attr of compositeAttrs) {
1799
+ const value = row[attr];
1800
+ if (value !== undefined)
1801
+ composites[attr] = value;
1802
+ }
1803
+ try {
1804
+ if (liveSkOf(toDomainView(fromAttributeMap(composites))) === sk)
1805
+ return true;
1806
+ }
1807
+ catch {
1808
+ // Not composable now: judged by its layout alone.
1809
+ }
1810
+ return !historyShaped(sk);
1811
+ },
1812
+ reads: [skField, ...compositeAttrs],
1813
+ };
1814
+ };
1815
+ // ---------------------------------------------------------------------------
1816
+ // History an earlier release wrote without an item segment (#133)
1817
+ // ---------------------------------------------------------------------------
1818
+ /**
1819
+ * Whether the entity's history keys carry an item segment — its primary sort
1820
+ * key has composites. Only then can history an earlier release wrote, under
1821
+ * the partition-wide keys (`#v#0000003`, `#deleted#<ts>`), be in the
1822
+ * partition: every reader of one item's history also reads those rows, and
1823
+ * takes the ones whose stored composites compose the item's live key. A
1824
+ * version held both ways is read from the segmented row.
1825
+ */
1826
+ const hasItemHistory = config.indexes.primary.sk.composite.length > 0;
1827
+ /** Whether a stored (attribute-keyed) row is the history of the item at `liveSk`. */
1828
+ const isItemsRow = (row, liveSk) => {
1829
+ try {
1830
+ return liveSkOf(toDomainView(fromAttributeMap(row))) === liveSk;
1831
+ }
1832
+ catch {
1833
+ return false;
1834
+ }
1835
+ };
1836
+ /**
1837
+ * The item's unsegmented history rows of one kind, in sort key order (latest
1838
+ * first with `descending`), stopping at the first with `first`. Unsegmented
1839
+ * rows sort before every segmented one — a version or timestamp starts with
1840
+ * a digit, a segment with a composite name — so a key range bounds them.
1841
+ */
1842
+ const legacyHistory = (args) => Effect.gen(function* () {
1843
+ const client = yield* DynamoClient;
1844
+ const primary = config.indexes.primary;
1845
+ const prefix = args.kind === "version"
1846
+ ? DynamoSchema.composeVersionKeyPrefix(schema, entityType)
1847
+ : DynamoSchema.composeDeletedKeyPrefix(schema, entityType);
1848
+ const range = {
1849
+ TableName: args.tableName,
1850
+ KeyConditionExpression: "#pk = :pk AND #sk BETWEEN :lo AND :hi",
1851
+ ExpressionAttributeNames: { "#pk": primary.pk.field, "#sk": primary.sk.field },
1852
+ ExpressionAttributeValues: {
1853
+ ":pk": toAttributeValue(args.pk),
1854
+ ":lo": toAttributeValue(`${prefix}0`),
1855
+ ":hi": toAttributeValue(`${prefix}:`),
1856
+ },
1857
+ ConsistentRead: true,
1858
+ };
1859
+ // The common case — a partition no earlier release wrote history into —
1860
+ // costs one `Limit 1` keys-only read. (A row it finds may still be
1861
+ // segmented, if a segment starts with a digit; the read below filters.)
1862
+ const probe = yield* client.query({ ...range, ProjectionExpression: "#sk", Limit: 1 });
1863
+ if ((probe.Items ?? []).length === 0)
1864
+ return [];
1865
+ const rows = [];
1866
+ let start;
1867
+ do {
1868
+ const result = yield* client.query({
1869
+ ...range,
1870
+ ScanIndexForward: !args.descending,
1871
+ ExclusiveStartKey: start,
1872
+ });
1873
+ for (const row of result.Items ?? []) {
1874
+ const sk = row[primary.sk.field]?.S ?? "";
1875
+ if (sk.slice(prefix.length).includes("#") || !isItemsRow(row, args.liveSk))
1876
+ continue;
1877
+ rows.push(row);
1878
+ if (args.first)
1879
+ return rows;
1880
+ }
1881
+ start = result.LastEvaluatedKey;
1882
+ } while (start !== undefined);
1883
+ return rows;
1884
+ });
1885
+ /** The version a history sort key ends with. */
1886
+ const versionOfSk = (sk) => {
1887
+ if (sk === undefined)
1888
+ return 0;
1889
+ const version = Number(sk.slice(sk.lastIndexOf("#") + 1));
1890
+ return Number.isInteger(version) ? version : 0;
1891
+ };
1892
+ /**
1893
+ * The item's latest tombstone — its own, or one an earlier release wrote
1894
+ * without a segment, whichever is later (its own on a tie).
1895
+ */
1896
+ const latestTombstone = (tableName, pk, liveSk) => Effect.gen(function* () {
1897
+ const client = yield* DynamoClient;
1898
+ const primary = config.indexes.primary;
1899
+ const own = (yield* client.query({
1900
+ TableName: tableName,
1901
+ KeyConditionExpression: "#pk = :pk AND begins_with(#sk, :skPrefix)",
1902
+ ExpressionAttributeNames: { "#pk": primary.pk.field, "#sk": primary.sk.field },
1903
+ ExpressionAttributeValues: {
1904
+ ":pk": toAttributeValue(pk),
1905
+ ":skPrefix": toAttributeValue(DynamoSchema.composeDeletedKeyPrefix(schema, entityType, historyKeyOptions(liveSk))),
1906
+ },
1907
+ Limit: 1,
1908
+ ScanIndexForward: false,
1909
+ })).Items?.[0];
1910
+ if (!hasItemHistory)
1911
+ return own;
1912
+ const [legacy] = yield* legacyHistory({
1913
+ tableName,
1914
+ pk,
1915
+ liveSk,
1916
+ kind: "deleted",
1917
+ descending: true,
1918
+ first: true,
1919
+ });
1920
+ if (legacy === undefined)
1921
+ return own;
1922
+ if (own === undefined)
1923
+ return legacy;
1924
+ const stamp = (row) => {
1925
+ const sk = row[primary.sk.field]?.S ?? "";
1926
+ return sk.slice(sk.lastIndexOf("#") + 1);
1927
+ };
1928
+ return stamp(legacy) > stamp(own) ? legacy : own;
1929
+ });
1930
+ /** The live sort key of the item at `encodedKey` (the caller's key, encoded). */
1931
+ const liveSkOf = (encodedKey) => KeyComposer.composeSk(schema, entityType, entityVersion, config.indexes.primary, keyForm(encodedKey));
819
1932
  /**
820
1933
  * Build a version snapshot item: same PK, version SK, stripped GSI keys,
821
1934
  * keeps __edd_e__ for entity type filtering.
@@ -824,7 +1937,9 @@ const makeImpl = (config, precomputedData) => {
824
1937
  * passed in — the snapshot belongs to the same physical table, so it must
825
1938
  * align with that table's `TimeToLiveSpecification.AttributeName`.
826
1939
  */
827
- const buildSnapshotItem = (item, version, _tablePkField, tableSkField, ttlAttrName, now) => {
1940
+ const buildSnapshotItem = (item, version, _tablePkField, tableSkField, ttlAttrName, now,
1941
+ /** The item's live sort key, when `item` is not the live item (a tombstone). */
1942
+ liveSk = item[tableSkField]) => {
828
1943
  const snapshot = { ...item };
829
1944
  // Strip GSI key fields — snapshots must not appear in index queries
830
1945
  for (const field of gsiKeyFields()) {
@@ -844,7 +1959,7 @@ const makeImpl = (config, precomputedData) => {
844
1959
  delete snapshot[definition.stashField];
845
1960
  }
846
1961
  // Replace SK with version SK
847
- snapshot[tableSkField] = DynamoSchema.composeVersionKey(schema, entityType, version);
1962
+ snapshot[tableSkField] = DynamoSchema.composeVersionKey(schema, entityType, version, historyKeyOptions(liveSk));
848
1963
  // Add optional TTL
849
1964
  const ttl = retainTtl();
850
1965
  if (ttl) {
@@ -869,63 +1984,403 @@ const makeImpl = (config, precomputedData) => {
869
1984
  features.push("softDelete");
870
1985
  return features;
871
1986
  };
1987
+ // ---------------------------------------------------------------------------
1988
+ // Unique sentinels: reservation and release (#133)
1989
+ // ---------------------------------------------------------------------------
1990
+ /**
1991
+ * The sentinel item reserving `key` for the item at `owner`. `_entity_pk` /
1992
+ * `_entity_sk` name the reserving item — the back-pointer a release checks
1993
+ * (see {@link ownedSentinels}).
1994
+ */
1995
+ const sentinelItemFor = (constraintName, constraintDef, key, owner, now, ttlAttrName) => {
1996
+ const sentinel = {
1997
+ [config.indexes.primary.pk.field]: key.pk,
1998
+ [config.indexes.primary.sk.field]: key.sk,
1999
+ __edd_e__: `${entityType}._unique.${constraintName}`,
2000
+ _entity_pk: owner.pk,
2001
+ _entity_sk: owner.sk,
2002
+ };
2003
+ // Optional TTL — when the constraint declares one, the sentinel
2004
+ // auto-expires (time-bounded uniqueness reservation/hold).
2005
+ const uniqueTtl = resolveUniqueTtl(constraintDef);
2006
+ if (uniqueTtl !== undefined) {
2007
+ sentinel[ttlAttrName] = DateTime.toEpochSeconds(now) + normalizeTtlSeconds(uniqueTtl);
2008
+ }
2009
+ return sentinel;
2010
+ };
2011
+ /**
2012
+ * The sentinels among `candidates` that the item at `owner` holds, read
2013
+ * consistently (#133). An item can hold a unique value WITHOUT owning its
2014
+ * sentinel: the constraint was added after the item was written, the item
2015
+ * was written outside the library, or a `ttl`'d reservation expired and
2016
+ * another item then claimed the value. That other item owns the sentinel
2017
+ * now — its `_entity_pk` / `_entity_sk` name it — and releasing the
2018
+ * sentinel by key alone would delete its reservation and let the value be
2019
+ * taken twice. So a write releases only the sentinels this returns, each
2020
+ * under {@link sentinelRelease}.
2021
+ */
2022
+ const ownedSentinels = (tableName, candidates, owner) => Effect.gen(function* () {
2023
+ if (candidates.length === 0)
2024
+ return [];
2025
+ const client = yield* DynamoClient;
2026
+ const pkField = config.indexes.primary.pk.field;
2027
+ const skField = config.indexes.primary.sk.field;
2028
+ const ownerPk = toAttributeValue(owner.pk);
2029
+ const ownerSk = toAttributeValue(owner.sk);
2030
+ const reads = yield* Effect.forEach(candidates, (key) => client.getItem({
2031
+ TableName: tableName,
2032
+ Key: toAttributeMap({ [pkField]: key.pk, [skField]: key.sk }),
2033
+ ConsistentRead: true,
2034
+ ProjectionExpression: "#epk, #esk",
2035
+ ExpressionAttributeNames: { "#epk": "_entity_pk", "#esk": "_entity_sk" },
2036
+ }), { concurrency: "unbounded" });
2037
+ return candidates.filter((_, i) => {
2038
+ const stored = reads[i]?.Item;
2039
+ return (stored !== undefined &&
2040
+ attributeValueEquals(stored._entity_pk, ownerPk) &&
2041
+ attributeValueEquals(stored._entity_sk, ownerSk));
2042
+ });
2043
+ });
2044
+ /**
2045
+ * The Delete releasing a sentinel {@link ownedSentinels} found owned by
2046
+ * `owner` — conditioned on that ownership still holding, so a reservation
2047
+ * that changed hands between the read and the write cancels the write
2048
+ * instead of being deleted.
2049
+ */
2050
+ const sentinelRelease = (tableName, key, owner) => ({
2051
+ Delete: {
2052
+ TableName: tableName,
2053
+ Key: toAttributeMap({
2054
+ [config.indexes.primary.pk.field]: key.pk,
2055
+ [config.indexes.primary.sk.field]: key.sk,
2056
+ }),
2057
+ ConditionExpression: "#epk = :epk AND #esk = :esk",
2058
+ ExpressionAttributeNames: { "#epk": "_entity_pk", "#esk": "_entity_sk" },
2059
+ ExpressionAttributeValues: {
2060
+ ":epk": toAttributeValue(owner.pk),
2061
+ ":esk": toAttributeValue(owner.sk),
2062
+ },
2063
+ },
2064
+ });
2065
+ /**
2066
+ * A sentinel release cancelled: the reservation changed hands between the
2067
+ * read and the write. Nothing was written; the item still holds the value.
2068
+ */
2069
+ const releaseRaced = (key, constraintName) => {
2070
+ const error = new ConcurrentModification({
2071
+ entityType,
2072
+ key,
2073
+ attributes: [...resolveUniqueFields(config.unique[constraintName])],
2074
+ current: Option.none(),
2075
+ });
2076
+ releaseRaces.add(error);
2077
+ return error;
2078
+ };
2079
+ // ---------------------------------------------------------------------------
2080
+ // Guarded puts (#133)
2081
+ // ---------------------------------------------------------------------------
872
2082
  /**
873
- * Side items for a `put`, derived purely from the payload being written —
874
- * see the `_buildPutSideItems` doc on the Entity interface for why the delete
875
- * side has no counterpart.
2083
+ * The highest version retained for the item at `pk` — 0 with none. A
2084
+ * deleted retain item's version snapshots outlive it, so the item created
2085
+ * again at its key continues the sequence after them rather than
2086
+ * overwriting its earlier incarnation's history.
2087
+ */
2088
+ const highestRetainedVersion = (tableName, pk, liveSk) => Effect.gen(function* () {
2089
+ const client = yield* DynamoClient;
2090
+ const primary = config.indexes.primary;
2091
+ const { Items } = yield* client.query({
2092
+ TableName: tableName,
2093
+ KeyConditionExpression: "#pk = :pk AND begins_with(#sk, :prefix)",
2094
+ ExpressionAttributeNames: { "#pk": primary.pk.field, "#sk": primary.sk.field },
2095
+ ExpressionAttributeValues: {
2096
+ ":pk": toAttributeValue(pk),
2097
+ ":prefix": toAttributeValue(DynamoSchema.composeVersionKeyPrefix(schema, entityType, historyKeyOptions(liveSk))),
2098
+ },
2099
+ ScanIndexForward: false,
2100
+ Limit: 1,
2101
+ ConsistentRead: true,
2102
+ });
2103
+ const own = versionOfSk(Items?.[0]?.[primary.sk.field]?.S);
2104
+ if (!hasItemHistory || typeof liveSk !== "string")
2105
+ return own;
2106
+ // History an earlier release wrote for this item counts too: a version it
2107
+ // holds is never written again.
2108
+ const [legacy] = yield* legacyHistory({
2109
+ tableName,
2110
+ pk,
2111
+ liveSk,
2112
+ kind: "version",
2113
+ descending: true,
2114
+ first: true,
2115
+ });
2116
+ return Math.max(own, versionOfSk(legacy?.[primary.sk.field]?.S));
2117
+ });
2118
+ /**
2119
+ * A guarded put — of a versioned or unique-constrained entity — compiled
2120
+ * against the item as read (#133). Shared by `put` / `create` / `upsert`
2121
+ * and the transaction paths (`Transaction.transactWrite`,
2122
+ * `EventStore.append`'s `additionalItems`), so they write the same items:
876
2123
  *
877
- * Mirrors the emission in `put`'s own transact path so the two cannot drift:
878
- * one sentinel per satisfiable constraint (sparse — a constraint whose fields
879
- * are unset produces none), then the v1 snapshot.
2124
+ * - **The item exists:** its next version, its incarnation and its
2125
+ * `createdAt` (unless the caller supplies one); the Put is guarded on the
2126
+ * item read — version and incarnation, or the unique values its sentinels
2127
+ * derive from. Retain: the replaced item is snapshotted at its version.
2128
+ * - **The item is missing** (or not read: `create`): the Put requires it
2129
+ * still missing. A retain item continues after the highest version
2130
+ * retained for its key — a deleted item's history outlives it — with a new
2131
+ * incarnation, and is snapshotted at that version.
2132
+ * - **Sentinels:** a changed value takes the new reservation and releases the
2133
+ * old one — only if this item owns it ({@link ownedSentinels}).
880
2134
  *
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.
2135
+ * `verdict` reads a cancellation: a lost race or retained history the read
2136
+ * did not see is retried by the caller (re-planned from a fresh read); the
2137
+ * caller's own condition is `Condition`; a taken value or a history conflict
2138
+ * is final.
886
2139
  */
887
- const buildPutSideItems = (item, now, ttlAttrName) => {
888
- const out = [];
2140
+ const planPut = (args) => Effect.gen(function* () {
2141
+ const client = yield* DynamoClient;
889
2142
  const pkField = config.indexes.primary.pk.field;
890
2143
  const skField = config.indexes.primary.sk.field;
891
- const itemDomain = toDomainView(item);
2144
+ const item = { ...args.item };
2145
+ const owner = { pk: item[pkField], sk: item[skField] };
2146
+ const current = args.create
2147
+ ? undefined
2148
+ : (yield* client.getItem({
2149
+ TableName: args.tableName,
2150
+ Key: toAttributeMap({ [pkField]: owner.pk, [skField]: owner.sk }),
2151
+ ConsistentRead: true,
2152
+ })).Item;
2153
+ yield* checkVersion(current, args.operation);
2154
+ const currentRaw = current !== undefined
2155
+ ? fromAttributeMap(current)
2156
+ : undefined;
2157
+ const storedVersion = storedVersionOf(current);
2158
+ if (currentRaw !== undefined) {
2159
+ // The same item, continued: its next version, its incarnation, its
2160
+ // creation time (unless the caller supplies one).
2161
+ if (systemFields.version) {
2162
+ item[systemFields.version] = (storedVersion ?? 0) + 1;
2163
+ if (currentRaw[INCARNATION_TOKEN] !== undefined) {
2164
+ item[INCARNATION_TOKEN] = currentRaw[INCARNATION_TOKEN];
2165
+ }
2166
+ }
2167
+ if (systemFields.createdAt && !args.createdAtSupplied) {
2168
+ const createdAttr = resolveDbName(systemFields.createdAt);
2169
+ if (currentRaw[createdAttr] !== undefined)
2170
+ item[createdAttr] = currentRaw[createdAttr];
2171
+ }
2172
+ }
2173
+ else if (systemFields.version && isRetainEnabled()) {
2174
+ item[systemFields.version] =
2175
+ (yield* highestRetainedVersion(args.tableName, owner.pk, owner.sk)) + 1;
2176
+ }
2177
+ const marshalled = toAttributeMap(item);
2178
+ // The main item. Missing: it must still be missing. Present: still the
2179
+ // item read — its version and incarnation, or (unversioned) the unique
2180
+ // values its sentinels are keyed by.
2181
+ const uniqueAttrs = [
2182
+ ...new Set(Object.values((config.unique ?? {}))
2183
+ .flatMap((def) => [...resolveUniqueFields(def)])
2184
+ .map(resolveDbName)),
2185
+ ];
2186
+ const userCondition = args.userCondition;
2187
+ const guard = current === undefined
2188
+ ? {
2189
+ expression: "attribute_not_exists(#pk)",
2190
+ names: { "#pk": pkField },
2191
+ values: {},
2192
+ inputs: [],
2193
+ }
2194
+ : deleteGuard(current, uniqueAttrs, userCondition?.expression);
2195
+ if (guard instanceof ValidationError)
2196
+ return yield* guard;
2197
+ const mainValues = { ...guard.values, ...userCondition?.values };
2198
+ const mainCondition = userCondition
2199
+ ? `${guard.expression} AND (${userCondition.expression})`
2200
+ : guard.expression;
2201
+ const tooLarge = oversizedCondition(args.operation, mainCondition, userCondition?.expression);
2202
+ if (tooLarge !== undefined)
2203
+ return yield* tooLarge;
2204
+ const items = [
2205
+ {
2206
+ Put: {
2207
+ TableName: args.tableName,
2208
+ Item: marshalled,
2209
+ ConditionExpression: mainCondition,
2210
+ ExpressionAttributeNames: { ...guard.names, ...userCondition?.names },
2211
+ ...(Object.keys(mainValues).length > 0 && { ExpressionAttributeValues: mainValues }),
2212
+ ReturnValuesOnConditionCheckFailure: "ALL_OLD",
2213
+ },
2214
+ },
2215
+ ];
2216
+ const roles = [{ kind: "main" }];
2217
+ // Sentinels: a changed value takes the new reservation and releases the
2218
+ // old one this item owns; an unchanged value is left alone. A field
2219
+ // holding only a default has none (`__edd_d__`).
892
2220
  if (config.unique != null) {
2221
+ const newSource = toDomainView(item);
2222
+ const oldSource = currentRaw !== undefined ? toDomainView(currentRaw) : undefined;
2223
+ const changes = [];
893
2224
  for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
894
- const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, itemDomain);
895
- // Sparse: a constraint whose composing fields are unset never had a
896
- // sentinel, so writing one would reserve `undefined` for everybody.
897
- if (!sentinel)
2225
+ const next = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, newSource);
2226
+ const prior = oldSource !== undefined
2227
+ ? composeUniqueSentinel(schema, entityType, constraintName, constraintDef, oldSource)
2228
+ : undefined;
2229
+ if (next !== undefined &&
2230
+ prior !== undefined &&
2231
+ next.key.pk === prior.key.pk &&
2232
+ next.key.sk === prior.key.sk) {
2233
+ continue;
2234
+ }
2235
+ changes.push({ constraintName, constraintDef, next, prior: prior?.key });
2236
+ }
2237
+ const owned = new Set(yield* ownedSentinels(args.tableName, changes.flatMap((change) => (change.prior !== undefined ? [change.prior] : [])), owner));
2238
+ for (const change of changes) {
2239
+ if (change.prior !== undefined && owned.has(change.prior)) {
2240
+ items.push(sentinelRelease(args.tableName, change.prior, owner));
2241
+ roles.push({ kind: "release", constraintName: change.constraintName });
2242
+ }
2243
+ if (change.next === undefined)
2244
+ continue;
2245
+ items.push({
2246
+ Put: {
2247
+ TableName: args.tableName,
2248
+ Item: toAttributeMap(sentinelItemFor(change.constraintName, change.constraintDef, change.next.key, owner, args.now, args.ttlAttrName)),
2249
+ ...sentinelGuard(),
2250
+ },
2251
+ });
2252
+ roles.push({
2253
+ kind: "sentinel",
2254
+ constraintName: change.constraintName,
2255
+ fields: change.next.fieldsRecord,
2256
+ });
2257
+ }
2258
+ }
2259
+ // Retain: the item replaced (at its version), or the new item at its own.
2260
+ if (isRetainEnabled()) {
2261
+ const version = currentRaw !== undefined ? (storedVersion ?? 0) : item[systemFields.version];
2262
+ items.push({
2263
+ Put: snapshotPut(args.tableName, buildSnapshotItem(currentRaw ?? item, version, pkField, skField, args.ttlAttrName, args.now)),
2264
+ });
2265
+ roles.push({ kind: "snapshot", version });
2266
+ }
2267
+ /** The stored attributes that differ from the item read. */
2268
+ const changedFrom = (stored) => guard.inputs.filter((attr) => !attributeValueEquals(stored[attr], current?.[attr]));
2269
+ /** A lost race on the main item: the error once retries run out. */
2270
+ const lostRace = (stored) => ({
2271
+ _tag: "Retry",
2272
+ stored,
2273
+ error: systemFields.version
2274
+ ? new OptimisticLockError({
2275
+ entityType,
2276
+ key: args.key,
2277
+ expectedVersion: storedVersion ?? 0,
2278
+ actualVersion: storedVersionOf(stored) ?? -1,
2279
+ })
2280
+ : new ConcurrentModification({
2281
+ entityType,
2282
+ key: args.key,
2283
+ attributes: stored !== undefined && current !== undefined
2284
+ ? changedFrom(stored).map(domainNameOf)
2285
+ : [],
2286
+ current: Option.none(),
2287
+ }),
2288
+ });
2289
+ const verdict = (reasons) => {
2290
+ const main = reasons[0];
2291
+ if (main?.Code === "ConditionalCheckFailed") {
2292
+ const stored = main.Item;
2293
+ // `create`: the item exists, or the caller's condition rejected it.
2294
+ if (args.create)
2295
+ return { _tag: "Condition" };
2296
+ if (current === undefined) {
2297
+ // Still missing: the caller's condition. Created since: a race.
2298
+ return stored === undefined && userCondition !== undefined
2299
+ ? { _tag: "Condition" }
2300
+ : lostRace(stored);
2301
+ }
2302
+ if (stored === undefined)
2303
+ return lostRace(stored);
2304
+ const raced = systemFields.version
2305
+ ? storedVersionOf(stored) !== storedVersion ||
2306
+ (incarnationAttr !== undefined &&
2307
+ !attributeValueEquals(stored[incarnationAttr], current[incarnationAttr]))
2308
+ : changedFrom(stored).length > 0;
2309
+ return raced || userCondition === undefined ? lostRace(stored) : { _tag: "Condition" };
2310
+ }
2311
+ for (let i = 1; i < roles.length; i++) {
2312
+ if (reasons[i]?.Code !== "ConditionalCheckFailed")
898
2313
  continue;
899
- const sentinelItem = {
900
- [pkField]: sentinel.key.pk,
901
- [skField]: sentinel.key.sk,
902
- __edd_e__: `${entityType}._unique.${constraintName}`,
903
- _entity_pk: item[pkField],
904
- _entity_sk: item[skField],
905
- };
906
- const uniqueTtl = resolveUniqueTtl(constraintDef);
907
- if (uniqueTtl !== undefined) {
908
- sentinelItem[ttlAttrName] = DateTime.toEpochSeconds(now) + normalizeTtlSeconds(uniqueTtl);
2314
+ const role = roles[i];
2315
+ switch (role.kind) {
2316
+ case "sentinel":
2317
+ return {
2318
+ _tag: "Fail",
2319
+ error: new UniqueConstraintViolation({
2320
+ entityType,
2321
+ constraint: role.constraintName,
2322
+ fields: role.fields,
2323
+ }),
2324
+ };
2325
+ case "snapshot":
2326
+ // A missing item's snapshot: history the read did not see — retried
2327
+ // past it. A present item's: its version disagrees with its history.
2328
+ return current === undefined
2329
+ ? lostRace(undefined)
2330
+ : { _tag: "Fail", error: historyConflict(role.version, args.operation) };
2331
+ case "release":
2332
+ // The reservation changed hands since it was read.
2333
+ return {
2334
+ _tag: "Retry",
2335
+ stored: current,
2336
+ error: releaseRaced(args.key, role.constraintName),
2337
+ };
909
2338
  }
910
- out.push({
911
- kind: "sentinel",
912
- constraintName,
913
- fields: sentinel.fieldsRecord,
914
- item: sentinelItem,
915
- // The guard IS the constraint — without it the sentinel would happily
916
- // overwrite another row's reservation and enforce nothing.
917
- guard: sentinelGuard(),
918
- });
2339
+ }
2340
+ return undefined;
2341
+ };
2342
+ return { items, item, marshalled, verdict };
2343
+ });
2344
+ /**
2345
+ * Run a guarded put to completion (#133): plan it from a fresh read, write
2346
+ * it, and on a lost race — a concurrent create, replace or delete of the
2347
+ * item, or retained history the read did not see — plan and write it again.
2348
+ * A put replaces the whole item, so the last writer wins, as a plain
2349
+ * `PutItem` would. Only the caller's own condition, a taken unique value or
2350
+ * a history conflict fail it; a race lost on every attempt fails with the
2351
+ * concurrency error.
2352
+ */
2353
+ const runGuardedPut = (args) => Effect.gen(function* () {
2354
+ const client = yield* DynamoClient;
2355
+ let lost;
2356
+ for (let attempt = 0; attempt < GUARDED_PUT_ATTEMPTS; attempt++) {
2357
+ const plan = yield* planPut(args);
2358
+ yield* checkTransactionLimit(entityType, args.operation, plan.items);
2359
+ const write = plan.items.length === 1
2360
+ ? client.putItem(plan.items[0].Put)
2361
+ : client.transactWriteItems({ TransactItems: [...plan.items] });
2362
+ const verdict = yield* write.pipe(Effect.as(undefined), Effect.catch((err) => {
2363
+ const reasons = isAwsTransactionCancelled(err.cause)
2364
+ ? (err.cause.CancellationReasons ?? [])
2365
+ : isAwsConditionalCheckFailed(err.cause)
2366
+ ? [{ Code: "ConditionalCheckFailed", Item: err.cause.Item }]
2367
+ : undefined;
2368
+ const read = reasons !== undefined ? plan.verdict(reasons) : undefined;
2369
+ return read !== undefined ? Effect.succeed(read) : Effect.fail(err);
2370
+ }));
2371
+ if (verdict === undefined)
2372
+ return plan;
2373
+ switch (verdict._tag) {
2374
+ case "Fail":
2375
+ return yield* verdict.error;
2376
+ case "Condition":
2377
+ return yield* new ConditionalCheckFailed({ entityType, key: args.key });
2378
+ case "Retry":
2379
+ lost = verdict;
919
2380
  }
920
2381
  }
921
- if (isRetainEnabled()) {
922
- out.push({
923
- kind: "snapshot",
924
- item: buildSnapshotItem(item, 1, pkField, skField, ttlAttrName, now),
925
- });
926
- }
927
- return out;
928
- };
2382
+ return yield* withCurrentItem(Effect.fail(lost.error), () => lost.stored);
2383
+ });
929
2384
  // ---------------------------------------------------------------------------
930
2385
  // Ref hydration
931
2386
  // ---------------------------------------------------------------------------
@@ -1177,7 +2632,9 @@ const makeImpl = (config, precomputedData) => {
1177
2632
  const tc = yield* tableTag;
1178
2633
  const tableName = tc.name;
1179
2634
  const ttlAttrName = resolveTtlAttributeName(tc);
1180
- yield* checkWithVectorNames(opts.withVectors, "put");
2635
+ // `upsert` creates through this path: its errors name it.
2636
+ const operation = opts.operation ?? "put";
2637
+ yield* checkWithVectorNames(opts.withVectors, operation);
1181
2638
  // Auto-generated id: fill the configured primary-key field with a
1182
2639
  // fresh UUID BEFORE the encode, but only when the caller omitted it —
1183
2640
  // a caller-supplied value is always respected. The id is sourced from
@@ -1185,7 +2642,9 @@ const makeImpl = (config, precomputedData) => {
1185
2642
  // so this keeps the bound `put` at `R = never`. The filled id then
1186
2643
  // flows through input validation → key composition → stored item →
1187
2644
  // returned record in this same pass.
1188
- const inputWithGeneratedId = yield* fillGeneratedId(input);
2645
+ const withGeneratedId = yield* fillGeneratedId(input);
2646
+ const unsentineled = unsentineledDefaults(withGeneratedId);
2647
+ const inputWithGeneratedId = yield* fillDecodingDefaults(withGeneratedId);
1189
2648
  // Encode user input → wire form. Users typically construct domain
1190
2649
  // values for transforms (DateTime, Redacted, Number) and plain
1191
2650
  // objects for Schema.Class fields. To handle both:
@@ -1198,7 +2657,7 @@ const makeImpl = (config, precomputedData) => {
1198
2657
  // instances) and the substituted date/Redacted transforms
1199
2658
  // tolerate either form. The subsequent `encode` produces
1200
2659
  // the canonical wire shape.
1201
- const encodedInput = yield* encodeOrDecodeEncode(schemas.inputSchema, inputWithGeneratedId, entityType, "put");
2660
+ const encodedInput = yield* encodeOrDecodeEncode(schemas.inputSchema, inputWithGeneratedId, entityType, operation);
1202
2661
  // Compose all keys from the encoded input (ID fields still present
1203
2662
  // and key composites are wire-format strings/numbers).
1204
2663
  const keys = composeAllKeys(encodedInput);
@@ -1229,8 +2688,13 @@ const makeImpl = (config, precomputedData) => {
1229
2688
  }
1230
2689
  if (systemFields.version)
1231
2690
  item[systemFields.version] = 1;
2691
+ if (stampsIncarnation)
2692
+ item[INCARNATION_TOKEN] = yield* freshIncarnation;
1232
2693
  // Add entity type discriminator
1233
2694
  item.__edd_e__ = entityType;
2695
+ // Unique fields holding only a default: no sentinel for them (#133).
2696
+ if (unsentineled.length > 0)
2697
+ item[UNSENTINELED_DEFAULTS] = new Set(unsentineled);
1234
2698
  // Apply composed keys
1235
2699
  Object.assign(item, keys);
1236
2700
  // Vector search: embed the source text and compose the partition
@@ -1243,6 +2707,15 @@ const makeImpl = (config, precomputedData) => {
1243
2707
  for (const field of vectorWrite.removes)
1244
2708
  delete item[field];
1245
2709
  }
2710
+ const hasUniqueConstraints = config.unique != null && Object.keys(config.unique).length > 0;
2711
+ // A versioned or unique-constrained entity's put is guarded (#133):
2712
+ // a put over an existing item continues its version sequence,
2713
+ // incarnation and `createdAt`, snapshots the replaced item (retain)
2714
+ // and rotates its unique sentinels — never resetting history or
2715
+ // orphaning one. See `runGuardedPut`.
2716
+ const guarded = hasUniqueConstraints || Boolean(systemFields.version);
2717
+ const createdAtSupplied = systemFields.createdAt !== null &&
2718
+ encoded[systemFields.createdAt] !== undefined;
1246
2719
  // Rename domain fields to DynamoDB attribute names
1247
2720
  renameToDynamo(item);
1248
2721
  // Flatten sparse Map fields into per-entry top-level attributes.
@@ -1253,122 +2726,39 @@ const makeImpl = (config, precomputedData) => {
1253
2726
  catch (e) {
1254
2727
  return yield* new ValidationError({
1255
2728
  entityType,
1256
- operation: "put.sparse",
2729
+ operation: `${operation}.sparse`,
1257
2730
  cause: e instanceof Error ? e.message : String(e),
1258
2731
  });
1259
2732
  }
1260
- const marshalledItem = toAttributeMap(item);
1261
- // Build user condition expression if provided
1262
- const userCondition = opts.condition
1263
- ? compileCondition(opts.condition, resolveDbName)
1264
- : undefined;
1265
- const hasUniqueConstraints = config.unique != null && Object.keys(config.unique).length > 0;
1266
- const needsTransaction = hasUniqueConstraints || isRetainEnabled();
1267
- if (needsTransaction) {
1268
- // Use transactWriteItems for atomic put + sentinel creation + snapshot
1269
- const transactItems = [];
1270
- // Main entity item (with optional user condition)
1271
- const mainPut = {
1272
- TableName: tableName,
1273
- Item: marshalledItem,
1274
- };
1275
- if (userCondition) {
1276
- mainPut.ConditionExpression = userCondition.expression;
1277
- mainPut.ExpressionAttributeNames = userCondition.names;
1278
- if (Object.keys(userCondition.values).length > 0) {
1279
- mainPut.ExpressionAttributeValues = userCondition.values;
1280
- }
1281
- }
1282
- transactItems.push({ Put: mainPut });
1283
- // Unique constraint sentinels (sparse — skip when any composing field is missing)
1284
- const sentinelConstraints = [];
1285
- if (hasUniqueConstraints) {
1286
- for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
1287
- const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, encoded);
1288
- if (!sentinel)
1289
- continue;
1290
- sentinelConstraints.push(constraintName);
1291
- // Optional TTL — when the constraint declares one, the sentinel
1292
- // auto-expires (time-bounded uniqueness reservation/hold). Uses
1293
- // the table's resolved TTL attribute name + Clock-backed `now`.
1294
- const sentinelItem = {
1295
- [config.indexes.primary.pk.field]: sentinel.key.pk,
1296
- [config.indexes.primary.sk.field]: sentinel.key.sk,
1297
- __edd_e__: `${entityType}._unique.${constraintName}`,
1298
- _entity_pk: keys[config.indexes.primary.pk.field],
1299
- _entity_sk: keys[config.indexes.primary.sk.field],
1300
- };
1301
- const uniqueTtl = resolveUniqueTtl(constraintDef);
1302
- if (uniqueTtl !== undefined) {
1303
- sentinelItem[ttlAttrName] =
1304
- DateTime.toEpochSeconds(now) + normalizeTtlSeconds(uniqueTtl);
1305
- }
1306
- transactItems.push({
1307
- Put: {
1308
- TableName: tableName,
1309
- Item: toAttributeMap(sentinelItem),
1310
- ...sentinelGuard(),
1311
- },
1312
- });
1313
- }
1314
- }
1315
- // Version snapshot (v1) when retain is enabled
1316
- if (isRetainEnabled()) {
1317
- const snapshotItem = buildSnapshotItem(item, 1, config.indexes.primary.pk.field, config.indexes.primary.sk.field, ttlAttrName, now);
1318
- transactItems.push({
1319
- Put: {
1320
- TableName: tableName,
1321
- Item: toAttributeMap(snapshotItem),
1322
- },
1323
- });
2733
+ // The caller's condition, ANDed onto `create`'s not-exists guard —
2734
+ // a guard no `.condition()` can replace (#133). `upsert`'s create
2735
+ // attempt brings its own (`runGuardedPut` requires the item missing).
2736
+ const condition = withGuard(opts.putKind === "create" && operation !== "upsert"
2737
+ ? {
2738
+ attributeNotExists: [
2739
+ config.indexes.primary.pk.field,
2740
+ config.indexes.primary.sk.field,
2741
+ ],
1324
2742
  }
1325
- yield* checkTransactionLimit(entityType, "put", transactItems);
1326
- yield* client
1327
- .transactWriteItems({
1328
- TransactItems: transactItems.map((t) => ({ Put: t.Put })),
1329
- })
1330
- .pipe(Effect.mapError((err) => {
1331
- // Check if this is a transaction cancellation
1332
- if (isAwsTransactionCancelled(err.cause)) {
1333
- const reasons = err.cause.CancellationReasons;
1334
- if (reasons) {
1335
- // Index 0 is the main item — if it failed with user condition, return ConditionalCheckFailed
1336
- if (opts.condition && reasons[0]?.Code === "ConditionalCheckFailed") {
1337
- return new ConditionalCheckFailed({
1338
- entityType,
1339
- key: encoded,
1340
- });
1341
- }
1342
- // Sentinel items start at index 1, in the order recorded in
1343
- // `sentinelConstraints` (skipped sparse entries are absent)
1344
- for (let i = 1; i < reasons.length; i++) {
1345
- if (reasons[i]?.Code === "ConditionalCheckFailed") {
1346
- const constraintName = sentinelConstraints[i - 1] ?? "unknown";
1347
- const constraintDef = config.unique?.[constraintName];
1348
- const uniqueFields = constraintDef
1349
- ? resolveUniqueFields(constraintDef)
1350
- : [];
1351
- const fieldsRecord = {};
1352
- for (const f of uniqueFields) {
1353
- const v = encoded[f];
1354
- if (v !== undefined && v !== null) {
1355
- fieldsRecord[f] = KeyComposer.serializeValue(v);
1356
- }
1357
- }
1358
- return new UniqueConstraintViolation({
1359
- entityType,
1360
- constraint: constraintName,
1361
- fields: fieldsRecord,
1362
- });
1363
- }
1364
- }
1365
- }
1366
- }
1367
- return err;
1368
- }));
2743
+ : undefined, opts.condition);
2744
+ const userCondition = condition ? compileCondition(condition, resolveDbName) : undefined;
2745
+ if (guarded) {
2746
+ const plan = yield* runGuardedPut({
2747
+ tableName,
2748
+ ttlAttrName,
2749
+ now,
2750
+ item,
2751
+ createdAtSupplied,
2752
+ userCondition,
2753
+ create: opts.putKind === "create",
2754
+ operation,
2755
+ key: encoded,
2756
+ });
2757
+ return yield* decodeAs(plan.item, plan.marshalled, mode);
1369
2758
  }
1370
- else {
1371
- // Simple put without unique constraints or retain
2759
+ const marshalledItem = toAttributeMap(item);
2760
+ {
2761
+ // Simple put without unique constraints or versioning
1372
2762
  const putInput = {
1373
2763
  TableName: tableName,
1374
2764
  Item: marshalledItem,
@@ -1381,7 +2771,7 @@ const makeImpl = (config, precomputedData) => {
1381
2771
  }
1382
2772
  }
1383
2773
  yield* client.putItem(putInput).pipe(Effect.mapError((err) => {
1384
- if (opts.condition && isAwsConditionalCheckFailed(err.cause)) {
2774
+ if (condition && isAwsConditionalCheckFailed(err.cause)) {
1385
2775
  return new ConditionalCheckFailed({
1386
2776
  entityType,
1387
2777
  key: encoded,
@@ -1396,19 +2786,20 @@ const makeImpl = (config, precomputedData) => {
1396
2786
  // ---------------------------------------------------------------------------
1397
2787
  // create operation — put + attribute_not_exists condition
1398
2788
  // ---------------------------------------------------------------------------
2789
+ // The not-exists guard is the put builder's, from `putKind: "create"` —
2790
+ // never the op's condition, which `.condition()` replaces (#133).
1399
2791
  const create = (input) => {
1400
- const pkField = config.indexes.primary.pk.field;
1401
- const skField = config.indexes.primary.sk.field;
1402
2792
  const op = put(input);
1403
- return new EntityPutImpl(op._builder, op._entity, op._input, { attributeNotExists: [pkField, skField] }, op._withVectors, "create");
2793
+ return new EntityPutImpl(op._builder, op._entity, op._input, op._condition, op._withVectors, "create");
1404
2794
  };
1405
2795
  // ---------------------------------------------------------------------------
1406
2796
  // patch operation — update + attribute_exists condition
1407
2797
  // ---------------------------------------------------------------------------
2798
+ // The exists guard is `runUpdate`'s, from `patch: true` — never the op's
2799
+ // condition, which `.condition()` replaces (#133).
1408
2800
  const patch = (key) => {
1409
- const pkField = config.indexes.primary.pk.field;
1410
2801
  const op = update(key);
1411
- return new EntityUpdateImpl(op._builder, { ...op._updateState, condition: { attributeExists: [pkField] } }, op._entity, op._key);
2802
+ return new EntityUpdateImpl(op._builder, { ...op._updateState, patch: true }, op._entity, op._key);
1412
2803
  };
1413
2804
  // ---------------------------------------------------------------------------
1414
2805
  // get operation
@@ -1452,10 +2843,63 @@ const makeImpl = (config, precomputedData) => {
1452
2843
  // ---------------------------------------------------------------------------
1453
2844
  // update operation
1454
2845
  // ---------------------------------------------------------------------------
1455
- const update = (key) => new EntityUpdateImpl((mode, uState) => Effect.gen(function* () {
2846
+ const runUpdate = (key, mode, requested,
2847
+ /**
2848
+ * The item as already read — consistently — by the caller (`upsert`).
2849
+ * Used instead of reading it again: the write is guarded on it either way.
2850
+ */
2851
+ preRead) => Effect.gen(function* () {
2852
+ // Path operations on a field that feeds a key or a unique sentinel
2853
+ // become the record operations they are equivalent to, or are
2854
+ // refused (#133) — before anything reads the state.
2855
+ const normalized = normalizeDerivedPathOps(requested);
2856
+ if (typeof normalized === "string") {
2857
+ return yield* new ValidationError({
2858
+ entityType,
2859
+ operation: "update",
2860
+ cause: normalized,
2861
+ });
2862
+ }
2863
+ // Removing a defaulted index composite re-materialises its default —
2864
+ // stored, keys recomposed from it — as a put that omits it does (#133).
2865
+ const { state: uState, defaulted: removedToDefault } = yield* rematerializeRemovedDefaults(normalized);
2866
+ const refusedSet = refusedRecordSet(uState.updates, key);
2867
+ if (refusedSet !== undefined) {
2868
+ return yield* new ValidationError({
2869
+ entityType,
2870
+ operation: "update",
2871
+ cause: refusedSet,
2872
+ });
2873
+ }
2874
+ const immutables = assertedImmutables(uState.updates);
2875
+ if (typeof immutables === "string") {
2876
+ return yield* new ValidationError({
2877
+ entityType,
2878
+ operation: "update",
2879
+ cause: immutables,
2880
+ });
2881
+ }
2882
+ const upsertable = completeUpsertPayload(uState.updates);
2883
+ const requestedRv = uState.returnValues ?? "allNew";
2884
+ // A cascade propagates the NEW item; a mode returning the OLD one then
2885
+ // needs both images exactly — the read-then-write branch has both.
2886
+ const needsBothImages = uState.cascade !== undefined && (requestedRv === "allOld" || requestedRv === "updatedOld");
1456
2887
  const updates = uState.updates;
1457
2888
  const evExpected = uState.expectedVersion;
1458
- const userCond = uState.condition;
2889
+ // An entity without `versioned` has no version to check: an expected
2890
+ // version would be silently skipped — a concurrency check that never
2891
+ // runs — so it is refused before anything is read or sent (#134).
2892
+ if (evExpected !== undefined && !systemFields.version) {
2893
+ return yield* new ValidationError({
2894
+ entityType,
2895
+ operation: "update.expectedVersion",
2896
+ cause: `expectedVersion(${evExpected}): entity "${entityType}" is not versioned, so there ` +
2897
+ "is no version to check. Add `versioned: true`, or use a `.condition()`. Nothing was sent.",
2898
+ });
2899
+ }
2900
+ // The caller's condition, ANDed onto `patch`'s exists guard — a guard
2901
+ // no `.condition()` can replace (#133).
2902
+ const userCond = withGuard(uState.patch ? { attributeExists: [config.indexes.primary.pk.field] } : undefined, uState.condition);
1459
2903
  const client = yield* DynamoClient;
1460
2904
  const tc = yield* tableTag;
1461
2905
  const tableName = tc.name;
@@ -1465,14 +2909,22 @@ const makeImpl = (config, precomputedData) => {
1465
2909
  yield* checkWithVectorNames(uState.withVectors, "update");
1466
2910
  // Caller key: Type side in, ENCODED out (see `encodeKey`).
1467
2911
  const encodedKey = yield* encodeKey(key, "update.decodeKey");
2912
+ // A missing item: `patch`'s exists guard failed — `ConditionalCheckFailed`,
2913
+ // on every path, including those that read first (#134); for `update`,
2914
+ // `ItemNotFound`.
2915
+ const missingItem = () => uState.patch
2916
+ ? new ConditionalCheckFailed({
2917
+ entityType,
2918
+ key: encodedKey,
2919
+ })
2920
+ : new ItemNotFound({ entityType, key: encodedKey });
1468
2921
  // Encode update payload → wire form (see `put` for the strategy).
1469
2922
  const encodedUpdates = yield* encodeOrDecodeEncode(schemas.updateSchema, updates ?? {}, entityType, "update");
1470
2923
  // Hydrate refs in updates: for any ${field}Id present, fetch and embed
1471
2924
  let hydratedUpdates = encodedUpdates;
1472
2925
  if (hasRefs) {
1473
2926
  const refsToHydrate = resolvedRefs.filter((ref) => ref.idFieldName in encodedUpdates &&
1474
- encodedUpdates[ref.idFieldName] !==
1475
- undefined);
2927
+ encodedUpdates[ref.idFieldName] !== undefined);
1476
2928
  if (refsToHydrate.length > 0) {
1477
2929
  hydratedUpdates = yield* hydrateRefs(hydratedUpdates, refsToHydrate);
1478
2930
  }
@@ -1495,19 +2947,69 @@ const makeImpl = (config, precomputedData) => {
1495
2947
  ]);
1496
2948
  touchesUniqueFields = [...uniqueFieldSet].some((f) => allUpdatedFields.has(f));
1497
2949
  }
1498
- if (isRetainEnabled() || touchesUniqueFields) {
2950
+ // `.add()` / `.subtract()` / `.append()` / `.deleteFromSet()` on an
2951
+ // index composite: DynamoDB would compute the new value, but the
2952
+ // index key is composed from it here — so the item is read, the
2953
+ // value computed and the keys recomposed by the read-then-write
2954
+ // branch below, exactly as for a retain entity (#133).
2955
+ const indexComposites = indexCompositeFields();
2956
+ const computedComposites = [
2957
+ ...Object.keys(uState.add ?? {}),
2958
+ ...Object.keys(uState.subtract ?? {}),
2959
+ ...Object.keys(uState.append ?? {}),
2960
+ ...Object.keys(uState.deleteFromSet ?? {}),
2961
+ ].filter((field) => indexComposites.has(field));
2962
+ // Path operations are never emulated in memory: DynamoDB applies them
2963
+ // (#133). A retain update carrying them takes the standard branch
2964
+ // below, transacted with its version snapshot. An update that also
2965
+ // rotates a unique sentinel or computes an index composite needs this
2966
+ // branch's read-then-put, which cannot carry them — refused rather
2967
+ // than silently dropping them.
2968
+ const pathOpsPresent = hasPathOps(uState);
2969
+ if (pathOpsPresent && (touchesUniqueFields || computedComposites.length > 0)) {
2970
+ return yield* new ValidationError({
2971
+ entityType,
2972
+ operation: "update",
2973
+ cause: touchesUniqueFields
2974
+ ? "Path operations (pathSet, pathAppend, …) cannot be combined with a change to a " +
2975
+ "unique-constraint field in one update: the unique sentinels are rotated by a " +
2976
+ "read-then-put that DynamoDB path expressions cannot join. Split the update in two."
2977
+ : `Path operations (pathSet, pathAppend, …) cannot be combined with a computed ` +
2978
+ `change to index composite "${computedComposites[0]}" in one update: its new ` +
2979
+ "value is computed by a read-then-put that DynamoDB path expressions cannot " +
2980
+ "join. Split the update in two.",
2981
+ });
2982
+ }
2983
+ if (needsBothImages && pathOpsPresent && !isRetainEnabled() && !systemFields.version) {
2984
+ return yield* new ValidationError({
2985
+ entityType,
2986
+ operation: "update",
2987
+ cause: 'A cascade with returnValues("allOld" | "updatedOld") and path operations ' +
2988
+ "needs the item before AND after the update exactly, which an entity without " +
2989
+ "`versioned` cannot prove. Drop the path operations, the cascade, or the mode.",
2990
+ });
2991
+ }
2992
+ if ((isRetainEnabled() ||
2993
+ touchesUniqueFields ||
2994
+ computedComposites.length > 0 ||
2995
+ needsBothImages) &&
2996
+ !pathOpsPresent) {
1499
2997
  // --- Retain path: read-then-transact ---
1500
2998
  // Read current item (needed to create snapshot of pre-update state)
1501
- const currentResult = yield* client.getItem({
1502
- TableName: tableName,
1503
- Key: marshalledKey,
1504
- });
2999
+ const currentResult = preRead !== undefined
3000
+ ? { Item: preRead }
3001
+ : yield* client.getItem({
3002
+ TableName: tableName,
3003
+ Key: marshalledKey,
3004
+ ConsistentRead: true,
3005
+ });
1505
3006
  if (!currentResult.Item) {
1506
- return yield* new ItemNotFound({ entityType, key: encodedKey });
3007
+ return yield* missingItem();
1507
3008
  }
1508
3009
  const currentRaw = fromAttributeMap(currentResult.Item);
3010
+ // 0: written before the entity was `versioned` (#133).
1509
3011
  const currentVersion = systemFields.version
1510
- ? currentRaw[systemFields.version]
3012
+ ? (currentRaw[systemFields.version] ?? 0)
1511
3013
  : 0;
1512
3014
  // Validate optimistic lock if requested
1513
3015
  if (evExpected !== undefined && currentVersion !== evExpected) {
@@ -1600,7 +3102,15 @@ const makeImpl = (config, precomputedData) => {
1600
3102
  if (uState.append) {
1601
3103
  for (const [attr, val] of Object.entries(uState.append)) {
1602
3104
  const existing = newItem[attr] ?? [];
1603
- newItem[attr] = [...existing, ...val];
3105
+ const appended = encodePathValue([attr], val, "elements");
3106
+ if (appended.issue !== undefined) {
3107
+ return yield* new ValidationError({
3108
+ entityType,
3109
+ operation: "update.append",
3110
+ cause: appended.issue,
3111
+ });
3112
+ }
3113
+ newItem[attr] = [...existing, ...appended.encoded];
1604
3114
  }
1605
3115
  }
1606
3116
  if (uState.deleteFromSet) {
@@ -1613,6 +3123,31 @@ const makeImpl = (config, precomputedData) => {
1613
3123
  }
1614
3124
  }
1615
3125
  }
3126
+ // A unique field that held only a default gets its sentinel once a
3127
+ // write supplies (or removes) its value (#133).
3128
+ const defaultedMarker = newItem[UNSENTINELED_DEFAULTS];
3129
+ if (defaultedMarker instanceof Set) {
3130
+ const supplied = new Set([
3131
+ ...Object.keys(hydratedUpdates),
3132
+ ...(uState.remove ?? []),
3133
+ ...Object.keys(uState.add ?? {}),
3134
+ ...Object.keys(uState.subtract ?? {}),
3135
+ ]);
3136
+ const still = [...defaultedMarker].filter((f) => !supplied.has(f));
3137
+ if (still.length === 0)
3138
+ delete newItem[UNSENTINELED_DEFAULTS];
3139
+ else
3140
+ newItem[UNSENTINELED_DEFAULTS] = new Set(still);
3141
+ }
3142
+ // …and a unique field removed back to its default holds only a default.
3143
+ const uniqueRemovedToDefault = removedToDefault.filter((field) => Object.values(config.unique ?? {}).some((def) => resolveUniqueFields(def).includes(field)));
3144
+ if (uniqueRemovedToDefault.length > 0) {
3145
+ const marker = newItem[UNSENTINELED_DEFAULTS];
3146
+ newItem[UNSENTINELED_DEFAULTS] = new Set([
3147
+ ...(marker instanceof Set ? marker : []),
3148
+ ...uniqueRemovedToDefault,
3149
+ ]);
3150
+ }
1616
3151
  // Increment version and update timestamp. If the caller supplied a
1617
3152
  // value for `updatedAt` (allowed when the field collides with a
1618
3153
  // model-declared field), respect it (already wire-form via encode);
@@ -1657,10 +3192,7 @@ const makeImpl = (config, precomputedData) => {
1657
3192
  // `newItem` and drop both keys when any composite is missing.
1658
3193
  // For touched halves, trust the composer's per-half decision
1659
3194
  // (SET / REMOVE / leave-stored-value-on-newItem-alone for noop).
1660
- const addressed = new Set([
1661
- ...Object.keys(gsiUpdate.sets),
1662
- ...gsiUpdate.removes,
1663
- ]);
3195
+ const addressed = new Set([...Object.keys(gsiUpdate.sets), ...gsiUpdate.removes]);
1664
3196
  for (const [indexName, indexDef] of Object.entries(allIndexes)) {
1665
3197
  if (indexName === "primary")
1666
3198
  continue;
@@ -1727,137 +3259,325 @@ const makeImpl = (config, precomputedData) => {
1727
3259
  cause: e instanceof Error ? e.message : String(e),
1728
3260
  });
1729
3261
  }
3262
+ const currentItem = currentResult.Item;
3263
+ yield* checkVersion(currentItem, "update");
3264
+ const immutableChange = immutableMismatch(immutables, currentItem);
3265
+ if (immutableChange !== undefined) {
3266
+ return yield* new ValidationError({
3267
+ entityType,
3268
+ operation: "update",
3269
+ cause: immutableChange,
3270
+ });
3271
+ }
3272
+ const pkField = config.indexes.primary.pk.field;
3273
+ const skField = config.indexes.primary.sk.field;
3274
+ // A legacy item of an entity whose incarnation is proven by a
3275
+ // hidden token gets one now, so the write is provable from here on.
3276
+ if (stampsIncarnation && currentItem[INCARNATION_TOKEN] === undefined) {
3277
+ newItem[INCARNATION_TOKEN] = yield* freshIncarnation;
3278
+ }
1730
3279
  const marshalledNewItem = toAttributeMap(newItem);
1731
- // Build snapshot of the pre-update state (only when retain is enabled)
1732
- const snapshotItem = isRetainEnabled()
1733
- ? buildSnapshotItem(currentRaw, currentVersion, config.indexes.primary.pk.field, config.indexes.primary.sk.field, ttlAttrName, now)
1734
- : undefined;
1735
- const transactItems = [];
1736
- // Put new item with version condition + optional user condition
1737
- {
1738
- const retainCondParts = [];
1739
- const retainNames = {};
1740
- const retainValues = {};
1741
- if (systemFields.version) {
1742
- retainNames["#ver"] = systemFields.version;
1743
- retainValues[":expectedVer"] = toAttributeValue(currentVersion);
1744
- retainCondParts.push("#ver = :expectedVer");
1745
- }
1746
- if (userCond) {
1747
- const uc = compileCondition(userCond, resolveDbName);
1748
- retainCondParts.push(`(${uc.expression})`);
1749
- Object.assign(retainNames, uc.names);
1750
- Object.assign(retainValues, uc.values);
3280
+ // The write is a guarded Update of what changed — never a Put of
3281
+ // the whole item read, which would overwrite whatever another
3282
+ // writer changed in between (#133). Attributes the update names
3283
+ // are written even when unchanged; the rest only when they differ.
3284
+ const named = new Set([
3285
+ ...Object.keys(hydratedUpdates),
3286
+ ...(uState.remove ?? []),
3287
+ ...Object.keys(uState.add ?? {}),
3288
+ ...Object.keys(uState.subtract ?? {}),
3289
+ ...Object.keys(uState.append ?? {}),
3290
+ ...Object.keys(uState.deleteFromSet ?? {}),
3291
+ ].map(resolveDbName));
3292
+ if (systemFields.version)
3293
+ named.add(systemFields.version);
3294
+ if (systemFields.updatedAt)
3295
+ named.add(systemFields.updatedAt);
3296
+ const setAttrs = {};
3297
+ for (const [attr, value] of Object.entries(marshalledNewItem)) {
3298
+ if (attr === pkField || attr === skField)
3299
+ continue;
3300
+ if (named.has(attr) || !attributeValueEquals(currentItem[attr], value)) {
3301
+ setAttrs[attr] = value;
1751
3302
  }
1752
- const mainPut = {
1753
- TableName: tableName,
1754
- Item: marshalledNewItem,
3303
+ }
3304
+ const removeAttrs = Object.keys(currentItem).filter((attr) => !(attr in marshalledNewItem));
3305
+ const written = new Set([...Object.keys(setAttrs), ...removeAttrs]);
3306
+ const writeNames = {};
3307
+ const writeValues = {};
3308
+ const setClauses = Object.entries(setAttrs).map(([attr, value], i) => {
3309
+ writeNames[`#w${i}`] = attr;
3310
+ writeValues[`:w${i}`] = value;
3311
+ return `#w${i} = :w${i}`;
3312
+ });
3313
+ const removeClauses = removeAttrs.map((attr, i) => {
3314
+ writeNames[`#x${i}`] = attr;
3315
+ return `#x${i}`;
3316
+ });
3317
+ if (setClauses.length === 0 && removeClauses.length === 0) {
3318
+ // Nothing changed: an idempotent write keeps the condition (and
3319
+ // the transaction's other items) meaningful.
3320
+ writeNames["#we"] = "__edd_e__";
3321
+ writeValues[":we"] = currentItem.__edd_e__ ?? toAttributeValue(entityType);
3322
+ setClauses.push("#we = :we");
3323
+ }
3324
+ const updateExpression = [
3325
+ setClauses.length > 0 ? `SET ${setClauses.join(", ")}` : "",
3326
+ removeClauses.length > 0 ? `REMOVE ${removeClauses.join(", ")}` : "",
3327
+ ]
3328
+ .filter((part) => part !== "")
3329
+ .join(" ");
3330
+ // The condition proves the item is still what the new values were
3331
+ // computed from. Versioned: its version and incarnation. Otherwise:
3332
+ // it exists, and every attribute whose read value fed a computed
3333
+ // value is unchanged — so an unrelated concurrent write survives
3334
+ // and a concurrent change to an input is refused.
3335
+ const condParts = [];
3336
+ const inputs = [];
3337
+ if (systemFields.version) {
3338
+ condParts.push(versionIs(currentVersion, writeNames, writeValues, "#ver", ":expectedVer"));
3339
+ const incarnation = incarnationGuard(currentItem, writeNames, writeValues);
3340
+ if (incarnation !== undefined)
3341
+ condParts.push(incarnation);
3342
+ }
3343
+ else {
3344
+ writeNames["#pk"] = pkField;
3345
+ condParts.push("attribute_exists(#pk)");
3346
+ const fromPayload = new Set([
3347
+ ...Object.entries(hydratedUpdates)
3348
+ .filter(([, value]) => value !== undefined && value !== null)
3349
+ .map(([field]) => field),
3350
+ ]);
3351
+ const add = (fields, always = false) => {
3352
+ for (const field of fields) {
3353
+ if ((always || !fromPayload.has(field)) && !inputs.includes(field)) {
3354
+ inputs.push(field);
3355
+ }
3356
+ }
1755
3357
  };
1756
- if (retainCondParts.length > 0) {
1757
- mainPut.ConditionExpression = retainCondParts.join(" AND ");
1758
- mainPut.ExpressionAttributeNames = retainNames;
1759
- mainPut.ExpressionAttributeValues = retainValues;
3358
+ add([
3359
+ ...Object.keys(uState.add ?? {}),
3360
+ ...Object.keys(uState.subtract ?? {}),
3361
+ ...Object.keys(uState.append ?? {}),
3362
+ ...Object.keys(uState.deleteFromSet ?? {}),
3363
+ ], true);
3364
+ for (const [indexName, indexDef] of Object.entries(allIndexes)) {
3365
+ if (indexName === "primary")
3366
+ continue;
3367
+ if (written.has(indexDef.pk.field))
3368
+ add(indexDef.pk.composite);
3369
+ if (written.has(indexDef.sk.field))
3370
+ add(indexDef.sk.composite);
3371
+ }
3372
+ for (const rotation of sentinelRotations) {
3373
+ add(resolveUniqueFields(config.unique[rotation.constraintName]), true);
3374
+ }
3375
+ for (const [, definition] of vectorIndexEntries) {
3376
+ if (written.has(definition.vectorField))
3377
+ add(definition.sourceFields);
3378
+ if (written.has(definition.partitionField))
3379
+ add(definition.partition);
3380
+ }
3381
+ for (const [i, field] of inputs.entries()) {
3382
+ const attr = resolveDbName(field);
3383
+ writeNames[`#g${i}`] = attr;
3384
+ const read = currentItem[attr];
3385
+ if (read === undefined) {
3386
+ condParts.push(`attribute_not_exists(#g${i})`);
3387
+ }
3388
+ else {
3389
+ writeValues[`:g${i}`] = read;
3390
+ condParts.push(`#g${i} = :g${i}`);
3391
+ }
1760
3392
  }
1761
- transactItems.push({ Put: mainPut });
1762
3393
  }
1763
- // Snapshot of pre-update state (only when retain is enabled)
1764
- if (snapshotItem) {
1765
- transactItems.push({
1766
- Put: {
1767
- TableName: tableName,
1768
- Item: toAttributeMap(snapshotItem),
1769
- },
1770
- });
3394
+ const uc = userCond ? compileCondition(userCond, resolveDbName) : undefined;
3395
+ if (uc) {
3396
+ condParts.push(`(${uc.expression})`);
3397
+ Object.assign(writeNames, uc.names);
3398
+ Object.assign(writeValues, uc.values);
1771
3399
  }
1772
- // Apply sentinel rotations computed earlier. Delete and Put are emitted
1773
- // independently — a sparse field that becomes set emits Put only; a sparse
1774
- // field that becomes unset emits Delete only.
1775
- const sentinelPutIndices = [];
1776
- for (const rotation of sentinelRotations) {
1777
- if (rotation.oldUniqueKey) {
1778
- transactItems.push({
1779
- Delete: {
1780
- TableName: tableName,
1781
- Key: toAttributeMap({
1782
- [config.indexes.primary.pk.field]: rotation.oldUniqueKey.pk,
1783
- [config.indexes.primary.sk.field]: rotation.oldUniqueKey.sk,
1784
- }),
1785
- },
1786
- });
3400
+ const mainUpdate = {
3401
+ TableName: tableName,
3402
+ Key: marshalledKey,
3403
+ UpdateExpression: updateExpression,
3404
+ ConditionExpression: condParts.join(" AND "),
3405
+ ExpressionAttributeNames: writeNames,
3406
+ ...(Object.keys(writeValues).length > 0 && {
3407
+ ExpressionAttributeValues: writeValues,
3408
+ }),
3409
+ ReturnValuesOnConditionCheckFailure: "ALL_OLD",
3410
+ };
3411
+ // An update too wide for one expression (DynamoDB: 4 KB, 300
3412
+ // operators — e.g. hundreds of sparse-map entries) writes the whole
3413
+ // item instead, as before #133: under the version condition when
3414
+ // versioned (exact), otherwise under the strongest guard that fits —
3415
+ // the same fallback a wide soft delete uses.
3416
+ const wide = !expressionFits(updateExpression, "update");
3417
+ const wideGuardOrRefusal = wide && !systemFields.version
3418
+ ? deleteGuard(currentItem, "item", condParts.slice(1).join(" AND "))
3419
+ : undefined;
3420
+ if (wideGuardOrRefusal instanceof ValidationError)
3421
+ return yield* wideGuardOrRefusal;
3422
+ const wideGuard = wideGuardOrRefusal;
3423
+ const putCondition = wideGuard === undefined
3424
+ ? condParts.join(" AND ")
3425
+ : [wideGuard.expression, ...condParts.slice(1)].join(" AND ");
3426
+ // Only the placeholders the condition uses (the Put has no update expression).
3427
+ const putTokens = new Set(putCondition.match(/[#:][A-Za-z0-9_]+/g) ?? []);
3428
+ const used = (entries) => Object.fromEntries(Object.entries(entries).filter(([token]) => putTokens.has(token)));
3429
+ const putValues = used({ ...writeValues, ...wideGuard?.values });
3430
+ const mainPut = {
3431
+ TableName: tableName,
3432
+ Item: marshalledNewItem,
3433
+ ConditionExpression: putCondition,
3434
+ ExpressionAttributeNames: used({ ...writeNames, ...wideGuard?.names }),
3435
+ ...(Object.keys(putValues).length > 0 && { ExpressionAttributeValues: putValues }),
3436
+ ReturnValuesOnConditionCheckFailure: "ALL_OLD",
3437
+ };
3438
+ const tooLarge = oversizedCondition("update", wide ? putCondition : mainUpdate.ConditionExpression, userCond !== undefined
3439
+ ? compileCondition(userCond, resolveDbName)?.expression
3440
+ : undefined);
3441
+ if (tooLarge !== undefined)
3442
+ return yield* tooLarge;
3443
+ // The stored attributes the rejection compares: the guarded inputs,
3444
+ // and on the wide fallback whatever else its guard covered.
3445
+ const guardedAttrs = [
3446
+ ...new Set([...inputs.map(resolveDbName), ...(wideGuard?.inputs ?? [])]),
3447
+ ];
3448
+ // A rejected main write: which predicate, from the stored item.
3449
+ const mainRejection = (stored) => {
3450
+ if (systemFields.version) {
3451
+ return conditionRejection(encodedKey, stored, { version: currentVersion, read: currentItem }, userCond !== undefined);
1787
3452
  }
1788
- if (rotation.newUniqueKey && rotation.newFieldsRecord) {
1789
- sentinelPutIndices.push({
1790
- index: transactItems.length,
1791
- constraintName: rotation.constraintName,
1792
- newFieldsRecord: rotation.newFieldsRecord,
1793
- });
1794
- transactItems.push({
1795
- Put: {
1796
- TableName: tableName,
1797
- Item: toAttributeMap({
1798
- [config.indexes.primary.pk.field]: rotation.newUniqueKey.pk,
1799
- [config.indexes.primary.sk.field]: rotation.newUniqueKey.sk,
1800
- __edd_e__: `${entityType}._unique.${rotation.constraintName}`,
1801
- _entity_pk: primaryKey[config.indexes.primary.pk.field],
1802
- _entity_sk: primaryKey[config.indexes.primary.sk.field],
1803
- }),
1804
- ...sentinelGuard(),
1805
- },
1806
- });
3453
+ if (stored === undefined)
3454
+ return missingItem();
3455
+ const changed = guardedAttrs.filter((attr) => !attributeValueEquals(stored[attr], currentItem[attr]));
3456
+ if (changed.length === 0 && userCond) {
3457
+ return new ConditionalCheckFailed({ entityType, key: encodedKey });
1807
3458
  }
3459
+ return new ConcurrentModification({
3460
+ entityType,
3461
+ key: encodedKey,
3462
+ attributes: changed.map(domainNameOf),
3463
+ current: Option.none(),
3464
+ });
3465
+ };
3466
+ let rejectedItem;
3467
+ const recordRejected = (stored) => {
3468
+ rejectedItem = stored;
3469
+ return mainRejection(stored);
3470
+ };
3471
+ // `current` of a ConcurrentModification: the stored item, decoded.
3472
+ const withCurrent = (effect) => effect.pipe(Effect.catchIf((e) => e instanceof ConcurrentModification, (e) => Effect.gen(function* () {
3473
+ const current = rejectedItem === undefined
3474
+ ? Option.none()
3475
+ : yield* Effect.option(decodeAs(fromAttributeMap(rejectedItem), rejectedItem, "model"));
3476
+ return yield* keepReleaseRace(e, new ConcurrentModification({ ...e, current }));
3477
+ })));
3478
+ // Build snapshot of the pre-update state (only when retain is enabled)
3479
+ const snapshotItem = isRetainEnabled()
3480
+ ? buildSnapshotItem(currentRaw, currentVersion, config.indexes.primary.pk.field, config.indexes.primary.sk.field, ttlAttrName, now)
3481
+ : undefined;
3482
+ const rv = uState.returnValues ?? "allNew";
3483
+ // The item before and after the write. Exact: a versioned write is
3484
+ // CAS-guarded on what was read; an unguarded-for-unrelated-attribute
3485
+ // single UpdateItem returns its own image.
3486
+ let oldImage = currentItem;
3487
+ let newImage = marshalledNewItem;
3488
+ if (snapshotItem === undefined && sentinelRotations.length === 0) {
3489
+ // One item: a plain UpdateItem, whose ALL_OLD image plus this
3490
+ // write's own SET / REMOVE is the exact new image as well.
3491
+ const result = yield* withCurrent((wide
3492
+ ? client.putItem({ ...mainPut, ReturnValues: "ALL_OLD" })
3493
+ : client.updateItem({ ...mainUpdate, ReturnValues: "ALL_OLD" })).pipe(Effect.mapError((err) => isAwsConditionalCheckFailed(err.cause) ? recordRejected(err.cause.Item) : err)));
3494
+ oldImage = result.Attributes ?? currentItem;
3495
+ const applied = {
3496
+ ...oldImage,
3497
+ ...setAttrs,
3498
+ };
3499
+ for (const attr of removeAttrs)
3500
+ delete applied[attr];
3501
+ newImage = wide ? marshalledNewItem : applied;
1808
3502
  }
1809
- yield* checkTransactionLimit(entityType, "update", transactItems);
1810
- yield* client
1811
- .transactWriteItems({
1812
- TransactItems: transactItems.map((t) => "Put" in t ? { Put: t.Put } : { Delete: t.Delete }),
1813
- })
1814
- .pipe(Effect.mapError((err) => {
1815
- // The main item's ConditionExpression carries the version CAS
1816
- // (when the entity is versioned) ANDed with any user condition.
1817
- // DynamoDB does not say which half rejected, so attribute the
1818
- // failure to the version CAS whenever one is present, and to
1819
- // the user condition only when it is the sole predicate —
1820
- // otherwise an unversioned entity's `.condition()` rejection
1821
- // would surface as a nonsensical OptimisticLockError.
1822
- const mainItemRejection = () => {
1823
- if (!systemFields.version && userCond) {
1824
- return new ConditionalCheckFailed({ entityType, key: encodedKey });
3503
+ else {
3504
+ const transactItems = [wide ? { Put: mainPut } : { Update: mainUpdate }];
3505
+ // Snapshot of pre-update state (only when retain is enabled)
3506
+ if (snapshotItem) {
3507
+ transactItems.push({ Put: snapshotPut(tableName, snapshotItem) });
3508
+ }
3509
+ // Apply sentinel rotations computed earlier. Delete and Put are emitted
3510
+ // independently — a sparse field that becomes set emits Put only; a sparse
3511
+ // field that becomes unset emits Delete only. A Delete releases only a
3512
+ // sentinel this item owns (#133): the item may hold the old value
3513
+ // without its reservation, which another item may hold.
3514
+ const owner = { pk: primaryKey[pkField], sk: primaryKey[skField] };
3515
+ const owned = new Set(yield* ownedSentinels(tableName, sentinelRotations.flatMap((rotation) => rotation.oldUniqueKey !== undefined ? [rotation.oldUniqueKey] : []), owner));
3516
+ const sentinelPutIndices = [];
3517
+ const releaseIndices = [];
3518
+ for (const rotation of sentinelRotations) {
3519
+ if (rotation.oldUniqueKey && owned.has(rotation.oldUniqueKey)) {
3520
+ releaseIndices.push({
3521
+ index: transactItems.length,
3522
+ constraintName: rotation.constraintName,
3523
+ });
3524
+ transactItems.push(sentinelRelease(tableName, rotation.oldUniqueKey, owner));
1825
3525
  }
1826
- return new OptimisticLockError({
1827
- entityType,
1828
- key: encodedKey,
1829
- expectedVersion: currentVersion,
1830
- actualVersion: -1,
1831
- });
1832
- };
1833
- if (isAwsTransactionCancelled(err.cause)) {
1834
- const reasons = err.cause.CancellationReasons;
1835
- if (reasons) {
1836
- // Check sentinel Puts for unique constraint violations
1837
- for (const { index, constraintName, newFieldsRecord, } of sentinelPutIndices) {
1838
- if (reasons[index]?.Code === "ConditionalCheckFailed") {
1839
- return new UniqueConstraintViolation({
1840
- entityType,
1841
- constraint: constraintName,
1842
- fields: newFieldsRecord,
1843
- });
3526
+ if (rotation.newUniqueKey && rotation.newFieldsRecord) {
3527
+ sentinelPutIndices.push({
3528
+ index: transactItems.length,
3529
+ constraintName: rotation.constraintName,
3530
+ newFieldsRecord: rotation.newFieldsRecord,
3531
+ });
3532
+ transactItems.push({
3533
+ Put: {
3534
+ TableName: tableName,
3535
+ Item: toAttributeMap(sentinelItemFor(rotation.constraintName, config.unique[rotation.constraintName], rotation.newUniqueKey, owner, now, ttlAttrName)),
3536
+ ...sentinelGuard(),
3537
+ },
3538
+ });
3539
+ }
3540
+ }
3541
+ yield* checkTransactionLimit(entityType, "update", transactItems);
3542
+ yield* withCurrent(client.transactWriteItems({ TransactItems: transactItems }).pipe(Effect.mapError((err) => {
3543
+ if (isAwsTransactionCancelled(err.cause)) {
3544
+ const reasons = err.cause.CancellationReasons;
3545
+ if (reasons) {
3546
+ // The main item first: a sentinel Put rejected because
3547
+ // the main write was cancelled reports nothing useful.
3548
+ if (reasons[0]?.Code === "ConditionalCheckFailed") {
3549
+ return recordRejected(reasons[0].Item);
3550
+ }
3551
+ if (snapshotItem && reasons[1]?.Code === "ConditionalCheckFailed") {
3552
+ return historyConflict(currentVersion, "update");
3553
+ }
3554
+ for (const { index, constraintName, newFieldsRecord } of sentinelPutIndices) {
3555
+ if (reasons[index]?.Code === "ConditionalCheckFailed") {
3556
+ return new UniqueConstraintViolation({
3557
+ entityType,
3558
+ constraint: constraintName,
3559
+ fields: newFieldsRecord,
3560
+ });
3561
+ }
3562
+ }
3563
+ for (const { index, constraintName } of releaseIndices) {
3564
+ if (reasons[index]?.Code === "ConditionalCheckFailed") {
3565
+ return releaseRaced(encodedKey, constraintName);
3566
+ }
1844
3567
  }
1845
- }
1846
- // Index 0 is the main item — version conflict or user condition
1847
- if (reasons[0]?.Code === "ConditionalCheckFailed") {
1848
- return mainItemRejection();
1849
3568
  }
1850
3569
  }
1851
- }
1852
- if (isAwsConditionalCheckFailed(err.cause)) {
1853
- return mainItemRejection();
1854
- }
1855
- return err;
1856
- }));
1857
- const retainDecoded = yield* decodeAs(newItem, marshalledNewItem, mode);
1858
- // Execute cascade if configured (retain path)
3570
+ if (isAwsConditionalCheckFailed(err.cause)) {
3571
+ return recordRejected(err.cause.Item);
3572
+ }
3573
+ return err;
3574
+ })));
3575
+ }
3576
+ // Execute cascade if configured (retain path) — from the new item.
1859
3577
  if (uState.cascade) {
1860
- const domainData = { ...retainDecoded };
3578
+ const domainData = {
3579
+ ...(yield* decodeAs(fromAttributeMap(newImage), newImage, "model")),
3580
+ };
1861
3581
  const sourceId = sourceIdentifierField != null
1862
3582
  ? encodedKey[sourceIdentifierField]
1863
3583
  : undefined;
@@ -1865,9 +3585,83 @@ const makeImpl = (config, precomputedData) => {
1865
3585
  yield* executeCascade(uState.cascade, domainData, String(sourceId));
1866
3586
  }
1867
3587
  }
1868
- return retainDecoded;
3588
+ return yield* updateResult(rv, oldImage, newImage, written, mode, encodedKey);
1869
3589
  }
1870
3590
  // --- Standard path: updateItem ---
3591
+ // A retain entity reaching this branch carries path operations. Read
3592
+ // the item first: its version snapshot is built from it, and the
3593
+ // update below is conditioned on that version, so the snapshot is
3594
+ // exactly the item the update replaces (#133).
3595
+ let retainSnapshot;
3596
+ // A cascade that also returns the OLD item, with path operations on a
3597
+ // versioned entity: read first, condition the write on that version
3598
+ // and incarnation, so the read IS the old item and ALL_NEW the new.
3599
+ let casRead;
3600
+ if (isRetainEnabled() || needsBothImages) {
3601
+ const current = preRead !== undefined
3602
+ ? { Item: preRead }
3603
+ : yield* client.getItem({
3604
+ TableName: tableName,
3605
+ Key: marshalledKey,
3606
+ ConsistentRead: true,
3607
+ });
3608
+ if (!current.Item) {
3609
+ return yield* missingItem();
3610
+ }
3611
+ yield* checkVersion(current.Item, "update");
3612
+ const immutableChange = immutableMismatch(immutables, current.Item);
3613
+ if (immutableChange !== undefined) {
3614
+ return yield* new ValidationError({
3615
+ entityType,
3616
+ operation: "update",
3617
+ cause: immutableChange,
3618
+ });
3619
+ }
3620
+ const currentRaw = fromAttributeMap(current.Item);
3621
+ const version = systemFields.version
3622
+ ? (currentRaw[systemFields.version] ?? 0)
3623
+ : 0;
3624
+ if (evExpected !== undefined && version !== evExpected) {
3625
+ return yield* new OptimisticLockError({
3626
+ entityType,
3627
+ key: encodedKey,
3628
+ expectedVersion: evExpected,
3629
+ actualVersion: version,
3630
+ });
3631
+ }
3632
+ if (!isRetainEnabled()) {
3633
+ casRead = { raw: current.Item, version };
3634
+ }
3635
+ else {
3636
+ retainSnapshot = {
3637
+ item: buildSnapshotItem(currentRaw, version, config.indexes.primary.pk.field, config.indexes.primary.sk.field, ttlAttrName, now),
3638
+ raw: current.Item,
3639
+ version,
3640
+ ...(stampsIncarnation && current.Item[INCARNATION_TOKEN] === undefined
3641
+ ? yield* freshIncarnation.pipe(Effect.map((stamp) => ({ stamp, incarnation: toAttributeValue(stamp) })))
3642
+ : {
3643
+ stamp: undefined,
3644
+ incarnation: incarnationAttr !== undefined ? current.Item[incarnationAttr] : undefined,
3645
+ }),
3646
+ };
3647
+ }
3648
+ }
3649
+ // Plain (unread) updates require the item to exist (#133). One that
3650
+ // could create a complete item — a plain `.set()` of every required
3651
+ // field and key composite — does so on a missing item through `put`
3652
+ // (`createOnMissing`), so the item is exactly the one `put` writes.
3653
+ const plainWrite = retainSnapshot === undefined && casRead === undefined;
3654
+ const createsOnMissing = plainWrite &&
3655
+ upsertable &&
3656
+ !uState.patch &&
3657
+ evExpected === undefined &&
3658
+ userCond === undefined &&
3659
+ uState.cascade === undefined &&
3660
+ uState.withVectors === undefined &&
3661
+ (requestedRv === "allNew" || requestedRv === "none" || requestedRv === "updatedNew") &&
3662
+ [uState.remove, uState.sparseRemoveEntries, uState.sparseClearFields].every((ops) => ops === undefined || ops.length === 0) &&
3663
+ [uState.add, uState.subtract, uState.append, uState.deleteFromSet].every((ops) => ops === undefined || Object.keys(ops).length === 0) &&
3664
+ !hasPathOps(uState);
1871
3665
  // Build UpdateExpression
1872
3666
  const setClauses = [];
1873
3667
  const names = {};
@@ -1952,7 +3746,10 @@ const makeImpl = (config, precomputedData) => {
1952
3746
  if (systemFields.version) {
1953
3747
  const nameKey = `#u${counter}`;
1954
3748
  names[nameKey] = systemFields.version;
1955
- setClauses.push(`${nameKey} = ${nameKey} + :vinc`);
3749
+ // An item written before the entity was `versioned` has none: its
3750
+ // first versioned write makes it version 1 (#133).
3751
+ values[":vzero"] = toAttributeValue(0);
3752
+ setClauses.push(`${nameKey} = if_not_exists(${nameKey}, :vzero) + :vinc`);
1956
3753
  values[":vinc"] = toAttributeValue(1);
1957
3754
  counter++;
1958
3755
  }
@@ -1977,7 +3774,15 @@ const makeImpl = (config, precomputedData) => {
1977
3774
  const nameKey = `#u${counter}`;
1978
3775
  const valKey = `:u${counter}`;
1979
3776
  names[nameKey] = resolveDbName(attr);
1980
- values[valKey] = toAttributeValue(val);
3777
+ const appended = encodePathValue([attr], val, "elements");
3778
+ if (appended.issue !== undefined) {
3779
+ return yield* new ValidationError({
3780
+ entityType,
3781
+ operation: "update.append",
3782
+ cause: appended.issue,
3783
+ });
3784
+ }
3785
+ values[valKey] = toAttributeValue(appended.encoded);
1981
3786
  setClauses.push(`${nameKey} = list_append(${nameKey}, ${valKey})`);
1982
3787
  counter++;
1983
3788
  }
@@ -2018,17 +3823,23 @@ const makeImpl = (config, precomputedData) => {
2018
3823
  // composer no longer throws. Hole patterns collapse into the
2019
3824
  // unified can't-compose rule.
2020
3825
  const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, allIndexes, keyForm(hydratedUpdates), keyForm(encodedKey), removedSet === undefined ? {} : { removedSet });
3826
+ // The key and vector attributes this update writes — a retain
3827
+ // snapshot strips them, so the post-image is rebuilt from these.
3828
+ const derivedSets = {};
3829
+ const derivedRemoves = new Set();
2021
3830
  for (const [field, value] of Object.entries(gsiUpdate.sets)) {
2022
3831
  const nameKey = `#u${counter}`;
2023
3832
  const valKey = `:u${counter}`;
2024
3833
  names[nameKey] = field;
2025
3834
  values[valKey] = toAttributeValue(value);
3835
+ derivedSets[field] = values[valKey];
2026
3836
  setClauses.push(`${nameKey} = ${valKey}`);
2027
3837
  counter++;
2028
3838
  }
2029
3839
  for (const keyField of gsiUpdate.removes) {
2030
3840
  const nameKey = `#r${removeClauses.length}`;
2031
3841
  names[nameKey] = keyField;
3842
+ derivedRemoves.add(keyField);
2032
3843
  removeClauses.push(nameKey);
2033
3844
  }
2034
3845
  // Vector search: partition value is recomposed whenever it can be
@@ -2042,6 +3853,7 @@ const makeImpl = (config, precomputedData) => {
2042
3853
  const valKey = `:u${counter}`;
2043
3854
  names[nameKey] = field;
2044
3855
  values[valKey] = toAttributeValue(value);
3856
+ derivedSets[field] = values[valKey];
2045
3857
  setClauses.push(`${nameKey} = ${valKey}`);
2046
3858
  counter++;
2047
3859
  }
@@ -2050,6 +3862,7 @@ const makeImpl = (config, precomputedData) => {
2050
3862
  for (const field of new Set(vectorWrite.removes)) {
2051
3863
  const nameKey = `#r${removeClauses.length}`;
2052
3864
  names[nameKey] = field;
3865
+ derivedRemoves.add(field);
2053
3866
  removeClauses.push(nameKey);
2054
3867
  }
2055
3868
  }
@@ -2085,7 +3898,15 @@ const makeImpl = (config, precomputedData) => {
2085
3898
  }
2086
3899
  else {
2087
3900
  const valKey = `:ps${pathCounter.value++}`;
2088
- values[valKey] = toAttributeValue(op.value);
3901
+ const set = encodePathValue(op.segments, op.value, "value");
3902
+ if (set.issue !== undefined) {
3903
+ return yield* new ValidationError({
3904
+ entityType,
3905
+ operation: "update.pathSet",
3906
+ cause: set.issue,
3907
+ });
3908
+ }
3909
+ values[valKey] = toAttributeValue(set.encoded);
2089
3910
  setClauses.push(`${pathExpr} = ${valKey}`);
2090
3911
  }
2091
3912
  }
@@ -2110,7 +3931,15 @@ const makeImpl = (config, precomputedData) => {
2110
3931
  for (const op of uState.pathAppends) {
2111
3932
  const pathExpr = compilePath(op.segments, names, "pa", pathCounter, resolveDbName);
2112
3933
  const valKey = `:pa${pathCounter.value++}`;
2113
- values[valKey] = toAttributeValue(op.value);
3934
+ const appended = encodePathValue(op.segments, op.value, "elements");
3935
+ if (appended.issue !== undefined) {
3936
+ return yield* new ValidationError({
3937
+ entityType,
3938
+ operation: "update.pathAppend",
3939
+ cause: appended.issue,
3940
+ });
3941
+ }
3942
+ values[valKey] = toAttributeValue(appended.encoded);
2114
3943
  setClauses.push(`${pathExpr} = list_append(${pathExpr}, ${valKey})`);
2115
3944
  }
2116
3945
  }
@@ -2119,7 +3948,15 @@ const makeImpl = (config, precomputedData) => {
2119
3948
  for (const op of uState.pathPrepends) {
2120
3949
  const pathExpr = compilePath(op.segments, names, "pp", pathCounter, resolveDbName);
2121
3950
  const valKey = `:pp${pathCounter.value++}`;
2122
- values[valKey] = toAttributeValue(op.value);
3951
+ const prepended = encodePathValue(op.segments, op.value, "elements");
3952
+ if (prepended.issue !== undefined) {
3953
+ return yield* new ValidationError({
3954
+ entityType,
3955
+ operation: "update.pathPrepend",
3956
+ cause: prepended.issue,
3957
+ });
3958
+ }
3959
+ values[valKey] = toAttributeValue(prepended.encoded);
2123
3960
  setClauses.push(`${pathExpr} = list_append(${valKey}, ${pathExpr})`);
2124
3961
  }
2125
3962
  }
@@ -2128,7 +3965,15 @@ const makeImpl = (config, precomputedData) => {
2128
3965
  for (const op of uState.pathIfNotExists) {
2129
3966
  const pathExpr = compilePath(op.segments, names, "pi", pathCounter, resolveDbName);
2130
3967
  const valKey = `:pi${pathCounter.value++}`;
2131
- values[valKey] = toAttributeValue(op.value);
3968
+ const ifAbsent = encodePathValue(op.segments, op.value, "value");
3969
+ if (ifAbsent.issue !== undefined) {
3970
+ return yield* new ValidationError({
3971
+ entityType,
3972
+ operation: "update.pathIfNotExists",
3973
+ cause: ifAbsent.issue,
3974
+ });
3975
+ }
3976
+ values[valKey] = toAttributeValue(ifAbsent.encoded);
2132
3977
  setClauses.push(`${pathExpr} = if_not_exists(${pathExpr}, ${valKey})`);
2133
3978
  }
2134
3979
  }
@@ -2220,16 +4065,28 @@ const makeImpl = (config, precomputedData) => {
2220
4065
  }
2221
4066
  }
2222
4067
  }
2223
- // If the item doesn't exist, clearMap is a no-op — the subsequent
2224
- // UpdateItem will create the row (with whatever other clauses the
2225
- // builder accumulated) per DynamoDB UpdateItem semantics.
4068
+ // If the item doesn't exist, clearMap is a no-op — and the
4069
+ // subsequent UpdateItem's existence condition reports it.
4070
+ }
4071
+ // A legacy retain item gets its incarnation token with this write.
4072
+ if (retainSnapshot?.stamp !== undefined) {
4073
+ names["#eddInc"] = INCARNATION_TOKEN;
4074
+ values[":eddInc"] = toAttributeValue(retainSnapshot.stamp);
4075
+ setClauses.push("#eddInc = :eddInc");
2226
4076
  }
2227
4077
  const hasAnyUpdate = setClauses.length > 0 ||
2228
4078
  removeClauses.length > 0 ||
2229
4079
  addClauses.length > 0 ||
2230
4080
  deleteClauses.length > 0;
4081
+ const rv = uState.returnValues ?? "allNew";
2231
4082
  if (!hasAnyUpdate) {
2232
- // Nothing to update — just get the current item
4083
+ // Nothing to update: nothing written, so nothing "updated"; the
4084
+ // whole-item modes are the current item.
4085
+ if (rv === "none")
4086
+ return undefined;
4087
+ if (rv === "updatedOld" || rv === "updatedNew") {
4088
+ return yield* decodePartial(undefined, new Set(), mode);
4089
+ }
2233
4090
  return yield* get(key)._run(mode);
2234
4091
  }
2235
4092
  // Compose UpdateExpression from all clause types
@@ -2246,17 +4103,134 @@ const makeImpl = (config, precomputedData) => {
2246
4103
  // Build condition expression — combine optimistic lock + user condition
2247
4104
  const condParts = [];
2248
4105
  if (evExpected !== undefined && systemFields.version) {
2249
- names["#condVer"] = systemFields.version;
2250
- values[":expectedVer"] = toAttributeValue(evExpected);
2251
- condParts.push("#condVer = :expectedVer");
4106
+ condParts.push(versionIs(evExpected, names, values, "#condVer", ":expectedVer"));
4107
+ }
4108
+ if (plainWrite && systemFields.version) {
4109
+ // Never version an item whose version was removed outside the library
4110
+ // (incarnation token, no version) as if it predated versioning.
4111
+ names["#intVer"] = systemFields.version;
4112
+ names["#intInc"] = INCARNATION_TOKEN;
4113
+ condParts.push("(attribute_exists(#intVer) OR attribute_not_exists(#intInc))");
2252
4114
  }
2253
- if (userCond) {
2254
- const uc = compileCondition(userCond, resolveDbName);
4115
+ // A plain write already requires the item (`attribute_exists(#exists)`
4116
+ // below), so `patch`'s own exists guard would only repeat it.
4117
+ const sentCond = plainWrite ? uState.condition : userCond;
4118
+ const uc = sentCond ? compileCondition(sentCond, resolveDbName) : undefined;
4119
+ if (uc) {
2255
4120
  condParts.push(`(${uc.expression})`);
2256
4121
  Object.assign(names, uc.names);
2257
4122
  Object.assign(values, uc.values);
2258
4123
  }
4124
+ if (plainWrite) {
4125
+ // Restated immutable values must match the item's.
4126
+ for (const [i, { attr, value }] of immutables.entries()) {
4127
+ names[`#imm${i}`] = attr;
4128
+ values[`:imm${i}`] = value;
4129
+ condParts.push(`#imm${i} = :imm${i}`);
4130
+ }
4131
+ names["#exists"] = config.indexes.primary.pk.field;
4132
+ condParts.push("attribute_exists(#exists)");
4133
+ }
4134
+ if (casRead !== undefined && systemFields.version) {
4135
+ condParts.push(versionIs(casRead.version, names, values, "#casVer", ":casVer"));
4136
+ const incarnation = incarnationGuard(casRead.raw, names, values);
4137
+ if (incarnation !== undefined)
4138
+ condParts.push(incarnation);
4139
+ }
2259
4140
  const conditionExpression = condParts.length > 0 ? condParts.join(" AND ") : undefined;
4141
+ {
4142
+ const tooLarge = oversizedCondition("update", conditionExpression, sentCond !== undefined
4143
+ ? compileCondition(sentCond, resolveDbName)?.expression
4144
+ : undefined);
4145
+ if (tooLarge !== undefined)
4146
+ return yield* tooLarge;
4147
+ }
4148
+ // The top-level attributes this update writes — what `updatedOld` /
4149
+ // `updatedNew` return.
4150
+ const written = expressionTargets(updateExpression, names);
4151
+ const cascadeFrom = (image) => Effect.gen(function* () {
4152
+ if (!uState.cascade)
4153
+ return;
4154
+ const sourceId = sourceIdentifierField != null
4155
+ ? encodedKey[sourceIdentifierField]
4156
+ : undefined;
4157
+ if (sourceId == null)
4158
+ return;
4159
+ const domainData = yield* decodeAs(fromAttributeMap(image), image, "model");
4160
+ yield* executeCascade(uState.cascade, { ...domainData }, String(sourceId));
4161
+ });
4162
+ if (retainSnapshot !== undefined) {
4163
+ // The SAME UpdateExpression, transacted with the version snapshot of
4164
+ // the item it replaces — DynamoDB applies the path operations, so
4165
+ // their semantics are DynamoDB's own (#133). The version AND the
4166
+ // incarnation prove the snapshot is of the item this replaces.
4167
+ const snapshot = retainSnapshot;
4168
+ const guardParts = [...condParts];
4169
+ if (systemFields.version) {
4170
+ guardParts.push(versionIs(snapshot.version, names, values, "#retainVer", ":retainVer"));
4171
+ const incarnation = incarnationGuard(snapshot.raw, names, values);
4172
+ if (incarnation !== undefined)
4173
+ guardParts.push(incarnation);
4174
+ }
4175
+ const tooLarge = oversizedCondition("update", guardParts.join(" AND "), sentCond !== undefined
4176
+ ? compileCondition(sentCond, resolveDbName)?.expression
4177
+ : undefined);
4178
+ if (tooLarge !== undefined)
4179
+ return yield* tooLarge;
4180
+ const transactItems = [
4181
+ {
4182
+ Update: {
4183
+ TableName: tableName,
4184
+ Key: marshalledKey,
4185
+ UpdateExpression: updateExpression,
4186
+ ExpressionAttributeNames: names,
4187
+ ExpressionAttributeValues: Object.keys(values).length > 0 ? values : undefined,
4188
+ ...(guardParts.length > 0 && {
4189
+ ConditionExpression: guardParts.join(" AND "),
4190
+ ReturnValuesOnConditionCheckFailure: "ALL_OLD",
4191
+ }),
4192
+ },
4193
+ },
4194
+ { Put: snapshotPut(tableName, snapshot.item) },
4195
+ ];
4196
+ yield* checkTransactionLimit(entityType, "update", transactItems);
4197
+ yield* client.transactWriteItems({ TransactItems: transactItems }).pipe(Effect.mapError((err) => {
4198
+ // The update is guarded by the snapshot version AND any user
4199
+ // condition; the stored item (ALL_OLD) says which rejected it.
4200
+ const rejection = (stored) => conditionRejection(encodedKey, stored, systemFields.version
4201
+ ? { version: snapshot.version, read: snapshot.raw }
4202
+ : undefined, userCond !== undefined);
4203
+ if (isAwsTransactionCancelled(err.cause)) {
4204
+ const main = err.cause.CancellationReasons?.[0];
4205
+ if (main?.Code === "ConditionalCheckFailed")
4206
+ return rejection(main.Item);
4207
+ if (err.cause.CancellationReasons?.[1]?.Code === "ConditionalCheckFailed") {
4208
+ return historyConflict(snapshot.version, "update");
4209
+ }
4210
+ }
4211
+ if (isAwsConditionalCheckFailed(err.cause))
4212
+ return rejection(err.cause.Item);
4213
+ return err;
4214
+ }));
4215
+ // A transacted Update returns no attributes. The old item is exact:
4216
+ // the version condition proves `snapshot.raw` is what it replaced.
4217
+ // The new item is read back only when needed, and proven ours.
4218
+ const needsNew = rv === "allNew" || rv === "updatedNew" || uState.cascade !== undefined;
4219
+ const after = needsNew
4220
+ ? yield* readRetainPostImage(tableName, marshalledKey, snapshot.raw, snapshot.version + 1, snapshot.incarnation, derivedSets, derivedRemoves, ttlAttrName, encodedKey)
4221
+ : undefined;
4222
+ if (after !== undefined)
4223
+ yield* cascadeFrom(after);
4224
+ return yield* updateResult(rv, snapshot.raw, after, written, mode, encodedKey);
4225
+ }
4226
+ // The image the mode returns; `none` asks for nothing unless a
4227
+ // cascade needs the new item.
4228
+ const wantsOld = casRead === undefined && (rv === "allOld" || rv === "updatedOld");
4229
+ const returned = wantsOld
4230
+ ? "ALL_OLD"
4231
+ : rv === "none" && !uState.cascade && casRead === undefined
4232
+ ? "NONE"
4233
+ : "ALL_NEW";
2260
4234
  // DynamoDB rejects an empty `ExpressionAttributeValues` map. When
2261
4235
  // the UpdateExpression is REMOVE-only (e.g. clearMap with no other
2262
4236
  // combinators), `values` may be empty — omit the property entirely.
@@ -2268,44 +4242,69 @@ const makeImpl = (config, precomputedData) => {
2268
4242
  ExpressionAttributeNames: names,
2269
4243
  ExpressionAttributeValues: Object.keys(values).length > 0 ? values : undefined,
2270
4244
  ConditionExpression: conditionExpression,
2271
- ReturnValues: returnValuesMap[uState.returnValues ?? "allNew"],
4245
+ ...(conditionExpression !== undefined && {
4246
+ ReturnValuesOnConditionCheckFailure: "ALL_OLD",
4247
+ }),
4248
+ ReturnValues: returned,
2272
4249
  })
2273
4250
  .pipe(Effect.mapError((err) => {
4251
+ // The stored item (ALL_OLD) says what rejected the update: no
4252
+ // item (it must exist), a restated immutable value, the
4253
+ // version CAS, or the user's condition.
2274
4254
  if (isAwsConditionalCheckFailed(err.cause)) {
2275
- if (evExpected !== undefined) {
2276
- return new OptimisticLockError({
2277
- entityType,
2278
- key: encodedKey,
2279
- expectedVersion: evExpected,
2280
- actualVersion: -1,
2281
- });
4255
+ const stored = err.cause.Item;
4256
+ if (stored === undefined && !uState.patch) {
4257
+ return createsOnMissing
4258
+ ? new MissingForCreate({ input: uState.updates })
4259
+ : new ItemNotFound({ entityType, key: encodedKey });
2282
4260
  }
2283
- if (userCond) {
2284
- return new ConditionalCheckFailed({
4261
+ const corrupt = versionCorruption(stored, "update");
4262
+ if (corrupt !== undefined)
4263
+ return corrupt;
4264
+ const immutableChange = stored !== undefined ? immutableMismatch(immutables, stored) : undefined;
4265
+ if (immutableChange !== undefined) {
4266
+ return new ValidationError({
2285
4267
  entityType,
2286
- key: encodedKey,
4268
+ operation: "update",
4269
+ cause: immutableChange,
2287
4270
  });
2288
4271
  }
4272
+ return conditionRejection(encodedKey, stored, casRead !== undefined
4273
+ ? { version: casRead.version, read: casRead.raw }
4274
+ : systemFields.version && evExpected !== undefined
4275
+ ? { version: evExpected }
4276
+ : undefined, userCond !== undefined);
2289
4277
  }
2290
4278
  return err;
2291
4279
  }));
2292
- if (!result.Attributes) {
2293
- return yield* new ItemNotFound({ entityType, key: encodedKey });
2294
- }
2295
- const raw = fromAttributeMap(result.Attributes);
2296
- const decoded = yield* decodeAs(raw, result.Attributes, mode);
2297
- // Execute cascade if configured
2298
- if (uState.cascade) {
2299
- const domainData = { ...decoded };
2300
- const sourceId = sourceIdentifierField != null
2301
- ? encodedKey[sourceIdentifierField]
2302
- : undefined;
2303
- if (sourceId != null) {
2304
- yield* executeCascade(uState.cascade, domainData, String(sourceId));
2305
- }
4280
+ // A cascade never meets an old-image mode here: that combination is
4281
+ // routed to the read-then-write branch, or read first (`casRead`).
4282
+ const newImage = wantsOld ? undefined : result.Attributes;
4283
+ if (uState.cascade && newImage !== undefined)
4284
+ yield* cascadeFrom(newImage);
4285
+ return yield* updateResult(rv, casRead !== undefined ? casRead.raw : wantsOld ? result.Attributes : undefined, newImage, written, mode, encodedKey);
4286
+ });
4287
+ /**
4288
+ * An update; on a missing item, a plain `.set()` of a complete item creates
4289
+ * it through `create` — exactly the item `put` writes (#133). If another
4290
+ * writer creates it first, the update runs once more against that item.
4291
+ */
4292
+ const updateOrCreate = (key, mode, requested) => runUpdate(key, mode, requested).pipe(Effect.catchIf((e) => e instanceof MissingForCreate, (missing) => Effect.gen(function* () {
4293
+ const created = yield* Effect.exit(create(missing.input)._run("native"));
4294
+ if (created._tag === "Failure") {
4295
+ const raced = Cause.findErrorOption(created.cause).pipe(Option.filter((e) => e instanceof ConditionalCheckFailed));
4296
+ if (Option.isNone(raced))
4297
+ return yield* created;
4298
+ // Created concurrently: now an ordinary update of that item.
4299
+ return yield* runUpdate(key, mode, requested).pipe(Effect.catchIf((e) => e instanceof MissingForCreate, () => Effect.fail(new ItemNotFound({
4300
+ entityType,
4301
+ key: key,
4302
+ }))));
2306
4303
  }
2307
- return decoded;
2308
- }), emptyUpdateState, self, key);
4304
+ const item = created.value;
4305
+ return yield* updateResult(requested.returnValues ?? "allNew", undefined, item, new Set(Object.keys(item)), mode, key);
4306
+ })));
4307
+ const update = (key) => new EntityUpdateImpl((mode, requested) => updateOrCreate(key, mode, requested), emptyUpdateState, self, key);
2309
4308
  // ---------------------------------------------------------------------------
2310
4309
  // delete operation
2311
4310
  // ---------------------------------------------------------------------------
@@ -2321,10 +4320,52 @@ const makeImpl = (config, precomputedData) => {
2321
4320
  const marshalledKey = toAttributeMap(primaryKey);
2322
4321
  const primary = config.indexes.primary;
2323
4322
  const hasUniqueConstraints = config.unique != null && Object.keys(config.unique).length > 0;
2324
- // Build user condition expression if provided
2325
- const userCondition = opts.condition
2326
- ? compileCondition(opts.condition, resolveDbName)
2327
- : undefined;
4323
+ // A delete that reads the item first already requires it to exist,
4324
+ // so a condition asserting only that (`deleteIfExists`) adds nothing
4325
+ // to its guard: it is judged against the read instead — a missing
4326
+ // item is `ConditionalCheckFailed` — and the delete is retried after
4327
+ // a race like an unconditioned one.
4328
+ const readsFirst = isSoftDeleteEnabled() || hasUniqueConstraints || isRetainEnabled();
4329
+ // `deleteIfExists` (alone, or ANDed with a `.condition()`): a missing
4330
+ // item fails its condition — a guard no `.condition()` replaces.
4331
+ const mustExist = opts.mustExist;
4332
+ const existenceOnly = readsFirst && mustExist && opts.condition === undefined;
4333
+ if (opts.returnValues !== undefined &&
4334
+ opts.returnValues !== "none" &&
4335
+ opts.returnValues !== "allOld") {
4336
+ return yield* new ValidationError({
4337
+ entityType,
4338
+ operation: "delete",
4339
+ cause: `delete: returnValues("${opts.returnValues}") is not a mode DeleteItem ` +
4340
+ 'supports — only "none" and "allOld". Nothing was sent.',
4341
+ });
4342
+ }
4343
+ // The caller's condition, ANDed onto `deleteIfExists`'s exists guard.
4344
+ const condition = withGuard(mustExist ? { attributeExists: [primary.pk.field] } : undefined, opts.condition);
4345
+ const userCondition = condition && !existenceOnly ? compileCondition(condition, resolveDbName) : undefined;
4346
+ /**
4347
+ * What the delete returns: with `returnValues("allOld")`, the item it
4348
+ * removed — the one read (every read-first delete is guarded on it) or
4349
+ * DynamoDB's `ALL_OLD` — as a model, `undefined` when there was none.
4350
+ */
4351
+ const deletedResult = (old) => {
4352
+ if (opts.returnValues !== "allOld" || old === undefined)
4353
+ return Effect.succeed(undefined);
4354
+ const raw = fromAttributeMap(old);
4355
+ // The delete is applied: a decode failure must not read as a refusal.
4356
+ return decodeAs({ ...raw }, old, "model").pipe(Effect.mapError((cause) => new DeleteAppliedButUnreadable({
4357
+ entityType,
4358
+ key: encodedKey,
4359
+ item: raw,
4360
+ cause,
4361
+ })));
4362
+ };
4363
+ /** The item is missing: what a delete that read it reports. */
4364
+ const missing = (otherwise) => mustExist
4365
+ ? Effect.fail(new ConditionalCheckFailed({ entityType, key: encodedKey }))
4366
+ : otherwise === undefined
4367
+ ? Effect.succeed(undefined)
4368
+ : Effect.fail(otherwise);
2328
4369
  /**
2329
4370
  * Map a rejected user condition on either transaction delete path
2330
4371
  * (soft-delete, unique-constraint) to `ConditionalCheckFailed`.
@@ -2345,166 +4386,271 @@ const makeImpl = (config, precomputedData) => {
2345
4386
  }
2346
4387
  return err;
2347
4388
  };
2348
- if (isSoftDeleteEnabled()) {
2349
- // --- Soft delete path ---
2350
- // Read current item
2351
- const result = yield* client.getItem({
2352
- TableName: tableName,
2353
- Key: marshalledKey,
2354
- });
2355
- if (!result.Item) {
2356
- return yield* Effect.fail(new ItemNotFound({ entityType, key: encodedKey }));
2357
- }
2358
- const raw = fromAttributeMap(result.Item);
2359
- // Clock-backed time source; `now` is the ISO timestamp used for the
2360
- // deleted SK + deletedAt, `dtNow` drives the optional TTL + snapshot.
2361
- const dtNow = yield* DateTime.now;
2362
- const now = nowIso(dtNow);
2363
- // Build soft-deleted item: same PK, replace SK with deleted key, strip GSI keys
2364
- const deletedItem = { ...raw };
2365
- // Strip GSI key fields — soft-deleted items must not appear in index queries
2366
- for (const field of gsiKeyFields()) {
2367
- delete deletedItem[field];
2368
- }
2369
- // Vector attributes: stash the embedding under a non-indexed name
2370
- // before stripping. Sparse semantics drop the tombstone out of the
2371
- // vector index immediately, and `restore()` un-stashes without
2372
- // paying for another Embedder call. See `DESIGN.md §14`.
2373
- for (const [, definition] of vectorIndexEntries) {
2374
- const stored = deletedItem[definition.vectorField];
2375
- if (stored !== undefined)
2376
- deletedItem[definition.stashField] = stored;
2377
- delete deletedItem[definition.vectorField];
2378
- delete deletedItem[definition.partitionField];
2379
- }
2380
- // Replace SK with deleted sort key
2381
- deletedItem[primary.sk.field] = DynamoSchema.composeDeletedKey(schema, entityType, now);
2382
- // Add deletedAt
2383
- deletedItem.deletedAt = now;
2384
- // Add optional TTL
2385
- const sdTtl = softDeleteTtl();
2386
- if (sdTtl) {
2387
- deletedItem[ttlAttrName] = DateTime.toEpochSeconds(dtNow) + normalizeTtlSeconds(sdTtl);
2388
- }
2389
- const transactItems = [];
2390
- // Delete current entity item — index 0, and the ONLY item in this
2391
- // transaction carrying a ConditionExpression, so a cancellation
2392
- // naming index 0 is unambiguously the user's condition. The guard
2393
- // rides the transaction rather than being pre-checked against the
2394
- // item read above: a client-side check would leave a race window
2395
- // between the read and the write.
2396
- const currentDelete = {
2397
- TableName: tableName,
2398
- Key: marshalledKey,
4389
+ /** The guard ANDed with the user's condition, for the main Delete. */
4390
+ const guardedDeleteCondition = (guard) => {
4391
+ const values = { ...guard.values, ...userCondition?.values };
4392
+ return {
4393
+ ConditionExpression: userCondition
4394
+ ? `${guard.expression} AND (${userCondition.expression})`
4395
+ : guard.expression,
4396
+ ExpressionAttributeNames: { ...guard.names, ...userCondition?.names },
4397
+ ...(Object.keys(values).length > 0 && { ExpressionAttributeValues: values }),
4398
+ ReturnValuesOnConditionCheckFailure: "ALL_OLD",
2399
4399
  };
2400
- if (userCondition) {
2401
- currentDelete.ConditionExpression = userCondition.expression;
2402
- currentDelete.ExpressionAttributeNames = userCondition.names;
2403
- if (Object.keys(userCondition.values).length > 0) {
2404
- currentDelete.ExpressionAttributeValues = userCondition.values;
4400
+ };
4401
+ /**
4402
+ * Run a guarded delete transaction: index 0 is the main Delete, the
4403
+ * only conditioned item, so its rejection is read from the stored
4404
+ * item (ALL_OLD) — gone, raced, or the user's condition.
4405
+ */
4406
+ const guardedDelete = (write, read, inputs, snapshotAt, releases) => {
4407
+ let rejected;
4408
+ const reject = (stored) => {
4409
+ rejected = stored;
4410
+ // Deleted since the read: with nothing asserted, read it again —
4411
+ // a delete of a missing item reports what such a delete does.
4412
+ if (stored === undefined && userCondition === undefined) {
4413
+ return new DeletedConcurrently();
4414
+ }
4415
+ return deleteRejection(encodedKey, read, inputs, stored, userCondition !== undefined);
4416
+ };
4417
+ return withCurrentItem(write.pipe(Effect.mapError((err) => {
4418
+ if (isAwsTransactionCancelled(err.cause)) {
4419
+ const main = err.cause.CancellationReasons?.[0];
4420
+ if (main?.Code === "ConditionalCheckFailed")
4421
+ return reject(main.Item);
4422
+ if (snapshotAt !== undefined &&
4423
+ err.cause.CancellationReasons?.[snapshotAt.index]?.Code ===
4424
+ "ConditionalCheckFailed") {
4425
+ return historyConflict(snapshotAt.version, "delete");
4426
+ }
4427
+ for (const { index, constraintName } of releases) {
4428
+ if (err.cause.CancellationReasons?.[index]?.Code === "ConditionalCheckFailed") {
4429
+ return releaseRaced(encodedKey, constraintName);
4430
+ }
4431
+ }
2405
4432
  }
4433
+ if (isAwsConditionalCheckFailed(err.cause))
4434
+ return reject(err.cause.Item);
4435
+ return err;
4436
+ })), () => rejected);
4437
+ };
4438
+ /**
4439
+ * Append a release for each sentinel the stored item `raw` holds AND
4440
+ * owns (#133) — composed from its domain view, since the stored row is
4441
+ * attribute-keyed and the constraint is not (#127) — returning where
4442
+ * each landed, so a release that lost a race can be reported.
4443
+ */
4444
+ const ownedReleases = (table, raw, into) => Effect.gen(function* () {
4445
+ const rawDomain = toDomainView(raw);
4446
+ const held = [];
4447
+ for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
4448
+ const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, rawDomain);
4449
+ if (sentinel)
4450
+ held.push({ ...sentinel.key, constraintName });
2406
4451
  }
2407
- transactItems.push({ Delete: currentDelete });
2408
- // Put soft-deleted item
2409
- transactItems.push({
2410
- Put: {
2411
- TableName: tableName,
2412
- Item: toAttributeMap(deletedItem),
2413
- },
2414
- });
2415
- // Version snapshot if retain is enabled
2416
- if (isRetainEnabled()) {
2417
- const currentVersion = systemFields.version
2418
- ? raw[systemFields.version]
2419
- : 0;
2420
- const snapshotItem = buildSnapshotItem(raw, currentVersion, primary.pk.field, primary.sk.field, ttlAttrName, dtNow);
2421
- transactItems.push({
2422
- Put: {
2423
- TableName: tableName,
2424
- Item: toAttributeMap(snapshotItem),
2425
- },
2426
- });
4452
+ const owner = { pk: primaryKey[primary.pk.field], sk: primaryKey[primary.sk.field] };
4453
+ const releases = [];
4454
+ for (const sentinel of yield* ownedSentinels(table, held, owner)) {
4455
+ releases.push({ index: into.length, constraintName: sentinel.constraintName });
4456
+ into.push(sentinelRelease(table, sentinel, owner));
2427
4457
  }
2428
- // Delete sentinels if not preserving unique. Sparse — fields that were
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).
2433
- if (hasUniqueConstraints && !preserveUnique()) {
2434
- const rawDomain = toDomainView(raw);
2435
- for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
2436
- const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, rawDomain);
2437
- if (!sentinel)
2438
- continue;
4458
+ return releases;
4459
+ });
4460
+ if (isSoftDeleteEnabled() || hasUniqueConstraints || isRetainEnabled()) {
4461
+ const readFirst = Effect.gen(function* () {
4462
+ if (isSoftDeleteEnabled()) {
4463
+ // --- Soft delete path ---
4464
+ // Read current item
4465
+ const result = yield* client.getItem({
4466
+ TableName: tableName,
4467
+ Key: marshalledKey,
4468
+ ConsistentRead: true,
4469
+ });
4470
+ if (!result.Item) {
4471
+ return yield* missing(new ItemNotFound({ entityType, key: encodedKey }));
4472
+ }
4473
+ // The tombstone (and snapshot) copy the item read: the delete is
4474
+ // conditioned on it being unchanged, so no concurrent update is lost
4475
+ // into them (#133).
4476
+ yield* checkVersion(result.Item, "delete");
4477
+ const softGuard = deleteGuard(result.Item, "item", userCondition?.expression);
4478
+ if (softGuard instanceof ValidationError)
4479
+ return yield* softGuard;
4480
+ const raw = fromAttributeMap(result.Item);
4481
+ // Clock-backed time source; `now` is the ISO timestamp used for the
4482
+ // deleted SK + deletedAt, `dtNow` drives the optional TTL + snapshot.
4483
+ const dtNow = yield* DateTime.now;
4484
+ const now = nowIso(dtNow);
4485
+ // Build soft-deleted item: same PK, replace SK with deleted key, strip GSI keys
4486
+ const deletedItem = { ...raw };
4487
+ // Strip GSI key fields — soft-deleted items must not appear in index queries
4488
+ for (const field of gsiKeyFields()) {
4489
+ delete deletedItem[field];
4490
+ }
4491
+ // Vector attributes: stash the embedding under a non-indexed name
4492
+ // before stripping. Sparse semantics drop the tombstone out of the
4493
+ // vector index immediately, and `restore()` un-stashes without
4494
+ // paying for another Embedder call. See `DESIGN.md §14`.
4495
+ for (const [, definition] of vectorIndexEntries) {
4496
+ const stored = deletedItem[definition.vectorField];
4497
+ if (stored !== undefined)
4498
+ deletedItem[definition.stashField] = stored;
4499
+ delete deletedItem[definition.vectorField];
4500
+ delete deletedItem[definition.partitionField];
4501
+ }
4502
+ // Replace SK with deleted sort key
4503
+ deletedItem[primary.sk.field] = DynamoSchema.composeDeletedKey(schema, entityType, now, historyKeyOptions(primaryKey[primary.sk.field]));
4504
+ // Add deletedAt
4505
+ deletedItem.deletedAt = now;
4506
+ // Add optional TTL
4507
+ const sdTtl = softDeleteTtl();
4508
+ if (sdTtl) {
4509
+ deletedItem[ttlAttrName] =
4510
+ DateTime.toEpochSeconds(dtNow) + normalizeTtlSeconds(sdTtl);
4511
+ }
4512
+ const transactItems = [];
4513
+ // Delete current entity item — index 0, and the ONLY item in this
4514
+ // transaction carrying a ConditionExpression, so a cancellation
4515
+ // naming index 0 is unambiguously the user's condition. The guard
4516
+ // rides the transaction rather than being pre-checked against the
4517
+ // item read above: a client-side check would leave a race window
4518
+ // between the read and the write.
4519
+ const currentDelete = {
4520
+ TableName: tableName,
4521
+ Key: marshalledKey,
4522
+ ...guardedDeleteCondition(softGuard),
4523
+ };
4524
+ transactItems.push({ Delete: currentDelete });
4525
+ // Put soft-deleted item
4526
+ transactItems.push({
4527
+ Put: {
4528
+ TableName: tableName,
4529
+ Item: toAttributeMap(deletedItem),
4530
+ },
4531
+ });
4532
+ // Version snapshot if retain is enabled — never over existing history.
4533
+ let snapshotAt;
4534
+ if (isRetainEnabled()) {
4535
+ const currentVersion = systemFields.version
4536
+ ? (raw[systemFields.version] ?? 0)
4537
+ : 0;
4538
+ const snapshotItem = buildSnapshotItem(raw, currentVersion, primary.pk.field, primary.sk.field, ttlAttrName, dtNow);
4539
+ snapshotAt = { index: transactItems.length, version: currentVersion };
4540
+ transactItems.push({ Put: snapshotPut(tableName, snapshotItem) });
4541
+ }
4542
+ // Release sentinels if not preserving unique — only those this item
4543
+ // owns (#133). Sparse — fields that were unset on the live item
4544
+ // never had a sentinel, so nothing to release.
4545
+ const releases = hasUniqueConstraints && !preserveUnique()
4546
+ ? yield* ownedReleases(tableName, raw, transactItems)
4547
+ : [];
4548
+ yield* checkTransactionLimit(entityType, "delete", transactItems);
4549
+ yield* guardedDelete(client.transactWriteItems({ TransactItems: transactItems }), result.Item, softGuard.inputs, snapshotAt, releases);
4550
+ return result.Item;
4551
+ }
4552
+ else {
4553
+ // --- Hard delete with unique constraints and/or retained history ---
4554
+ // The item is read first: the sentinels to release are keyed by its
4555
+ // unique values, and a retain entity snapshots its final state.
4556
+ const result = yield* client.getItem({
4557
+ TableName: tableName,
4558
+ Key: marshalledKey,
4559
+ ConsistentRead: true,
4560
+ });
4561
+ if (!result.Item) {
4562
+ if (hasUniqueConstraints || mustExist) {
4563
+ return yield* missing(new ItemNotFound({ entityType, key: encodedKey }));
4564
+ }
4565
+ // Retain only: deleting a missing item writes nothing, as a plain
4566
+ // `DeleteItem` would. The caller's condition is still judged
4567
+ // against no item — and the delete never removes an item created
4568
+ // since the read, which would leave its final state unsnapshotted.
4569
+ if (!userCondition)
4570
+ return undefined;
4571
+ const values = userCondition.values;
4572
+ yield* client
4573
+ .deleteItem({
4574
+ TableName: tableName,
4575
+ Key: marshalledKey,
4576
+ ConditionExpression: `attribute_not_exists(#dpk) AND (${userCondition.expression})`,
4577
+ ExpressionAttributeNames: {
4578
+ ...userCondition.names,
4579
+ "#dpk": primary.pk.field,
4580
+ },
4581
+ ...(Object.keys(values).length > 0 && { ExpressionAttributeValues: values }),
4582
+ ReturnValuesOnConditionCheckFailure: "ALL_OLD",
4583
+ })
4584
+ .pipe(Effect.mapError((err) => isAwsConditionalCheckFailed(err.cause)
4585
+ ? conditionRejection(encodedKey, err.cause.Item, err.cause.Item === undefined ? undefined : { version: 0 }, true)
4586
+ : err));
4587
+ return undefined;
4588
+ }
4589
+ // The sentinel deletes are keyed by the unique values read, and the
4590
+ // snapshot copies the item read: the delete is conditioned on it —
4591
+ // its version and incarnation — so no sentinel is orphaned and no
4592
+ // concurrent write is lost from the history (#133).
4593
+ yield* checkVersion(result.Item, "delete");
4594
+ const hardGuard = deleteGuard(result.Item, hasUniqueConstraints
4595
+ ? [
4596
+ ...new Set(Object.values(config.unique).flatMap((def) => resolveUniqueFields(def).map(resolveDbName))),
4597
+ ]
4598
+ : [], userCondition?.expression);
4599
+ if (hardGuard instanceof ValidationError)
4600
+ return yield* hardGuard;
4601
+ const raw = fromAttributeMap(result.Item);
4602
+ const transactItems = [];
4603
+ // Delete entity item — index 0, and the only item conditioned on the
4604
+ // item read (the sentinel Deletes are conditioned on ownership, the
4605
+ // snapshot on history), so an index-0 cancellation is read from it.
2439
4606
  transactItems.push({
2440
4607
  Delete: {
2441
4608
  TableName: tableName,
2442
- Key: toAttributeMap({
2443
- [primary.pk.field]: sentinel.key.pk,
2444
- [primary.sk.field]: sentinel.key.sk,
2445
- }),
4609
+ Key: marshalledKey,
4610
+ ...guardedDeleteCondition(hardGuard),
2446
4611
  },
2447
4612
  });
4613
+ // Retain: the final state is history too. Snapshotted at its own
4614
+ // version — under the same never-overwrite guard as every snapshot —
4615
+ // so an item created again at this key continues PAST it, and a
4616
+ // writer holding this version can never match the new incarnation.
4617
+ let snapshotAt;
4618
+ if (isRetainEnabled()) {
4619
+ const currentVersion = storedVersionOf(result.Item) ?? 0;
4620
+ snapshotAt = { index: transactItems.length, version: currentVersion };
4621
+ transactItems.push({
4622
+ Put: snapshotPut(tableName, buildSnapshotItem(raw, currentVersion, primary.pk.field, primary.sk.field, ttlAttrName, yield* DateTime.now)),
4623
+ });
4624
+ }
4625
+ // Release sentinels — only those this item owns (#133). Sparse: no
4626
+ // sentinel was ever written for a constraint whose fields are unset.
4627
+ const releases = hasUniqueConstraints
4628
+ ? yield* ownedReleases(tableName, raw, transactItems)
4629
+ : [];
4630
+ yield* checkTransactionLimit(entityType, "delete", transactItems);
4631
+ yield* guardedDelete(client.transactWriteItems({ TransactItems: transactItems }), result.Item, hardGuard.inputs, snapshotAt, releases);
4632
+ return result.Item;
2448
4633
  }
2449
- }
2450
- yield* checkTransactionLimit(entityType, "delete", transactItems);
2451
- yield* client
2452
- .transactWriteItems({
2453
- TransactItems: transactItems,
2454
- })
2455
- .pipe(Effect.mapError(mapDeleteConditionFailure));
2456
- }
2457
- else if (hasUniqueConstraints) {
2458
- // --- Hard delete with unique constraints ---
2459
- // First get the item to find sentinel key values
2460
- const result = yield* client.getItem({
2461
- TableName: tableName,
2462
- Key: marshalledKey,
2463
4634
  });
2464
- if (!result.Item) {
2465
- return yield* Effect.fail(new ItemNotFound({ entityType, key: encodedKey }));
2466
- }
2467
- const raw = fromAttributeMap(result.Item);
2468
- const transactItems = [];
2469
- // Delete entity item — index 0, and the only conditioned item (the
2470
- // sentinel Deletes below are unconditional), so an index-0
2471
- // cancellation is unambiguously the user's condition.
2472
- const entityDelete = {
2473
- TableName: tableName,
2474
- Key: marshalledKey,
2475
- };
2476
- if (userCondition) {
2477
- entityDelete.ConditionExpression = userCondition.expression;
2478
- entityDelete.ExpressionAttributeNames = userCondition.names;
2479
- if (Object.keys(userCondition.values).length > 0) {
2480
- entityDelete.ExpressionAttributeValues = userCondition.values;
2481
- }
2482
- }
2483
- transactItems.push({ Delete: entityDelete });
2484
- // Delete sentinels (sparse — skip constraints whose fields were unset
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);
2488
- for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
2489
- const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, rawDomain);
2490
- if (!sentinel)
2491
- continue;
2492
- transactItems.push({
2493
- Delete: {
2494
- TableName: tableName,
2495
- Key: toAttributeMap({
2496
- [primary.pk.field]: sentinel.key.pk,
2497
- [primary.sk.field]: sentinel.key.sk,
2498
- }),
2499
- },
2500
- });
2501
- }
2502
- yield* checkTransactionLimit(entityType, "delete", transactItems);
2503
- yield* client
2504
- .transactWriteItems({
2505
- TransactItems: transactItems.map((t) => ({ Delete: t.Delete })),
2506
- })
2507
- .pipe(Effect.mapError(mapDeleteConditionFailure));
4635
+ // The caller asserted nothing about the item, so a concurrent write
4636
+ // between the read and the transaction — a changed item, or a
4637
+ // sentinel release that changed hands — is not a refusal: the
4638
+ // delete reads the item again and is written again, as a put is.
4639
+ // With a `.condition()` the read is what the condition was judged
4640
+ // against, and a race fails.
4641
+ // Deleted concurrently on every attempt: what a delete that read
4642
+ // it missing reports.
4643
+ const deleted = (yield* userCondition === undefined
4644
+ ? readFirst.pipe(Effect.retry({
4645
+ times: GUARDED_PUT_ATTEMPTS - 1,
4646
+ while: (e) => e instanceof OptimisticLockError ||
4647
+ e instanceof ConcurrentModification ||
4648
+ e instanceof DeletedConcurrently,
4649
+ }), Effect.catchIf((e) => e instanceof DeletedConcurrently, () => missing(hasUniqueConstraints || isSoftDeleteEnabled()
4650
+ ? new ItemNotFound({ entityType, key: encodedKey })
4651
+ : undefined)))
4652
+ : readFirst);
4653
+ return yield* deletedResult(deleted);
2508
4654
  }
2509
4655
  else {
2510
4656
  // Simple delete
@@ -2519,30 +4665,153 @@ const makeImpl = (config, precomputedData) => {
2519
4665
  deleteInput.ExpressionAttributeValues = userCondition.values;
2520
4666
  }
2521
4667
  }
2522
- if (opts.returnValues) {
2523
- deleteInput.ReturnValues = returnValuesMap[opts.returnValues];
4668
+ if (opts.returnValues !== undefined) {
4669
+ deleteInput.ReturnValues = opts.returnValues === "allOld" ? "ALL_OLD" : "NONE";
2524
4670
  }
2525
- yield* client.deleteItem(deleteInput).pipe(Effect.mapError(mapDeleteConditionFailure));
4671
+ const output = yield* client
4672
+ .deleteItem(deleteInput)
4673
+ .pipe(Effect.mapError(mapDeleteConditionFailure));
4674
+ return yield* deletedResult(output.Attributes);
2526
4675
  }
2527
4676
  }), self, key);
2528
4677
  // ---------------------------------------------------------------------------
2529
4678
  // deleteIfExists operation — delete + attribute_exists condition
2530
4679
  // ---------------------------------------------------------------------------
2531
4680
  const deleteIfExists = (key) => {
2532
- const pkField = config.indexes.primary.pk.field;
2533
4681
  const op = del(key);
2534
- return new EntityDeleteImpl(op._builder, op._entity, op._key, { attributeExists: [pkField] }, op._returnValues);
4682
+ return new EntityDeleteImpl(op._builder, op._entity, op._key, op._condition, op._returnValues, true);
2535
4683
  };
2536
4684
  // ---------------------------------------------------------------------------
2537
4685
  // upsert operation — create-or-update via UpdateItem with if_not_exists()
2538
4686
  // ---------------------------------------------------------------------------
4687
+ /**
4688
+ * `upsert` that reads the item first (#133) — of an entity with unique
4689
+ * constraints or `versioned: { retain: true }`, or an input that omits a
4690
+ * defaulted index composite. A single UpdateItem cannot write, rotate or
4691
+ * check a sentinel, snapshot the replaced item, or tell whether to store a
4692
+ * default or keep the stored value. Missing: a `create` (sentinels guarded
4693
+ * by `attribute_not_exists`, the retain snapshot, omitted defaults stored).
4694
+ * Present: an update of the upserted fields — sentinels rotate for changed
4695
+ * values, unchanged ones are left alone, immutable fields, `createdAt` and
4696
+ * fields the input omits keep their stored values, retain snapshots the
4697
+ * replaced item — under the update's version / incarnation or attribute
4698
+ * guards, from that one read. The whole input is validated either way. A
4699
+ * concurrent create or delete between the read and the write is retried the
4700
+ * other way, and a sentinel release whose reservation changed hands is
4701
+ * planned again from a fresh read (as a put's is); a race lost on every
4702
+ * attempt fails with the concurrency error. A concurrent change of the item
4703
+ * itself fails the upsert, as it fails an update.
4704
+ */
4705
+ const guardedUpsert = (input, mode, opts) => Effect.gen(function* () {
4706
+ const client = yield* DynamoClient;
4707
+ const { name: tableName } = yield* tableTag;
4708
+ yield* checkWithVectorNames(opts.withVectors, "upsert");
4709
+ // Required fields are required whether or not the item exists.
4710
+ yield* encodeOrDecodeEncode(schemas.inputSchema, yield* fillDecodingDefaults(input), entityType, "upsert");
4711
+ const record = (input ?? {});
4712
+ const primary = config.indexes.primary;
4713
+ const keyFields = [...primary.pk.composite, ...primary.sk.composite];
4714
+ const key = Object.fromEntries(keyFields.map((field) => [field, record[field]]));
4715
+ const encodedKey = yield* encodeKey(key, "upsert.decodeKey");
4716
+ const marshalledKey = toAttributeMap(composePrimaryKey(encodedKey));
4717
+ const dropped = new Set([
4718
+ ...keyFields,
4719
+ ...immutableFields,
4720
+ ...(systemFields.createdAt ? [systemFields.createdAt] : []),
4721
+ ...(systemFields.version ? [systemFields.version] : []),
4722
+ ]);
4723
+ const updates = Object.fromEntries(Object.entries(record).filter(([field]) => !dropped.has(field)));
4724
+ const read = () => client
4725
+ .getItem({ TableName: tableName, Key: marshalledKey, ConsistentRead: true })
4726
+ .pipe(Effect.map((result) => result.Item));
4727
+ let current = yield* read();
4728
+ let expected = 0;
4729
+ let lost;
4730
+ for (let attempt = 0; attempt < GUARDED_PUT_ATTEMPTS; attempt++) {
4731
+ yield* checkVersion(current, "upsert");
4732
+ expected = storedVersionOf(current) ?? 0;
4733
+ if (current === undefined) {
4734
+ const created = yield* Effect.exit(put(input)._builder(mode, {
4735
+ condition: opts.condition,
4736
+ withVectors: opts.withVectors,
4737
+ putKind: "create",
4738
+ operation: "upsert",
4739
+ }));
4740
+ if (created._tag === "Success")
4741
+ return created.value;
4742
+ const error = Cause.findErrorOption(created.cause);
4743
+ if (Option.isNone(error) || !(error.value instanceof ConditionalCheckFailed)) {
4744
+ return yield* created;
4745
+ }
4746
+ // Created concurrently: upsert it as the existing item it now is.
4747
+ // Still missing: the caller's condition rejected it.
4748
+ current = yield* read();
4749
+ if (current === undefined)
4750
+ return yield* created;
4751
+ continue;
4752
+ }
4753
+ const updated = yield* Effect.exit(runUpdate(key, mode, {
4754
+ ...emptyUpdateState,
4755
+ updates,
4756
+ condition: opts.condition,
4757
+ withVectors: opts.withVectors,
4758
+ }, current).pipe(
4759
+ // Its errors name the upsert, not the update it runs.
4760
+ Effect.mapError((e) => e instanceof ValidationError && e.operation.startsWith("update")
4761
+ ? new ValidationError({
4762
+ entityType: e.entityType,
4763
+ operation: `upsert${e.operation.slice("update".length)}`,
4764
+ cause: e.cause,
4765
+ })
4766
+ : e)));
4767
+ if (updated._tag === "Success")
4768
+ return updated.value;
4769
+ const error = Cause.findErrorOption(updated.cause);
4770
+ if (Option.isNone(error))
4771
+ return yield* updated;
4772
+ // A sentinel it would release changed hands since its ownership read:
4773
+ // nothing was written, and the upsert is planned again from a fresh
4774
+ // read, as a put is.
4775
+ if (error.value instanceof ConcurrentModification && releaseRaces.has(error.value)) {
4776
+ lost = error.value;
4777
+ }
4778
+ else if (!(error.value instanceof ItemNotFound)) {
4779
+ return yield* updated;
4780
+ }
4781
+ // Deleted concurrently: upserted as the missing item it now is.
4782
+ current = yield* read();
4783
+ }
4784
+ if (lost !== undefined)
4785
+ return yield* lost;
4786
+ // Every attempt raced a concurrent create or delete.
4787
+ return yield* systemFields.version
4788
+ ? new OptimisticLockError({
4789
+ entityType,
4790
+ key: encodedKey,
4791
+ expectedVersion: expected,
4792
+ actualVersion: storedVersionOf(current) ?? -1,
4793
+ })
4794
+ : new ConcurrentModification({
4795
+ entityType,
4796
+ key: encodedKey,
4797
+ attributes: [],
4798
+ current: Option.none(),
4799
+ });
4800
+ });
2539
4801
  const upsert = (input) => new EntityPutImpl((mode, opts) => Effect.gen(function* () {
4802
+ if ((config.unique != null && Object.keys(config.unique).length > 0) ||
4803
+ isRetainEnabled() ||
4804
+ omitsIndexedDefault(input)) {
4805
+ return yield* guardedUpsert(input, mode, opts);
4806
+ }
2540
4807
  const client = yield* DynamoClient;
2541
4808
  const { name: tableName } = yield* tableTag;
2542
4809
  // Clock-backed time source for createdAt/updatedAt SET clauses.
2543
4810
  const now = yield* DateTime.now;
2544
4811
  yield* checkWithVectorNames(opts.withVectors, "upsert");
2545
- // Encode user input → wire form (see `put` for strategy).
4812
+ // Encode user input → wire form (see `put` for strategy). An input
4813
+ // that omits a defaulted index composite took the guarded path: the
4814
+ // default is stored on create, the stored value kept on update.
2546
4815
  const encodedInput = yield* encodeOrDecodeEncode(schemas.inputSchema, input, entityType, "upsert");
2547
4816
  // Hydrate refs: replace ID fields with full entity domain data
2548
4817
  const encoded = hasRefs
@@ -2712,13 +4981,26 @@ const makeImpl = (config, precomputedData) => {
2712
4981
  setClauses.push(`${nameKey} = if_not_exists(${nameKey}, ${zeroKey}) + :vinc`);
2713
4982
  counter++;
2714
4983
  }
4984
+ // An upsert that creates the item gives it an incarnation token (#133).
4985
+ if (stampsIncarnation) {
4986
+ names["#eddInc"] = INCARNATION_TOKEN;
4987
+ values[":eddInc"] = toAttributeValue(yield* freshIncarnation);
4988
+ setClauses.push("#eddInc = if_not_exists(#eddInc, :eddInc)");
4989
+ }
2715
4990
  const updateExpression = upsertRemoveClauses.length > 0
2716
4991
  ? `SET ${setClauses.join(", ")} REMOVE ${upsertRemoveClauses.join(", ")}`
2717
4992
  : `SET ${setClauses.join(", ")}`;
2718
4993
  // Optional user condition
2719
4994
  const condParts = [];
2720
- if (opts.condition) {
2721
- const uc = compileCondition(opts.condition, resolveDbName);
4995
+ if (systemFields.version) {
4996
+ // Never version an item whose version was removed outside the library
4997
+ // (incarnation token, no version) as if it predated versioning (#133).
4998
+ names["#intVer"] = systemFields.version;
4999
+ names["#intInc"] = INCARNATION_TOKEN;
5000
+ condParts.push("(attribute_exists(#intVer) OR attribute_not_exists(#intInc))");
5001
+ }
5002
+ const uc = opts.condition ? compileCondition(opts.condition, resolveDbName) : undefined;
5003
+ if (uc) {
2722
5004
  condParts.push(`(${uc.expression})`);
2723
5005
  Object.assign(names, uc.names);
2724
5006
  Object.assign(values, uc.values);
@@ -2731,11 +5013,19 @@ const makeImpl = (config, precomputedData) => {
2731
5013
  ExpressionAttributeNames: names,
2732
5014
  ExpressionAttributeValues: values,
2733
5015
  ConditionExpression: condParts.length > 0 ? condParts.join(" AND ") : undefined,
5016
+ // Only the corruption guard needs the stored item.
5017
+ ...(systemFields.version && {
5018
+ ReturnValuesOnConditionCheckFailure: "ALL_OLD",
5019
+ }),
2734
5020
  ReturnValues: "ALL_NEW",
2735
5021
  })
2736
5022
  .pipe(Effect.mapError((err) => {
2737
- if (opts.condition && isAwsConditionalCheckFailed(err.cause)) {
2738
- return new ConditionalCheckFailed({ entityType, key: item });
5023
+ if (isAwsConditionalCheckFailed(err.cause)) {
5024
+ const corrupt = versionCorruption(err.cause.Item, "upsert");
5025
+ if (corrupt !== undefined)
5026
+ return corrupt;
5027
+ if (opts.condition)
5028
+ return new ConditionalCheckFailed({ entityType, key: item });
2739
5029
  }
2740
5030
  return err;
2741
5031
  }));
@@ -2817,7 +5107,7 @@ const makeImpl = (config, precomputedData) => {
2817
5107
  // `ValidationError("append.followUp")`. Undetected on skipFollowUp path.
2818
5108
  // ---------------------------------------------------------------------------
2819
5109
  const timeSeriesConfig = config.timeSeries;
2820
- const append = (input, userCondition, skipFollowUp = false, removeAttrs) => Effect.gen(function* () {
5110
+ const append = (input, givenCondition, skipFollowUp = false, removeAttrs) => Effect.gen(function* () {
2821
5111
  if (!timeSeriesConfig) {
2822
5112
  return yield* new ValidationError({
2823
5113
  entityType,
@@ -2825,6 +5115,17 @@ const makeImpl = (config, precomputedData) => {
2825
5115
  cause: "Entity is not configured with timeSeries. .append() requires timeSeries config.",
2826
5116
  });
2827
5117
  }
5118
+ // `append` takes its condition as an argument — no `condition`
5119
+ // combinator — so an empty one becomes none here (#133).
5120
+ const userCondition = nonEmptyCondition(givenCondition);
5121
+ const emptyPart = userCondition === undefined ? undefined : emptyPartProblem(toExpr(userCondition));
5122
+ if (emptyPart !== undefined) {
5123
+ return yield* new ValidationError({
5124
+ entityType,
5125
+ operation: "append.condition",
5126
+ cause: `append: ${emptyPart} Nothing was sent.`,
5127
+ });
5128
+ }
2828
5129
  const client = yield* DynamoClient;
2829
5130
  const tc = yield* tableTag;
2830
5131
  const tableName = tc.name;
@@ -3011,8 +5312,8 @@ const makeImpl = (config, precomputedData) => {
3011
5312
  values[":_tsNewOb"] = toAttributeValue(newObValue);
3012
5313
  const casCondition = "attribute_not_exists(#_tspk) OR #_tsob < :_tsNewOb";
3013
5314
  let finalCondition = casCondition;
3014
- if (userCondition !== undefined) {
3015
- const uc = compileCondition(userCondition, resolveDbName);
5315
+ const uc = userCondition !== undefined ? compileCondition(userCondition, resolveDbName) : undefined;
5316
+ if (uc) {
3016
5317
  finalCondition = `(${casCondition}) AND (${uc.expression})`;
3017
5318
  Object.assign(names, uc.names);
3018
5319
  Object.assign(values, uc.values);
@@ -3224,6 +5525,7 @@ const makeImpl = (config, precomputedData) => {
3224
5525
  const query = Query.make({
3225
5526
  tableName: "",
3226
5527
  indexName: indexDef.index,
5528
+ globalIndex: indexDef.index !== undefined,
3227
5529
  pkField: indexDef.pk.field,
3228
5530
  pkValue,
3229
5531
  skField: indexDef.sk.field,
@@ -3258,6 +5560,7 @@ const makeImpl = (config, precomputedData) => {
3258
5560
  decoder: (raw) => decodeRecord(raw),
3259
5561
  resolveTableName: tableTag.useSync((tc) => tc.name),
3260
5562
  keyFields: [config.indexes.primary.pk.field, config.indexes.primary.sk.field],
5563
+ liveRows: liveRows(),
3261
5564
  });
3262
5565
  // ---------------------------------------------------------------------------
3263
5566
  // getVersion operation
@@ -3270,21 +5573,30 @@ const makeImpl = (config, precomputedData) => {
3270
5573
  // Compose PK + version SK
3271
5574
  const primary = config.indexes.primary;
3272
5575
  const pkValue = KeyComposer.composePk(schema, entityType, primary, keyForm(encodedKey));
3273
- const versionSk = DynamoSchema.composeVersionKey(schema, entityType, versionNumber);
3274
- const marshalledKey = toAttributeMap({
3275
- [primary.pk.field]: pkValue,
3276
- [primary.sk.field]: versionSk,
3277
- });
3278
- const result = yield* client.getItem({
5576
+ const liveSk = liveSkOf(encodedKey);
5577
+ const read = (versionSk) => client
5578
+ .getItem({
3279
5579
  TableName: tableName,
3280
- Key: marshalledKey,
5580
+ Key: toAttributeMap({
5581
+ [primary.pk.field]: pkValue,
5582
+ [primary.sk.field]: versionSk,
5583
+ }),
3281
5584
  ConsistentRead: opts.consistentRead || undefined,
3282
- });
3283
- if (!result.Item) {
5585
+ })
5586
+ .pipe(Effect.map((result) => result.Item));
5587
+ let item = yield* read(DynamoSchema.composeVersionKey(schema, entityType, versionNumber, historyKeyOptions(liveSk)));
5588
+ // Not under the item's own key: written by an earlier release, under
5589
+ // the partition-wide one — if it is this item's (#133).
5590
+ if (item === undefined && hasItemHistory) {
5591
+ const legacy = yield* read(DynamoSchema.composeVersionKey(schema, entityType, versionNumber));
5592
+ if (legacy !== undefined && isItemsRow(legacy, liveSk))
5593
+ item = legacy;
5594
+ }
5595
+ if (!item) {
3284
5596
  return yield* new ItemNotFound({ entityType, key: encodedKey });
3285
5597
  }
3286
- const raw = fromAttributeMap(result.Item);
3287
- return yield* decodeAs(raw, result.Item, mode);
5598
+ const raw = fromAttributeMap(item);
5599
+ return yield* decodeAs(raw, item, mode);
3288
5600
  }), self, key);
3289
5601
  // ---------------------------------------------------------------------------
3290
5602
  // versions operation
@@ -3293,7 +5605,54 @@ const makeImpl = (config, precomputedData) => {
3293
5605
  const encodedKey = keyForm(encodeKeySync(key));
3294
5606
  const primary = config.indexes.primary;
3295
5607
  const pkValue = KeyComposer.composePk(schema, entityType, primary, keyForm(encodedKey));
3296
- const versionPrefix = DynamoSchema.composeVersionKeyPrefix(schema, entityType);
5608
+ const liveSk = liveSkOf(encodedKey);
5609
+ const versionPrefix = DynamoSchema.composeVersionKeyPrefix(schema, entityType, historyKeyOptions(liveSk));
5610
+ /**
5611
+ * History an earlier release wrote for this item, under the partition-wide
5612
+ * keys (#133): when there is any, the query reads the partition's history
5613
+ * and keeps the item's own rows plus those — a version held both ways is
5614
+ * read from its own row. Without any, it reads only the item's own.
5615
+ */
5616
+ const withLegacy = hasItemHistory
5617
+ ? (tableName) => Effect.gen(function* () {
5618
+ const legacy = yield* legacyHistory({ tableName, pk: pkValue, liveSk, kind: "version" });
5619
+ if (legacy.length === 0)
5620
+ return {};
5621
+ const client = yield* DynamoClient;
5622
+ const own = new Set();
5623
+ let start;
5624
+ do {
5625
+ const result = yield* client.query({
5626
+ TableName: tableName,
5627
+ KeyConditionExpression: "#pk = :pk AND begins_with(#sk, :prefix)",
5628
+ ExpressionAttributeNames: { "#pk": primary.pk.field, "#sk": primary.sk.field },
5629
+ ExpressionAttributeValues: {
5630
+ ":pk": toAttributeValue(pkValue),
5631
+ ":prefix": toAttributeValue(versionPrefix),
5632
+ },
5633
+ ProjectionExpression: "#sk",
5634
+ ConsistentRead: true,
5635
+ ExclusiveStartKey: start,
5636
+ });
5637
+ for (const row of result.Items ?? [])
5638
+ own.add(versionOfSk(row[primary.sk.field]?.S));
5639
+ start = result.LastEvaluatedKey;
5640
+ } while (start !== undefined);
5641
+ const legacySks = new Set(legacy
5642
+ .map((row) => row[primary.sk.field]?.S ?? "")
5643
+ .filter((sk) => !own.has(versionOfSk(sk))));
5644
+ return {
5645
+ replaceBeginsWith: {
5646
+ from: versionPrefix,
5647
+ to: DynamoSchema.composeVersionKeyPrefix(schema, entityType),
5648
+ },
5649
+ keep: (row) => {
5650
+ const sk = row[primary.sk.field]?.S ?? "";
5651
+ return sk.startsWith(versionPrefix) || legacySks.has(sk);
5652
+ },
5653
+ };
5654
+ })
5655
+ : undefined;
3297
5656
  return Query.make({
3298
5657
  tableName: "",
3299
5658
  indexName: undefined,
@@ -3304,42 +5663,30 @@ const makeImpl = (config, precomputedData) => {
3304
5663
  decoder: (raw) => decodeRecord(raw),
3305
5664
  resolveTableName: tableTag.useSync((tc) => tc.name),
3306
5665
  keyFields: [primary.pk.field, primary.sk.field],
5666
+ prepare: withLegacy,
3307
5667
  }).pipe(Query.where({ beginsWith: versionPrefix }));
3308
5668
  };
3309
5669
  // ---------------------------------------------------------------------------
3310
5670
  // deleted namespace
3311
5671
  // ---------------------------------------------------------------------------
3312
5672
  const deletedGet = (key) => new EntityGetImpl((mode, _opts) => Effect.gen(function* () {
3313
- const client = yield* DynamoClient;
3314
5673
  const { name: tableName } = yield* tableTag;
3315
5674
  // Caller key: Type side in, ENCODED out (see `encodeKey`).
3316
5675
  const encodedKey = yield* encodeKey(key, "deleted.get.decode");
3317
- // Query for soft-deleted item using begins_with on deleted prefix
5676
+ // The item's latest tombstone — not a sibling's in the same partition,
5677
+ // and one an earlier release wrote counts (#133).
3318
5678
  const primary = config.indexes.primary;
3319
5679
  const pkValue = KeyComposer.composePk(schema, entityType, primary, keyForm(encodedKey));
3320
- const deletedPrefix = DynamoSchema.composeDeletedKeyPrefix(schema, entityType);
3321
- const result = yield* client.query({
3322
- TableName: tableName,
3323
- KeyConditionExpression: "#pk = :pk AND begins_with(#sk, :skPrefix)",
3324
- ExpressionAttributeNames: {
3325
- "#pk": primary.pk.field,
3326
- "#sk": primary.sk.field,
3327
- },
3328
- ExpressionAttributeValues: {
3329
- ":pk": toAttributeValue(pkValue),
3330
- ":skPrefix": toAttributeValue(deletedPrefix),
3331
- },
3332
- Limit: 1,
3333
- ScanIndexForward: false,
3334
- });
3335
- if (!result.Items || result.Items.length === 0) {
5680
+ const tombstone = yield* latestTombstone(tableName, pkValue, liveSkOf(encodedKey));
5681
+ if (tombstone === undefined) {
3336
5682
  return yield* new ItemNotFound({ entityType, key: encodedKey });
3337
5683
  }
3338
- const raw = fromAttributeMap(result.Items[0]);
5684
+ const raw = fromAttributeMap(tombstone);
5685
+ yield* checkVersion(raw, "deleted.get.decode");
3339
5686
  // Soft-deleted items have GSI keys stripped — always use deletedRecordSchema
3340
5687
  // (itemSchema would fail because it expects GSI key fields that aren't present)
3341
5688
  if (mode === "native")
3342
- return result.Items[0];
5689
+ return tombstone;
3343
5690
  // Sparse Map fields: rebuild domain Records from flattened attrs so
3344
5691
  // soft-deleted items decode correctly. Sparse data is preserved
3345
5692
  // verbatim across soft-delete (GSI keys are stripped, sparse data is
@@ -3361,8 +5708,13 @@ const makeImpl = (config, precomputedData) => {
3361
5708
  const encodedKey = keyForm(encodeKeySync(key));
3362
5709
  const primary = config.indexes.primary;
3363
5710
  const pkValue = KeyComposer.composePk(schema, entityType, primary, keyForm(encodedKey));
5711
+ // The whole partition's tombstones — every item's, with sort key
5712
+ // composites (#133): no item segment.
3364
5713
  const deletedPrefix = DynamoSchema.composeDeletedKeyPrefix(schema, entityType);
3365
5714
  const decodeDeleted = (raw) => {
5715
+ const corruption = versionCorruption(raw, "deleted.list.decode");
5716
+ if (corruption !== undefined)
5717
+ return Effect.fail(corruption);
3366
5718
  // Sparse fields are domain data and are preserved across soft-delete.
3367
5719
  // Rebuild Records from flattened attrs before schema decode.
3368
5720
  deserializeSparseFields(raw);
@@ -3398,28 +5750,16 @@ const makeImpl = (config, precomputedData) => {
3398
5750
  const now = yield* DateTime.now;
3399
5751
  // Caller key: Type side in, ENCODED out (see `encodeKey`).
3400
5752
  const encodedKey = yield* encodeKey(key, "restore.decode");
3401
- // Query for the soft-deleted item
5753
+ // The item's latest tombstone — not a sibling's in the same partition,
5754
+ // and one an earlier release wrote counts (#133).
3402
5755
  const primary = config.indexes.primary;
3403
5756
  const pkValue = KeyComposer.composePk(schema, entityType, primary, keyForm(encodedKey));
3404
- const deletedPrefix = DynamoSchema.composeDeletedKeyPrefix(schema, entityType);
3405
- const queryResult = yield* client.query({
3406
- TableName: tableName,
3407
- KeyConditionExpression: "#pk = :pk AND begins_with(#sk, :skPrefix)",
3408
- ExpressionAttributeNames: {
3409
- "#pk": primary.pk.field,
3410
- "#sk": primary.sk.field,
3411
- },
3412
- ExpressionAttributeValues: {
3413
- ":pk": toAttributeValue(pkValue),
3414
- ":skPrefix": toAttributeValue(deletedPrefix),
3415
- },
3416
- Limit: 1,
3417
- ScanIndexForward: false,
3418
- });
3419
- if (!queryResult.Items || queryResult.Items.length === 0) {
5757
+ const tombstone = yield* latestTombstone(tableName, pkValue, liveSkOf(encodedKey));
5758
+ if (tombstone === undefined) {
3420
5759
  return yield* new ItemNotFound({ entityType, key: encodedKey });
3421
5760
  }
3422
- const deletedRaw = fromAttributeMap(queryResult.Items[0]);
5761
+ yield* checkVersion(tombstone, "restore");
5762
+ const deletedRaw = fromAttributeMap(tombstone);
3423
5763
  const deletedMarshalledKey = toAttributeMap({
3424
5764
  [primary.pk.field]: deletedRaw[primary.pk.field],
3425
5765
  [primary.sk.field]: deletedRaw[primary.sk.field],
@@ -3431,8 +5771,9 @@ const makeImpl = (config, precomputedData) => {
3431
5771
  delete restoredItem.deletedAt;
3432
5772
  delete restoredItem[ttlAttrName];
3433
5773
  // Increment version
5774
+ // 0: a tombstone of an item written before the entity was `versioned`.
3434
5775
  const currentVersion = systemFields.version
3435
- ? restoredItem[systemFields.version]
5776
+ ? (restoredItem[systemFields.version] ?? 0)
3436
5777
  : 0;
3437
5778
  const newVersion = currentVersion + 1;
3438
5779
  if (systemFields.version)
@@ -3464,15 +5805,24 @@ const makeImpl = (config, precomputedData) => {
3464
5805
  }
3465
5806
  const marshalledRestoredItem = toAttributeMap(restoredItem);
3466
5807
  const transactItems = [];
3467
- // Delete the soft-deleted item
5808
+ // Delete the soft-deleted item — only while it is still there: a
5809
+ // concurrent restore that already consumed it must not restore twice.
3468
5810
  transactItems.push({
3469
- Delete: { TableName: tableName, Key: deletedMarshalledKey },
5811
+ Delete: {
5812
+ TableName: tableName,
5813
+ Key: deletedMarshalledKey,
5814
+ ConditionExpression: "attribute_exists(#pk)",
5815
+ ExpressionAttributeNames: { "#pk": primary.pk.field },
5816
+ },
3470
5817
  });
3471
- // Put restored item
5818
+ // Put restored item — never over a live item (a re-created one, or
5819
+ // another restore's): that would lose it and orphan its sentinels.
3472
5820
  transactItems.push({
3473
5821
  Put: {
3474
5822
  TableName: tableName,
3475
5823
  Item: marshalledRestoredItem,
5824
+ ConditionExpression: "attribute_not_exists(#pk)",
5825
+ ExpressionAttributeNames: { "#pk": primary.pk.field },
3476
5826
  },
3477
5827
  });
3478
5828
  // Version snapshot if retain enabled. The source is the TOMBSTONE, so
@@ -3488,12 +5838,11 @@ const makeImpl = (config, precomputedData) => {
3488
5838
  const snapshotSource = { ...deletedRaw };
3489
5839
  delete snapshotSource.deletedAt;
3490
5840
  delete snapshotSource[ttlAttrName];
3491
- const snapshotItem = buildSnapshotItem(snapshotSource, currentVersion, primary.pk.field, primary.sk.field, ttlAttrName, now);
5841
+ const snapshotItem = buildSnapshotItem(snapshotSource, currentVersion, primary.pk.field, primary.sk.field, ttlAttrName, now, restoredKeys[primary.sk.field]);
5842
+ // Replaces only the delete-time snapshot of this same state —
5843
+ // never another version's history.
3492
5844
  transactItems.push({
3493
- Put: {
3494
- TableName: tableName,
3495
- Item: toAttributeMap(snapshotItem),
3496
- },
5845
+ Put: snapshotPut(tableName, snapshotItem),
3497
5846
  });
3498
5847
  }
3499
5848
  // Re-establish unique constraint sentinels (sparse — fields that are
@@ -3533,6 +5882,17 @@ const makeImpl = (config, precomputedData) => {
3533
5882
  .pipe(Effect.mapError((err) => {
3534
5883
  if (isAwsTransactionCancelled(err.cause)) {
3535
5884
  const reasons = err.cause.CancellationReasons;
5885
+ // The tombstone is gone (restored concurrently), or a live
5886
+ // item already exists under the key.
5887
+ if (reasons?.[0]?.Code === "ConditionalCheckFailed") {
5888
+ return new ItemNotFound({ entityType, key: encodedKey });
5889
+ }
5890
+ if (reasons?.[1]?.Code === "ConditionalCheckFailed") {
5891
+ return new ItemNotDeleted({ entityType, key: encodedKey });
5892
+ }
5893
+ if (isRetainEnabled() && reasons?.[2]?.Code === "ConditionalCheckFailed") {
5894
+ return historyConflict(currentVersion, "restore");
5895
+ }
3536
5896
  if (reasons && config.unique) {
3537
5897
  // Sentinel Puts come after Delete + Put + optional snapshot, in the
3538
5898
  // order recorded in `sentinelConstraints` (sparse-skipped entries absent)
@@ -3541,7 +5901,9 @@ const makeImpl = (config, precomputedData) => {
3541
5901
  if (reasons[i]?.Code === "ConditionalCheckFailed") {
3542
5902
  const constraintName = sentinelConstraints[i - sentinelStart] ?? "unknown";
3543
5903
  const constraintDef = config.unique[constraintName];
3544
- const uniqueFields = constraintDef ? resolveUniqueFields(constraintDef) : [];
5904
+ const uniqueFields = constraintDef
5905
+ ? resolveUniqueFields(constraintDef)
5906
+ : [];
3545
5907
  const fieldsRecord = {};
3546
5908
  for (const f of uniqueFields) {
3547
5909
  const v = restoredDomain[f];
@@ -3566,8 +5928,9 @@ const makeImpl = (config, precomputedData) => {
3566
5928
  // purge operation
3567
5929
  // ---------------------------------------------------------------------------
3568
5930
  const purge = (key) => new EntityDeleteImpl((opts) => Effect.gen(function* () {
3569
- // `purge` is partition-wide — it queries every item under the key and
3570
- // batch-deletes in chunks, so there is no single item for a
5931
+ // `purge` queries the partition — every row of it, or with sort key
5932
+ // composites this item's rows in it (#133) — and batch-deletes them
5933
+ // in chunks, so there is no single item for a
3571
5934
  // ConditionExpression to guard and no way to make one atomic across
3572
5935
  // the batches. `.condition()` is structurally available because
3573
5936
  // `purge` returns an `EntityDelete`; refuse it loudly rather than
@@ -3588,43 +5951,91 @@ const makeImpl = (config, precomputedData) => {
3588
5951
  const encodedKey = yield* encodeKey(key, "purge.decode");
3589
5952
  const primary = config.indexes.primary;
3590
5953
  const pkValue = KeyComposer.composePk(schema, entityType, primary, keyForm(encodedKey));
3591
- // Query ALL items in this partition (current + versions + deleted)
3592
- // Use ProjectionExpression to only get keys
5954
+ const liveSk = liveSkOf(encodedKey);
5955
+ const history = historyKeyOptions(liveSk);
5956
+ /**
5957
+ * Whether a row of the partition is this item's (#133). Only this
5958
+ * entity's rows (`__edd_e__`): in a single-table design another
5959
+ * entity can share the partition — a collection on the primary key —
5960
+ * and purge never removes its rows. Without sort key composites that
5961
+ * is every row of this entity. With them, the partition holds
5962
+ * siblings, and the item's rows are its live row, the rows nested
5963
+ * under it (time-series events), and its own history. History an
5964
+ * earlier release wrote without an item segment is the item's when
5965
+ * the composites it carries compose the item's live key.
5966
+ */
5967
+ const ownVersions = DynamoSchema.composeVersionKeyPrefix(schema, entityType, history);
5968
+ const ownTombstones = DynamoSchema.composeDeletedKeyPrefix(schema, entityType, history);
5969
+ const sharedHistory = [
5970
+ DynamoSchema.composeVersionKeyPrefix(schema, entityType),
5971
+ DynamoSchema.composeDeletedKeyPrefix(schema, entityType),
5972
+ ];
5973
+ const belongsToItem = (row) => {
5974
+ if (row.__edd_e__?.S !== entityType)
5975
+ return false;
5976
+ if (history === undefined)
5977
+ return true;
5978
+ const sk = row[primary.sk.field]?.S;
5979
+ if (sk === undefined)
5980
+ return false;
5981
+ if (sk === liveSk ||
5982
+ sk.startsWith(`${liveSk}#`) ||
5983
+ sk.startsWith(ownVersions) ||
5984
+ sk.startsWith(ownTombstones)) {
5985
+ return true;
5986
+ }
5987
+ const unsegmented = sharedHistory.some((prefix) => sk.startsWith(prefix) && !sk.slice(prefix.length).includes("#"));
5988
+ return unsegmented && isItemsRow(row, liveSk);
5989
+ };
5990
+ // Query ALL items in this partition (current + versions + deleted).
5991
+ // Keys and entity type only — plus, with sort key composites, the
5992
+ // composites that attribute an unsegmented history row to its item.
5993
+ const projected = { "#ent": "__edd_e__" };
5994
+ if (history !== undefined) {
5995
+ primary.sk.composite.forEach((attr, i) => {
5996
+ projected[`#c${i}`] = resolveDbName(attr);
5997
+ });
5998
+ }
3593
5999
  const allItems = [];
3594
6000
  let exclusiveStartKey;
3595
6001
  do {
3596
6002
  const result = yield* client.query({
3597
6003
  TableName: tableName,
3598
6004
  KeyConditionExpression: "#pk = :pk",
3599
- ExpressionAttributeNames: { "#pk": primary.pk.field },
6005
+ ExpressionAttributeNames: {
6006
+ "#pk": primary.pk.field,
6007
+ "#sk": primary.sk.field,
6008
+ ...projected,
6009
+ },
3600
6010
  ExpressionAttributeValues: { ":pk": toAttributeValue(pkValue) },
3601
- ProjectionExpression: `${primary.pk.field}, ${primary.sk.field}`,
6011
+ ProjectionExpression: ["#pk", "#sk", ...Object.keys(projected)].join(", "),
3602
6012
  ExclusiveStartKey: exclusiveStartKey,
3603
6013
  });
3604
6014
  if (result.Items) {
3605
- allItems.push(...result.Items);
6015
+ allItems.push(...result.Items.filter(belongsToItem));
3606
6016
  }
3607
6017
  exclusiveStartKey = result.LastEvaluatedKey;
3608
6018
  } while (exclusiveStartKey);
3609
- // Also get the main/deleted item to find unique sentinel keys
6019
+ // The unique sentinels the item holds — live, or soft-deleted with
6020
+ // `preserveUnique` — and owns (#133): a sentinel of a value the item
6021
+ // holds without owning its reservation belongs to another item.
3610
6022
  if (config.unique && Object.keys(config.unique).length > 0) {
3611
- // Get current or deleted item to extract field values for sentinel cleanup
3612
- const marshalledKey = toAttributeMap({
3613
- [primary.pk.field]: pkValue,
3614
- [primary.sk.field]: KeyComposer.composeSk(schema, entityType, entityVersion, primary, keyForm(encodedKey)),
3615
- });
6023
+ const owner = {
6024
+ pk: pkValue,
6025
+ sk: KeyComposer.composeSk(schema, entityType, entityVersion, primary, keyForm(encodedKey)),
6026
+ };
6027
+ const holders = [];
3616
6028
  const mainResult = yield* client.getItem({
3617
6029
  TableName: tableName,
3618
- Key: marshalledKey,
6030
+ Key: toAttributeMap({ [primary.pk.field]: owner.pk, [primary.sk.field]: owner.sk }),
6031
+ ConsistentRead: true,
3619
6032
  });
3620
- // Try deleted items if main not found
3621
- let entityItem;
3622
- if (mainResult.Item) {
3623
- entityItem = fromAttributeMap(mainResult.Item);
3624
- }
3625
- else {
3626
- // Check for a soft-deleted item
3627
- const deletedPrefix = DynamoSchema.composeDeletedKeyPrefix(schema, entityType);
6033
+ if (mainResult.Item)
6034
+ holders.push(mainResult.Item);
6035
+ // Every soft-deleted incarnation (each kept its reservations).
6036
+ const deletedPrefix = DynamoSchema.composeDeletedKeyPrefix(schema, entityType);
6037
+ let deletedStart;
6038
+ do {
3628
6039
  const deletedResult = yield* client.query({
3629
6040
  TableName: tableName,
3630
6041
  KeyConditionExpression: "#pk = :pk AND begins_with(#sk, :skPrefix)",
@@ -3636,27 +6047,28 @@ const makeImpl = (config, precomputedData) => {
3636
6047
  ":pk": toAttributeValue(pkValue),
3637
6048
  ":skPrefix": toAttributeValue(deletedPrefix),
3638
6049
  },
3639
- Limit: 1,
6050
+ ConsistentRead: true,
6051
+ ExclusiveStartKey: deletedStart,
3640
6052
  });
3641
- if (deletedResult.Items && deletedResult.Items.length > 0) {
3642
- entityItem = fromAttributeMap(deletedResult.Items[0]);
3643
- }
3644
- }
3645
- if (entityItem) {
3646
- // Add sentinel keys to delete list (sparse — fields that were unset
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);
6053
+ holders.push(...(deletedResult.Items ?? []).filter(belongsToItem));
6054
+ deletedStart = deletedResult.LastEvaluatedKey;
6055
+ } while (deletedStart !== undefined);
6056
+ // Sparse — fields that were unset never had a sentinel. The stored
6057
+ // row is attribute-keyed, the constraint is not (#127).
6058
+ const held = new Map();
6059
+ for (const holder of holders) {
6060
+ const holderDomain = toDomainView(fromAttributeMap(holder));
3650
6061
  for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
3651
- const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, entityItemDomain);
3652
- if (!sentinel)
3653
- continue;
3654
- allItems.push(toAttributeMap({
3655
- [primary.pk.field]: sentinel.key.pk,
3656
- [primary.sk.field]: sentinel.key.sk,
3657
- }));
6062
+ const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, holderDomain);
6063
+ if (sentinel)
6064
+ held.set(`${sentinel.key.pk}\u0000${sentinel.key.sk}`, sentinel.key);
3658
6065
  }
3659
6066
  }
6067
+ // Released one by one, each only while still this item's: a batch
6068
+ // delete cannot carry the ownership condition.
6069
+ for (const sentinel of yield* ownedSentinels(tableName, [...held.values()], owner)) {
6070
+ yield* client.deleteItem(sentinelRelease(tableName, sentinel, owner).Delete).pipe(Effect.catchIf((err) => isAwsConditionalCheckFailed(err.cause), () => Effect.void));
6071
+ }
3660
6072
  }
3661
6073
  // Batch delete in chunks of 25
3662
6074
  for (let i = 0; i < allItems.length; i += 25) {
@@ -3748,8 +6160,13 @@ const makeImpl = (config, precomputedData) => {
3748
6160
  _serializeSparseFields: serializeSparseFields,
3749
6161
  _keyForm: keyForm,
3750
6162
  _renameToDynamo: renameToDynamo,
3751
- _buildPutSideItems: buildPutSideItems,
6163
+ _incarnationToken: stampsIncarnation,
6164
+ _versionCorruption: versionCorruption,
6165
+ _fillDecodingDefaults: fillDecodingDefaults,
6166
+ _unsentineledDefaults: unsentineledDefaults,
6167
+ _planPut: planPut,
3752
6168
  _multiItemWriteFeatures: multiItemWriteFeatures(),
6169
+ _liveRows: liveRows,
3753
6170
  _attachPrototype: attachPrototype,
3754
6171
  _configure: (injectedSchema, injectedTableTag) => {
3755
6172
  schema = injectedSchema;
@@ -3856,6 +6273,7 @@ export const bind = (entity) => Effect.gen(function* () {
3856
6273
  pathBuilder: createPathBuilder(),
3857
6274
  conditionOps: createConditionOps(),
3858
6275
  provide,
6276
+ ...entityNaming(entity._resolveDbName),
3859
6277
  };
3860
6278
  return new BoundQueryImpl(q, bqConfig);
3861
6279
  };
@@ -4049,6 +6467,7 @@ export const bind = (entity) => Effect.gen(function* () {
4049
6467
  provide,
4050
6468
  skFields: orderBy ? [orderBy] : [],
4051
6469
  composeSkCondition,
6470
+ ...entityNaming(entity._resolveDbName),
4052
6471
  };
4053
6472
  return new BoundQueryImpl(q, bqConfig);
4054
6473
  },
@@ -4092,16 +6511,24 @@ export const extractTransactable = (op) => {
4092
6511
  if (target._opType === "get") {
4093
6512
  return { opType: "get", entity: target._entity, key: target._key };
4094
6513
  }
6514
+ // `create`'s own not-exists guard is ANDed with the caller's condition:
6515
+ // no `.condition()` replaces it (#133).
6516
+ const primary = target._entity.indexes.primary;
4095
6517
  if (target._opType === "put") {
6518
+ const putKind = target._putKind ?? "put";
4096
6519
  return {
4097
6520
  opType: "put",
4098
6521
  entity: target._entity,
4099
6522
  input: target._input,
4100
- condition: target._condition,
4101
- putKind: target._putKind ?? "put",
6523
+ condition: withGuard(putKind === "create"
6524
+ ? { attributeNotExists: [primary.pk.field, primary.sk.field] }
6525
+ : undefined, target._condition),
6526
+ putKind,
4102
6527
  };
4103
6528
  }
4104
6529
  if (target._opType === "update") {
6530
+ // Every multi-item path (transactions, `Batch.write`, `EventStore`
6531
+ // additional items) refuses an update, so no guard is carried here.
4105
6532
  return {
4106
6533
  opType: "update",
4107
6534
  entity: target._entity,
@@ -4110,13 +6537,16 @@ export const extractTransactable = (op) => {
4110
6537
  };
4111
6538
  }
4112
6539
  }
4113
- // Check for EntityDelete intermediate
6540
+ // Check for EntityDelete intermediate — `deleteIfExists`'s exists guard
6541
+ // ANDed with the caller's condition.
4114
6542
  if (isEntityDelete(target)) {
4115
6543
  return {
4116
6544
  opType: "delete",
4117
6545
  entity: target._entity,
4118
6546
  key: target._key,
4119
- condition: target._condition,
6547
+ condition: withGuard(target._mustExist
6548
+ ? { attributeExists: [target._entity.indexes.primary.pk.field] }
6549
+ : undefined, target._condition),
4120
6550
  };
4121
6551
  }
4122
6552
  return undefined;
@@ -4150,9 +6580,14 @@ export const itemSchema = (entity) => entity.schemas.itemSchema;
4150
6580
  *
4151
6581
  * Returns an Effect that fails with `ValidationError` if the item doesn't match the schema.
4152
6582
  */
4153
- export const decodeMarshalledItem = (entity, marshalledItem) => Schema.decodeUnknownEffect(entity.schemas.itemSchema)(fromAttributeMap(marshalledItem)).pipe(Effect.map((decoded) => (entity._attachPrototype ? entity._attachPrototype(decoded) : decoded)), Effect.mapError((cause) => new ValidationError({
4154
- entityType: entity.entityType,
4155
- operation: "decodeMarshalledItem",
4156
- cause,
4157
- })));
6583
+ export const decodeMarshalledItem = (entity, marshalledItem) => {
6584
+ const corruption = entity._versionCorruption?.(marshalledItem, "decodeMarshalledItem");
6585
+ if (corruption !== undefined)
6586
+ return Effect.fail(corruption);
6587
+ return Schema.decodeUnknownEffect(entity.schemas.itemSchema)(fromAttributeMap(marshalledItem)).pipe(Effect.map((decoded) => (entity._attachPrototype ? entity._attachPrototype(decoded) : decoded)), Effect.mapError((cause) => new ValidationError({
6588
+ entityType: entity.entityType,
6589
+ operation: "decodeMarshalledItem",
6590
+ cause,
6591
+ })));
6592
+ };
4158
6593
  //# sourceMappingURL=Entity.js.map