effect-dynamodb 1.22.0 → 1.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Aggregate.d.ts +9 -4
- package/dist/Aggregate.d.ts.map +1 -1
- package/dist/Aggregate.js +644 -219
- package/dist/Aggregate.js.map +1 -1
- package/dist/Batch.d.ts +7 -0
- package/dist/Batch.d.ts.map +1 -1
- package/dist/Batch.js +132 -7
- package/dist/Batch.js.map +1 -1
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +13 -31
- package/dist/Collection.js.map +1 -1
- package/dist/DynamoClient.d.ts +40 -3
- package/dist/DynamoClient.d.ts.map +1 -1
- package/dist/DynamoClient.js +70 -19
- package/dist/DynamoClient.js.map +1 -1
- package/dist/Entity.d.ts +138 -55
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +3061 -626
- package/dist/Entity.js.map +1 -1
- package/dist/EventStore.d.ts +729 -53
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +1216 -231
- package/dist/EventStore.js.map +1 -1
- package/dist/Query.d.ts +75 -3
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +228 -30
- package/dist/Query.js.map +1 -1
- package/dist/Transaction.d.ts +13 -2
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +48 -38
- package/dist/Transaction.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/BoundCrud.d.ts +31 -20
- package/dist/internal/BoundCrud.d.ts.map +1 -1
- package/dist/internal/BoundCrud.js +1 -1
- package/dist/internal/BoundCrud.js.map +1 -1
- package/dist/internal/BoundQuery.d.ts +47 -2
- package/dist/internal/BoundQuery.d.ts.map +1 -1
- package/dist/internal/BoundQuery.js +182 -18
- package/dist/internal/BoundQuery.js.map +1 -1
- package/dist/internal/EntityCombinators.d.ts +32 -10
- package/dist/internal/EntityCombinators.d.ts.map +1 -1
- package/dist/internal/EntityCombinators.js +16 -7
- package/dist/internal/EntityCombinators.js.map +1 -1
- package/dist/internal/EntityOps.d.ts +44 -10
- package/dist/internal/EntityOps.d.ts.map +1 -1
- package/dist/internal/EntityOps.js +48 -5
- package/dist/internal/EntityOps.js.map +1 -1
- package/dist/internal/Expr.d.ts +23 -0
- package/dist/internal/Expr.d.ts.map +1 -1
- package/dist/internal/Expr.js +84 -9
- package/dist/internal/Expr.js.map +1 -1
- package/dist/internal/ItemSize.d.ts +38 -0
- package/dist/internal/ItemSize.d.ts.map +1 -0
- package/dist/internal/ItemSize.js +62 -0
- package/dist/internal/ItemSize.js.map +1 -0
- package/dist/internal/TransactWriteOps.d.ts +112 -19
- package/dist/internal/TransactWriteOps.d.ts.map +1 -1
- package/dist/internal/TransactWriteOps.js +210 -33
- package/dist/internal/TransactWriteOps.js.map +1 -1
- package/dist/internal/TransactableOps.d.ts +33 -14
- package/dist/internal/TransactableOps.d.ts.map +1 -1
- package/dist/internal/TransactableOps.js +46 -19
- package/dist/internal/TransactableOps.js.map +1 -1
- 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,
|
|
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
|
-
*
|
|
874
|
-
*
|
|
875
|
-
*
|
|
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
|
-
*
|
|
878
|
-
*
|
|
879
|
-
*
|
|
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
|
-
* `
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
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
|
|
888
|
-
const
|
|
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
|
|
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
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
|
|
922
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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:
|
|
2729
|
+
operation: `${operation}.sparse`,
|
|
1257
2730
|
cause: e instanceof Error ? e.message : String(e),
|
|
1258
2731
|
});
|
|
1259
2732
|
}
|
|
1260
|
-
|
|
1261
|
-
//
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
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
|
-
|
|
1371
|
-
|
|
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 (
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1502
|
-
|
|
1503
|
-
|
|
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*
|
|
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
|
-
|
|
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
|
-
//
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
const
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
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
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
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
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
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
|
-
|
|
1764
|
-
if (
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
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
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
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 (
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
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
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
//
|
|
1816
|
-
//
|
|
1817
|
-
//
|
|
1818
|
-
//
|
|
1819
|
-
//
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
const
|
|
1823
|
-
|
|
1824
|
-
|
|
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
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
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
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
}
|
|
1857
|
-
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2224
|
-
// UpdateItem
|
|
2225
|
-
|
|
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
|
|
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
|
|
2250
|
-
|
|
2251
|
-
|
|
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
|
-
|
|
2254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
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
|
-
|
|
2284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
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
|
-
|
|
2308
|
-
|
|
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
|
-
//
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
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
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
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
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
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
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
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
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
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:
|
|
2443
|
-
|
|
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
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
//
|
|
2470
|
-
//
|
|
2471
|
-
//
|
|
2472
|
-
const
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
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 =
|
|
4668
|
+
if (opts.returnValues !== undefined) {
|
|
4669
|
+
deleteInput.ReturnValues = opts.returnValues === "allOld" ? "ALL_OLD" : "NONE";
|
|
2524
4670
|
}
|
|
2525
|
-
yield* client
|
|
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,
|
|
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 (
|
|
2721
|
-
|
|
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 (
|
|
2738
|
-
|
|
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,
|
|
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
|
-
|
|
3015
|
-
|
|
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
|
|
3274
|
-
const
|
|
3275
|
-
|
|
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:
|
|
5580
|
+
Key: toAttributeMap({
|
|
5581
|
+
[primary.pk.field]: pkValue,
|
|
5582
|
+
[primary.sk.field]: versionSk,
|
|
5583
|
+
}),
|
|
3281
5584
|
ConsistentRead: opts.consistentRead || undefined,
|
|
3282
|
-
})
|
|
3283
|
-
|
|
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(
|
|
3287
|
-
return yield* decodeAs(raw,
|
|
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
|
|
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
|
-
//
|
|
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
|
|
3321
|
-
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
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
|
|
3405
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
|
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`
|
|
3570
|
-
//
|
|
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
|
-
|
|
3592
|
-
|
|
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: {
|
|
6005
|
+
ExpressionAttributeNames: {
|
|
6006
|
+
"#pk": primary.pk.field,
|
|
6007
|
+
"#sk": primary.sk.field,
|
|
6008
|
+
...projected,
|
|
6009
|
+
},
|
|
3600
6010
|
ExpressionAttributeValues: { ":pk": toAttributeValue(pkValue) },
|
|
3601
|
-
ProjectionExpression:
|
|
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
|
-
//
|
|
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
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
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:
|
|
6030
|
+
Key: toAttributeMap({ [primary.pk.field]: owner.pk, [primary.sk.field]: owner.sk }),
|
|
6031
|
+
ConsistentRead: true,
|
|
3619
6032
|
});
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
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
|
-
|
|
6050
|
+
ConsistentRead: true,
|
|
6051
|
+
ExclusiveStartKey: deletedStart,
|
|
3640
6052
|
});
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
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,
|
|
3652
|
-
if (
|
|
3653
|
-
|
|
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
|
-
|
|
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:
|
|
4101
|
-
|
|
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.
|
|
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) =>
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
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
|