effect-dynamodb 1.3.2 → 1.4.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.map +1 -1
- package/dist/Aggregate.js +51 -23
- package/dist/Aggregate.js.map +1 -1
- package/dist/Entity.d.ts +1 -3
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +282 -282
- package/dist/Entity.js.map +1 -1
- package/dist/Marshaller.d.ts +6 -17
- package/dist/Marshaller.d.ts.map +1 -1
- package/dist/Marshaller.js +6 -102
- package/dist/Marshaller.js.map +1 -1
- package/dist/internal/EntitySchemas.d.ts +93 -16
- package/dist/internal/EntitySchemas.d.ts.map +1 -1
- package/dist/internal/EntitySchemas.js +396 -79
- package/dist/internal/EntitySchemas.js.map +1 -1
- package/dist/internal/TransactableOps.d.ts +5 -0
- package/dist/internal/TransactableOps.d.ts.map +1 -1
- package/dist/internal/TransactableOps.js +33 -27
- package/dist/internal/TransactableOps.js.map +1 -1
- package/package.json +1 -1
package/dist/Entity.js
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* carries derived schemas for 7 type extractors (Model, Record, Input, Update,
|
|
7
7
|
* Key, Item, Marshalled).
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import { Duration, Effect, Schema, Stream } from "effect";
|
|
10
10
|
import { DynamoClient } from "./DynamoClient.js";
|
|
11
|
-
import {
|
|
11
|
+
import { getIdentifierField, isConfiguredModel, isHidden, isRef, isRefField, } from "./DynamoModel.js";
|
|
12
12
|
import * as DynamoSchema from "./DynamoSchema.js";
|
|
13
13
|
import { CascadePartialFailure, ConditionalCheckFailed, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled, OptimisticLockError, RefNotFound, TransactionOverflow, UniqueConstraintViolation, ValidationError, } from "./Errors.js";
|
|
14
14
|
import { makeBoundDelete, makeBoundPut, makeBoundUpdate } from "./internal/BoundCrud.js";
|
|
@@ -17,7 +17,7 @@ import { compileExpr, createConditionOps, isExpr, parseShorthand, parseSimpleSho
|
|
|
17
17
|
import { compilePath, createPathBuilder } from "./internal/PathBuilder.js";
|
|
18
18
|
import * as KeyComposer from "./KeyComposer.js";
|
|
19
19
|
import { normalizeGsiConfig } from "./KeyComposer.js";
|
|
20
|
-
import {
|
|
20
|
+
import { fromAttributeMap, toAttributeMap, toAttributeValue } from "./Marshaller.js";
|
|
21
21
|
import * as Query from "./Query.js";
|
|
22
22
|
import { filterExpr, selectPaths } from "./Query.js";
|
|
23
23
|
export { EntityDeleteImpl, EntityDeleteTypeId, EntityGetImpl, EntityOpTypeId, EntityPutImpl, EntityUpdateImpl, EntityUpdateTypeId, emptyUpdateState, returnValuesMap, } from "./internal/EntityOps.js";
|
|
@@ -25,7 +25,75 @@ import { EntityDeleteImpl, EntityDeleteTypeId, EntityGetImpl, EntityOpTypeId, En
|
|
|
25
25
|
import * as Projection from "./Projection.js";
|
|
26
26
|
export { add, append, asItem, asModel, asNative, asRecord, cascade, consistentRead, deleteFromSet, expectedVersion, pathAdd, pathAppend, pathDelete, pathIfNotExists, pathPrepend, pathRemove, pathSet, pathSubtract, project, remove, returnValues, set, subtract, } from "./internal/EntityCombinators.js";
|
|
27
27
|
import { asModel, condition as conditionCombinator, expectedVersion, set, } from "./internal/EntityCombinators.js";
|
|
28
|
-
import { allCompositeAttributes, allKeyFieldNames, buildDerivedSchemas,
|
|
28
|
+
import { allCompositeAttributes, allKeyFieldNames, buildDerivedSchemas, buildFieldEncodings, getFields, primaryKeyComposites, resolveSystemFields, resolveUniqueFields, validateNoTransformOverride, } from "./internal/EntitySchemas.js";
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Sparse-aware unique sentinel composition
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
/**
|
|
33
|
+
* Compose a unique-constraint sentinel key, returning `undefined` when any
|
|
34
|
+
* composing field is missing (`undefined` or `null`) on the source record.
|
|
35
|
+
*
|
|
36
|
+
* Mirrors GSI sparse semantics (`tryComposeIndexKeys`): a sentinel is only
|
|
37
|
+
* written/deleted/checked when every composing field is present, so multiple
|
|
38
|
+
* records can coexist with the field unset and an entity write doesn't synthesize
|
|
39
|
+
* a literal `"undefined"` collision key.
|
|
40
|
+
*
|
|
41
|
+
* `lookup` overrides the field-value reader. The update path passes a lookup that
|
|
42
|
+
* falls back from the domain field name to the renamed DB column name, since
|
|
43
|
+
* `currentRaw` (read from DynamoDB) carries DB names rather than domain names.
|
|
44
|
+
*/
|
|
45
|
+
const composeUniqueSentinel = (schema, entityType, constraintName, constraintDef, source, lookup) => {
|
|
46
|
+
const fields = resolveUniqueFields(constraintDef);
|
|
47
|
+
const get = lookup ?? ((s, f) => s[f]);
|
|
48
|
+
const serialized = [];
|
|
49
|
+
const fieldsRecord = {};
|
|
50
|
+
for (const f of fields) {
|
|
51
|
+
const raw = get(source, f);
|
|
52
|
+
if (raw === undefined || raw === null)
|
|
53
|
+
return undefined;
|
|
54
|
+
const s = KeyComposer.serializeValue(raw);
|
|
55
|
+
serialized.push(s);
|
|
56
|
+
fieldsRecord[f] = s;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
key: DynamoSchema.composeUniqueKey(schema, entityType, constraintName, serialized),
|
|
60
|
+
fieldsRecord,
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Encode-or-decode-encode helper
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
/**
|
|
67
|
+
* Validate user input and produce wire-form output, accepting both Type and
|
|
68
|
+
* Encoded shapes. Strategy:
|
|
69
|
+
*
|
|
70
|
+
* 1. Try `Schema.encode(input)`. This is the canonical Type → Encoded
|
|
71
|
+
* conversion and works when the user passes Type values for transforms
|
|
72
|
+
* (e.g. a `DateTime.Utc` for `Schema.DateTimeUtcFromString`, a `number`
|
|
73
|
+
* for `Schema.NumberFromString`, a `Redacted<string>` for our
|
|
74
|
+
* RedactedFromValue substitute).
|
|
75
|
+
*
|
|
76
|
+
* 2. On encode failure, fall back to `Schema.decode → Schema.encode`. This
|
|
77
|
+
* lifts plain inputs to the Type shape first (`Schema.Class` decode is
|
|
78
|
+
* forgiving — it constructs a class instance from a plain object), then
|
|
79
|
+
* re-encodes back to wire form. Required for nested `Schema.Class`
|
|
80
|
+
* fields whose Type is a class instance but whose Encoded is a plain
|
|
81
|
+
* object — users typically construct the plain object directly.
|
|
82
|
+
*
|
|
83
|
+
* Returns the Encoded shape (a plain object whose values are wire
|
|
84
|
+
* primitives), ready for `toAttributeMap`.
|
|
85
|
+
*
|
|
86
|
+
* @internal
|
|
87
|
+
*/
|
|
88
|
+
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)),
|
|
89
|
+
// On fallback failure, surface the original encode error — its
|
|
90
|
+
// message is keyed on the caller's input shape, which is what the
|
|
91
|
+
// user expects to see.
|
|
92
|
+
Effect.catch(() => Effect.fail(new ValidationError({
|
|
93
|
+
entityType,
|
|
94
|
+
operation: `${operation}.decode`,
|
|
95
|
+
cause: primaryCause,
|
|
96
|
+
}))))));
|
|
29
97
|
// ---------------------------------------------------------------------------
|
|
30
98
|
// Transaction limit pre-check (100-item DynamoDB limit)
|
|
31
99
|
// ---------------------------------------------------------------------------
|
|
@@ -272,7 +340,15 @@ const makeImpl = (config) => {
|
|
|
272
340
|
}
|
|
273
341
|
// Resolve identifier field name from model annotation
|
|
274
342
|
const resolvedIdentifier = getIdentifierField(config.model)?.name;
|
|
275
|
-
|
|
343
|
+
// Validate: no model field combines a transform schema with a ConfiguredModel
|
|
344
|
+
// storage override. The two configuration paths are mutually exclusive.
|
|
345
|
+
validateNoTransformOverride(modelFields, configuredAttributes);
|
|
346
|
+
// Resolve effective field encodings (annotation + ConfiguredModel override).
|
|
347
|
+
// Used by buildDerivedSchemas to substitute self date schemas with bidirectional
|
|
348
|
+
// transforms, and (below) by system-field generation to produce the right
|
|
349
|
+
// wire primitive when the field is library-managed.
|
|
350
|
+
const fieldEncodings = buildFieldEncodings(modelFields, configuredAttributes);
|
|
351
|
+
const schemas = buildDerivedSchemas(modelFields, allIndexes, systemFields, resolvedRefs, immutableFields, resolvedIdentifier, config.timeSeries, fieldEncodings);
|
|
276
352
|
// schema and tableTag are injected via _configure() when the entity is registered
|
|
277
353
|
// on a Table and bound through DynamoClient.make(). They are captured by operation
|
|
278
354
|
// closures and resolved at runtime (inside Effects), not at definition time.
|
|
@@ -281,37 +357,10 @@ const makeImpl = (config) => {
|
|
|
281
357
|
let tableTag;
|
|
282
358
|
const entityType = config.entityType;
|
|
283
359
|
const entityVersion = 1;
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
const fieldEncodings = {};
|
|
289
|
-
for (const [fieldName, fieldSchema] of Object.entries(modelFields)) {
|
|
290
|
-
const encoding = getEncoding(fieldSchema);
|
|
291
|
-
if (encoding) {
|
|
292
|
-
fieldEncodings[fieldName] = encoding;
|
|
293
|
-
}
|
|
294
|
-
else {
|
|
295
|
-
// Auto-detect standard Effect date schemas (Pattern B: pure model)
|
|
296
|
-
const inferred = inferDefaultEncoding(fieldSchema);
|
|
297
|
-
if (inferred)
|
|
298
|
-
fieldEncodings[fieldName] = inferred;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
// Apply ConfiguredModel storage overrides (takes precedence over schema annotations)
|
|
302
|
-
for (const [fieldName, attrConfig] of Object.entries(configuredAttributes)) {
|
|
303
|
-
if (attrConfig.encoding) {
|
|
304
|
-
const existing = fieldEncodings[fieldName];
|
|
305
|
-
fieldEncodings[fieldName] = {
|
|
306
|
-
storage: attrConfig.encoding.storage,
|
|
307
|
-
domain: existing?.domain ?? attrConfig.encoding.domain,
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
// Note: System timestamp encodings are NOT added to fieldEncodings.
|
|
312
|
-
// generateTimestamp() handles serialization directly for timestamps.
|
|
313
|
-
// This avoids double-serialization in serializeDateFields.
|
|
314
|
-
const hasDateFields = Object.keys(fieldEncodings).length > 0;
|
|
360
|
+
// Note: `fieldEncodings` is resolved earlier (above buildDerivedSchemas) and
|
|
361
|
+
// used to substitute self date schemas in derived schemas. System timestamp
|
|
362
|
+
// encodings are NOT in `fieldEncodings`; `generateTimestamp` produces wire
|
|
363
|
+
// primitives directly for non-colliding system fields.
|
|
315
364
|
// ---------------------------------------------------------------------------
|
|
316
365
|
// Field renaming: domain name → DynamoDB attribute name (from ConfiguredModel)
|
|
317
366
|
// ---------------------------------------------------------------------------
|
|
@@ -351,32 +400,6 @@ const makeImpl = (config) => {
|
|
|
351
400
|
};
|
|
352
401
|
/** Resolve a domain field name to its DynamoDB attribute name. */
|
|
353
402
|
const resolveDbName = (domainName) => fieldRenames[domainName] ?? domainName;
|
|
354
|
-
/**
|
|
355
|
-
* Convert domain date values to storage primitives for DynamoEncoding-annotated fields.
|
|
356
|
-
* Called before toAttributeMap (put path).
|
|
357
|
-
*/
|
|
358
|
-
const serializeDateFields = (item) => {
|
|
359
|
-
if (!hasDateFields)
|
|
360
|
-
return;
|
|
361
|
-
for (const [field, encoding] of Object.entries(fieldEncodings)) {
|
|
362
|
-
if (field in item && item[field] != null) {
|
|
363
|
-
item[field] = serializeDateForDynamo(item[field], encoding);
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
};
|
|
367
|
-
/**
|
|
368
|
-
* Convert storage primitives back to domain values for DynamoEncoding-annotated fields.
|
|
369
|
-
* Called after fromAttributeMap (get path).
|
|
370
|
-
*/
|
|
371
|
-
const deserializeDateFields = (raw) => {
|
|
372
|
-
if (!hasDateFields)
|
|
373
|
-
return;
|
|
374
|
-
for (const [field, encoding] of Object.entries(fieldEncodings)) {
|
|
375
|
-
if (field in raw && raw[field] != null) {
|
|
376
|
-
raw[field] = deserializeDateFromDynamo(raw[field], encoding);
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
};
|
|
380
403
|
// ---------------------------------------------------------------------------
|
|
381
404
|
// Helpers
|
|
382
405
|
// ---------------------------------------------------------------------------
|
|
@@ -392,7 +415,6 @@ const makeImpl = (config) => {
|
|
|
392
415
|
const attachPrototype = (decoded) => isSchemaClass ? Object.assign(Object.create(rawModel.prototype), decoded) : decoded;
|
|
393
416
|
const decodeRecord = (raw) => {
|
|
394
417
|
renameFromDynamo(raw);
|
|
395
|
-
deserializeDateFields(raw);
|
|
396
418
|
return Schema.decodeUnknownEffect(schemas.recordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
|
|
397
419
|
entityType,
|
|
398
420
|
operation: "decode",
|
|
@@ -403,15 +425,11 @@ const makeImpl = (config) => {
|
|
|
403
425
|
const decodeAs = (raw, marshalled, mode) => {
|
|
404
426
|
if (mode === "native")
|
|
405
427
|
return Effect.succeed(marshalled);
|
|
406
|
-
// Rename DynamoDB attributes back to domain field names
|
|
428
|
+
// Rename DynamoDB attributes back to domain field names. Substituted
|
|
429
|
+
// schemas handle wire→domain conversion (date primitives, Redacted, etc.).
|
|
407
430
|
renameFromDynamo(raw);
|
|
408
|
-
// Convert date storage primitives to domain values before Schema decode.
|
|
409
|
-
// recordSchema/itemSchema use fromSelf variants for annotated fields,
|
|
410
|
-
// so they accept domain values directly. modelSchema also uses fromSelf
|
|
411
|
-
// for the decode path after deserialization.
|
|
412
|
-
deserializeDateFields(raw);
|
|
413
431
|
const targetSchema = mode === "model"
|
|
414
|
-
? schemas.
|
|
432
|
+
? schemas.modelSchema
|
|
415
433
|
: mode === "record"
|
|
416
434
|
? schemas.recordSchema
|
|
417
435
|
: schemas.itemSchema;
|
|
@@ -427,33 +445,26 @@ const makeImpl = (config) => {
|
|
|
427
445
|
};
|
|
428
446
|
const nowIso = () => new Date().toISOString();
|
|
429
447
|
/**
|
|
430
|
-
* Generate a timestamp value
|
|
448
|
+
* Generate a timestamp value as a wire primitive ready for marshalling.
|
|
431
449
|
* Default (no encoding): ISO string. Custom encoding: serialized primitive.
|
|
432
450
|
*
|
|
433
|
-
* Used for
|
|
434
|
-
*
|
|
435
|
-
* `serializeDateFields` (because the field is absent from `fieldEncodings`).
|
|
451
|
+
* Used for system fields whether colliding or not — the value is always a
|
|
452
|
+
* wire primitive (string or number) ready for `toAttributeMap`.
|
|
436
453
|
*/
|
|
437
454
|
const generateTimestamp = (encoding) => {
|
|
438
455
|
if (!encoding)
|
|
439
456
|
return nowIso();
|
|
440
|
-
const now =
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
case "DateTime.Utc":
|
|
452
|
-
return DateTime.makeUnsafe(new Date());
|
|
453
|
-
case "DateTime.Zoned":
|
|
454
|
-
return DateTime.makeZonedUnsafe(new Date(), { timeZone: "UTC" });
|
|
455
|
-
case "Date":
|
|
456
|
-
return new Date();
|
|
457
|
+
const now = Date.now();
|
|
458
|
+
switch (encoding.storage) {
|
|
459
|
+
case "string":
|
|
460
|
+
// For DateTime.Zoned this would need the extended ISO format, but
|
|
461
|
+
// system timestamps generated here are UTC — DateTime.Zoned-typed
|
|
462
|
+
// collision fields are rare and not previously special-cased either.
|
|
463
|
+
return new Date(now).toISOString();
|
|
464
|
+
case "epochMs":
|
|
465
|
+
return now;
|
|
466
|
+
case "epochSeconds":
|
|
467
|
+
return Math.floor(now / 1000);
|
|
457
468
|
}
|
|
458
469
|
};
|
|
459
470
|
// ---------------------------------------------------------------------------
|
|
@@ -743,41 +754,41 @@ const makeImpl = (config) => {
|
|
|
743
754
|
const put = (input) => new EntityPutImpl((mode, opts) => Effect.gen(function* () {
|
|
744
755
|
const client = yield* DynamoClient;
|
|
745
756
|
const { name: tableName } = yield* tableTag;
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
//
|
|
753
|
-
|
|
757
|
+
// Encode user input → wire form. Users typically construct domain
|
|
758
|
+
// values for transforms (DateTime, Redacted, Number) and plain
|
|
759
|
+
// objects for Schema.Class fields. To handle both:
|
|
760
|
+
// 1. Try `encode` directly — the canonical Type → Encoded
|
|
761
|
+
// conversion. Works when the input is fully Type-shaped
|
|
762
|
+
// (which is the case for the transforms that issue #29
|
|
763
|
+
// surfaced: RedactedFromValue, NumberFromString, etc.).
|
|
764
|
+
// 2. On failure, fall back to `decode → encode` — `decode` is
|
|
765
|
+
// forgiving for Schema.Class (lifts plain objects to
|
|
766
|
+
// instances) and the substituted date/Redacted transforms
|
|
767
|
+
// tolerate either form. The subsequent `encode` produces
|
|
768
|
+
// the canonical wire shape.
|
|
769
|
+
const encodedInput = yield* encodeOrDecodeEncode(schemas.inputSchema, input, entityType, "put");
|
|
770
|
+
// Compose all keys from the encoded input (ID fields still present
|
|
771
|
+
// and key composites are wire-format strings/numbers).
|
|
772
|
+
const keys = composeAllKeys(encodedInput);
|
|
754
773
|
// Hydrate refs: replace ID fields with full entity domain data
|
|
755
|
-
const
|
|
756
|
-
? yield* hydrateRefs(
|
|
757
|
-
:
|
|
758
|
-
// Build the DynamoDB item
|
|
759
|
-
const item = { ...
|
|
774
|
+
const encoded = hasRefs
|
|
775
|
+
? yield* hydrateRefs(encodedInput)
|
|
776
|
+
: encodedInput;
|
|
777
|
+
// Build the DynamoDB item — already in wire-format after encode
|
|
778
|
+
const item = { ...encoded };
|
|
760
779
|
// Add system fields. When a timestamp field collides with a
|
|
761
780
|
// model-declared field, the user may have supplied their own value
|
|
762
|
-
// (
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
// through `serializeDateFields` downstream. Non-colliding fields
|
|
766
|
-
// get a storage primitive directly (no `serializeDateFields` pass).
|
|
781
|
+
// (optional on collision in `inputSchema`). Respect their value if
|
|
782
|
+
// present (already encoded to wire); otherwise generate a wire
|
|
783
|
+
// primitive directly.
|
|
767
784
|
if (systemFields.createdAt) {
|
|
768
785
|
if (item[systemFields.createdAt] === undefined) {
|
|
769
|
-
item[systemFields.createdAt] =
|
|
770
|
-
systemFields.createdAtCollision && systemFields.createdAtEncoding
|
|
771
|
-
? generateDomainTimestamp(systemFields.createdAtEncoding)
|
|
772
|
-
: generateTimestamp(systemFields.createdAtEncoding);
|
|
786
|
+
item[systemFields.createdAt] = generateTimestamp(systemFields.createdAtEncoding);
|
|
773
787
|
}
|
|
774
788
|
}
|
|
775
789
|
if (systemFields.updatedAt) {
|
|
776
790
|
if (item[systemFields.updatedAt] === undefined) {
|
|
777
|
-
item[systemFields.updatedAt] =
|
|
778
|
-
systemFields.updatedAtCollision && systemFields.updatedAtEncoding
|
|
779
|
-
? generateDomainTimestamp(systemFields.updatedAtEncoding)
|
|
780
|
-
: generateTimestamp(systemFields.updatedAtEncoding);
|
|
791
|
+
item[systemFields.updatedAt] = generateTimestamp(systemFields.updatedAtEncoding);
|
|
781
792
|
}
|
|
782
793
|
}
|
|
783
794
|
if (systemFields.version)
|
|
@@ -786,8 +797,6 @@ const makeImpl = (config) => {
|
|
|
786
797
|
item.__edd_e__ = entityType;
|
|
787
798
|
// Apply composed keys
|
|
788
799
|
Object.assign(item, keys);
|
|
789
|
-
// Convert date fields from domain values to storage primitives
|
|
790
|
-
serializeDateFields(item);
|
|
791
800
|
// Rename domain fields to DynamoDB attribute names
|
|
792
801
|
renameToDynamo(item);
|
|
793
802
|
const marshalledItem = toAttributeMap(item);
|
|
@@ -813,18 +822,20 @@ const makeImpl = (config) => {
|
|
|
813
822
|
}
|
|
814
823
|
}
|
|
815
824
|
transactItems.push({ Put: mainPut });
|
|
816
|
-
// Unique constraint sentinels
|
|
825
|
+
// Unique constraint sentinels (sparse — skip when any composing field is missing)
|
|
826
|
+
const sentinelConstraints = [];
|
|
817
827
|
if (hasUniqueConstraints) {
|
|
818
828
|
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
829
|
+
const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, encoded);
|
|
830
|
+
if (!sentinel)
|
|
831
|
+
continue;
|
|
832
|
+
sentinelConstraints.push(constraintName);
|
|
822
833
|
transactItems.push({
|
|
823
834
|
Put: {
|
|
824
835
|
TableName: tableName,
|
|
825
836
|
Item: toAttributeMap({
|
|
826
|
-
[config.indexes.primary.pk.field]:
|
|
827
|
-
[config.indexes.primary.sk.field]:
|
|
837
|
+
[config.indexes.primary.pk.field]: sentinel.key.pk,
|
|
838
|
+
[config.indexes.primary.sk.field]: sentinel.key.sk,
|
|
828
839
|
__edd_e__: `${entityType}._unique.${constraintName}`,
|
|
829
840
|
_entity_pk: keys[config.indexes.primary.pk.field],
|
|
830
841
|
_entity_sk: keys[config.indexes.primary.sk.field],
|
|
@@ -858,21 +869,24 @@ const makeImpl = (config) => {
|
|
|
858
869
|
if (opts.condition && reasons[0]?.Code === "ConditionalCheckFailed") {
|
|
859
870
|
return new ConditionalCheckFailed({
|
|
860
871
|
entityType,
|
|
861
|
-
key:
|
|
872
|
+
key: encoded,
|
|
862
873
|
});
|
|
863
874
|
}
|
|
864
|
-
// Sentinel items start at index 1
|
|
875
|
+
// Sentinel items start at index 1, in the order recorded in
|
|
876
|
+
// `sentinelConstraints` (skipped sparse entries are absent)
|
|
865
877
|
for (let i = 1; i < reasons.length; i++) {
|
|
866
878
|
if (reasons[i]?.Code === "ConditionalCheckFailed") {
|
|
867
|
-
const
|
|
868
|
-
const
|
|
869
|
-
const constraintDef = config.unique[constraintName];
|
|
879
|
+
const constraintName = sentinelConstraints[i - 1] ?? "unknown";
|
|
880
|
+
const constraintDef = config.unique?.[constraintName];
|
|
870
881
|
const uniqueFields = constraintDef
|
|
871
882
|
? resolveUniqueFields(constraintDef)
|
|
872
883
|
: [];
|
|
873
884
|
const fieldsRecord = {};
|
|
874
885
|
for (const f of uniqueFields) {
|
|
875
|
-
|
|
886
|
+
const v = encoded[f];
|
|
887
|
+
if (v !== undefined && v !== null) {
|
|
888
|
+
fieldsRecord[f] = KeyComposer.serializeValue(v);
|
|
889
|
+
}
|
|
876
890
|
}
|
|
877
891
|
return new UniqueConstraintViolation({
|
|
878
892
|
entityType,
|
|
@@ -901,7 +915,10 @@ const makeImpl = (config) => {
|
|
|
901
915
|
}
|
|
902
916
|
yield* client.putItem(putInput).pipe(Effect.mapError((err) => {
|
|
903
917
|
if (opts.condition && isAwsConditionalCheckFailed(err.cause)) {
|
|
904
|
-
return new ConditionalCheckFailed({
|
|
918
|
+
return new ConditionalCheckFailed({
|
|
919
|
+
entityType,
|
|
920
|
+
key: encoded,
|
|
921
|
+
});
|
|
905
922
|
}
|
|
906
923
|
return err;
|
|
907
924
|
}));
|
|
@@ -986,17 +1003,13 @@ const makeImpl = (config) => {
|
|
|
986
1003
|
operation: "update.decodeKey",
|
|
987
1004
|
cause,
|
|
988
1005
|
})));
|
|
989
|
-
//
|
|
990
|
-
const
|
|
991
|
-
entityType,
|
|
992
|
-
operation: "update.decodeUpdates",
|
|
993
|
-
cause,
|
|
994
|
-
})));
|
|
1006
|
+
// Encode update payload → wire form (see `put` for the strategy).
|
|
1007
|
+
const encodedUpdates = yield* encodeOrDecodeEncode(schemas.updateSchema, updates ?? {}, entityType, "update");
|
|
995
1008
|
// Hydrate refs in updates: for any ${field}Id present, fetch and embed
|
|
996
|
-
let hydratedUpdates =
|
|
1009
|
+
let hydratedUpdates = encodedUpdates;
|
|
997
1010
|
if (hasRefs) {
|
|
998
|
-
const refsToHydrate = resolvedRefs.filter((ref) => ref.idFieldName in
|
|
999
|
-
|
|
1011
|
+
const refsToHydrate = resolvedRefs.filter((ref) => ref.idFieldName in encodedUpdates &&
|
|
1012
|
+
encodedUpdates[ref.idFieldName] !==
|
|
1000
1013
|
undefined);
|
|
1001
1014
|
if (refsToHydrate.length > 0) {
|
|
1002
1015
|
hydratedUpdates = yield* hydrateRefs(hydratedUpdates, refsToHydrate);
|
|
@@ -1044,24 +1057,17 @@ const makeImpl = (config) => {
|
|
|
1044
1057
|
});
|
|
1045
1058
|
}
|
|
1046
1059
|
// Build new item: merge current + updates + rich ops + recompose keys.
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1051
|
-
//
|
|
1052
|
-
|
|
1053
|
-
// `toAttributeMap(newItem)` marshals the DateTime class instances
|
|
1054
|
-
// as DynamoDB Maps (via AWS SDK's `convertClassInstanceToMap`),
|
|
1055
|
-
// which corrupts the write and makes subsequent reads fail to
|
|
1056
|
-
// decode.
|
|
1057
|
-
const serializedRetainUpdates = {
|
|
1058
|
-
...hydratedUpdates,
|
|
1059
|
-
};
|
|
1060
|
-
serializeDateFields(serializedRetainUpdates);
|
|
1061
|
-
const newItem = {
|
|
1060
|
+
// `currentRaw` uses DynamoDB attribute names (e.g. `dn`, `hd`);
|
|
1061
|
+
// `hydratedUpdates` uses domain names. To avoid the merge leaving
|
|
1062
|
+
// both names (which would later have the rename clobber the user's
|
|
1063
|
+
// value), translate the current item's keys to domain names first
|
|
1064
|
+
// so the spread + assign overlay cleanly.
|
|
1065
|
+
const currentDomainItem = {
|
|
1062
1066
|
...currentRaw,
|
|
1063
1067
|
};
|
|
1064
|
-
|
|
1068
|
+
renameFromDynamo(currentDomainItem);
|
|
1069
|
+
const newItem = { ...currentDomainItem };
|
|
1070
|
+
for (const [attr, val] of Object.entries(hydratedUpdates)) {
|
|
1065
1071
|
if (val !== undefined)
|
|
1066
1072
|
newItem[attr] = val;
|
|
1067
1073
|
}
|
|
@@ -1099,45 +1105,42 @@ const makeImpl = (config) => {
|
|
|
1099
1105
|
}
|
|
1100
1106
|
// Increment version and update timestamp. If the caller supplied a
|
|
1101
1107
|
// value for `updatedAt` (allowed when the field collides with a
|
|
1102
|
-
// model-declared field), respect it
|
|
1103
|
-
//
|
|
1104
|
-
// (ISO string / epoch number) — not a domain DateTime instance.
|
|
1108
|
+
// model-declared field), respect it (already wire-form via encode);
|
|
1109
|
+
// else generate a fresh wire primitive.
|
|
1105
1110
|
const newVersion = currentVersion + 1;
|
|
1106
1111
|
if (systemFields.version)
|
|
1107
1112
|
newItem[systemFields.version] = newVersion;
|
|
1108
1113
|
if (systemFields.updatedAt) {
|
|
1109
|
-
const userSupplied =
|
|
1114
|
+
const userSupplied = hydratedUpdates[systemFields.updatedAt];
|
|
1110
1115
|
newItem[systemFields.updatedAt] =
|
|
1111
1116
|
userSupplied !== undefined
|
|
1112
1117
|
? userSupplied
|
|
1113
1118
|
: generateTimestamp(systemFields.updatedAtEncoding);
|
|
1114
1119
|
}
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
//
|
|
1118
|
-
renameFromDynamo(newItem);
|
|
1119
|
-
// Recompose all keys with updated attributes
|
|
1120
|
+
// `newItem` is already in domain names (the merge built it from
|
|
1121
|
+
// `currentDomainItem` + domain-keyed updates) — no rename needed
|
|
1122
|
+
// here. Recompose all keys with the updated attributes.
|
|
1120
1123
|
const newKeys = composeAllKeys(newItem);
|
|
1121
1124
|
Object.assign(newItem, newKeys);
|
|
1122
1125
|
const sentinelRotations = [];
|
|
1123
1126
|
if (touchesUniqueFields) {
|
|
1124
1127
|
const currentRawRecord = currentRaw;
|
|
1125
1128
|
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
1126
|
-
const
|
|
1127
|
-
const
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
if (
|
|
1129
|
+
const oldSentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, currentRawRecord, (s, f) => s[f] ?? s[resolveDbName(f)]);
|
|
1130
|
+
const newSentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, newItem);
|
|
1131
|
+
if (!oldSentinel && !newSentinel)
|
|
1132
|
+
continue;
|
|
1133
|
+
if (oldSentinel &&
|
|
1134
|
+
newSentinel &&
|
|
1135
|
+
oldSentinel.key.pk === newSentinel.key.pk &&
|
|
1136
|
+
oldSentinel.key.sk === newSentinel.key.sk) {
|
|
1131
1137
|
continue;
|
|
1132
|
-
const newFieldsRecord = {};
|
|
1133
|
-
for (const f of fields) {
|
|
1134
|
-
newFieldsRecord[f] = KeyComposer.serializeValue(newItem[f]);
|
|
1135
1138
|
}
|
|
1136
1139
|
sentinelRotations.push({
|
|
1137
1140
|
constraintName,
|
|
1138
|
-
oldUniqueKey:
|
|
1139
|
-
newUniqueKey:
|
|
1140
|
-
newFieldsRecord,
|
|
1141
|
+
oldUniqueKey: oldSentinel?.key,
|
|
1142
|
+
newUniqueKey: newSentinel?.key,
|
|
1143
|
+
newFieldsRecord: newSentinel?.fieldsRecord,
|
|
1141
1144
|
});
|
|
1142
1145
|
}
|
|
1143
1146
|
}
|
|
@@ -1185,38 +1188,42 @@ const makeImpl = (config) => {
|
|
|
1185
1188
|
},
|
|
1186
1189
|
});
|
|
1187
1190
|
}
|
|
1188
|
-
// Apply sentinel rotations computed earlier
|
|
1191
|
+
// Apply sentinel rotations computed earlier. Delete and Put are emitted
|
|
1192
|
+
// independently — a sparse field that becomes set emits Put only; a sparse
|
|
1193
|
+
// field that becomes unset emits Delete only.
|
|
1189
1194
|
const sentinelPutIndices = [];
|
|
1190
1195
|
for (const rotation of sentinelRotations) {
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1196
|
+
if (rotation.oldUniqueKey) {
|
|
1197
|
+
transactItems.push({
|
|
1198
|
+
Delete: {
|
|
1199
|
+
TableName: tableName,
|
|
1200
|
+
Key: toAttributeMap({
|
|
1201
|
+
[config.indexes.primary.pk.field]: rotation.oldUniqueKey.pk,
|
|
1202
|
+
[config.indexes.primary.sk.field]: rotation.oldUniqueKey.sk,
|
|
1203
|
+
}),
|
|
1204
|
+
},
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
if (rotation.newUniqueKey && rotation.newFieldsRecord) {
|
|
1208
|
+
sentinelPutIndices.push({
|
|
1209
|
+
index: transactItems.length,
|
|
1210
|
+
constraintName: rotation.constraintName,
|
|
1211
|
+
newFieldsRecord: rotation.newFieldsRecord,
|
|
1212
|
+
});
|
|
1213
|
+
transactItems.push({
|
|
1214
|
+
Put: {
|
|
1215
|
+
TableName: tableName,
|
|
1216
|
+
Item: toAttributeMap({
|
|
1217
|
+
[config.indexes.primary.pk.field]: rotation.newUniqueKey.pk,
|
|
1218
|
+
[config.indexes.primary.sk.field]: rotation.newUniqueKey.sk,
|
|
1219
|
+
__edd_e__: `${entityType}._unique.${rotation.constraintName}`,
|
|
1220
|
+
_entity_pk: primaryKey[config.indexes.primary.pk.field],
|
|
1221
|
+
_entity_sk: primaryKey[config.indexes.primary.sk.field],
|
|
1222
|
+
}),
|
|
1223
|
+
ConditionExpression: "attribute_not_exists(pk)",
|
|
1224
|
+
},
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1220
1227
|
}
|
|
1221
1228
|
yield* checkTransactionLimit(entityType, "update", transactItems);
|
|
1222
1229
|
yield* client
|
|
@@ -1277,18 +1284,17 @@ const makeImpl = (config) => {
|
|
|
1277
1284
|
const names = {};
|
|
1278
1285
|
const values = {};
|
|
1279
1286
|
let counter = 0;
|
|
1280
|
-
//
|
|
1281
|
-
//
|
|
1282
|
-
//
|
|
1283
|
-
const
|
|
1284
|
-
serializeDateFields(serializedUpdates);
|
|
1287
|
+
// `hydratedUpdates` was produced by `Schema.encode(updateSchema)` and
|
|
1288
|
+
// is already in wire-format (ISO string / epoch number / etc.). Use
|
|
1289
|
+
// it directly — no `serializeDateFields` pass required.
|
|
1290
|
+
const encodedUpdatesMap = hydratedUpdates;
|
|
1285
1291
|
// System-colliding updatedAt is handled by the system-field block below.
|
|
1286
|
-
// createdAt and version are already excluded by `
|
|
1292
|
+
// createdAt and version are already excluded by `updateSchema`.
|
|
1287
1293
|
const updateSystemColliders = new Set();
|
|
1288
1294
|
if (systemFields.updatedAtCollision && systemFields.updatedAt)
|
|
1289
1295
|
updateSystemColliders.add(systemFields.updatedAt);
|
|
1290
1296
|
// Add user-provided updates
|
|
1291
|
-
for (const [attr, val] of Object.entries(
|
|
1297
|
+
for (const [attr, val] of Object.entries(encodedUpdatesMap)) {
|
|
1292
1298
|
if (val === undefined)
|
|
1293
1299
|
continue;
|
|
1294
1300
|
if (updateSystemColliders.has(attr))
|
|
@@ -1300,10 +1306,11 @@ const makeImpl = (config) => {
|
|
|
1300
1306
|
setClauses.push(`${nameKey} = ${valKey}`);
|
|
1301
1307
|
counter++;
|
|
1302
1308
|
}
|
|
1303
|
-
// Add updatedAt timestamp. User-supplied value wins (
|
|
1304
|
-
//
|
|
1309
|
+
// Add updatedAt timestamp. User-supplied value wins (already in wire
|
|
1310
|
+
// form via encode); else fall back to a freshly generated wire
|
|
1311
|
+
// primitive.
|
|
1305
1312
|
if (systemFields.updatedAt) {
|
|
1306
|
-
const userSupplied =
|
|
1313
|
+
const userSupplied = encodedUpdatesMap[systemFields.updatedAt];
|
|
1307
1314
|
const nameKey = `#u${counter}`;
|
|
1308
1315
|
const valKey = `:u${counter}`;
|
|
1309
1316
|
names[nameKey] = systemFields.updatedAt;
|
|
@@ -1630,18 +1637,19 @@ const makeImpl = (config) => {
|
|
|
1630
1637
|
},
|
|
1631
1638
|
});
|
|
1632
1639
|
}
|
|
1633
|
-
// Delete sentinels if not preserving unique
|
|
1640
|
+
// Delete sentinels if not preserving unique. Sparse — fields that were
|
|
1641
|
+
// unset on the live item never had a sentinel, so nothing to delete.
|
|
1634
1642
|
if (hasUniqueConstraints && !preserveUnique()) {
|
|
1635
1643
|
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
1636
|
-
const
|
|
1637
|
-
|
|
1638
|
-
|
|
1644
|
+
const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, raw);
|
|
1645
|
+
if (!sentinel)
|
|
1646
|
+
continue;
|
|
1639
1647
|
transactItems.push({
|
|
1640
1648
|
Delete: {
|
|
1641
1649
|
TableName: tableName,
|
|
1642
1650
|
Key: toAttributeMap({
|
|
1643
|
-
[primary.pk.field]:
|
|
1644
|
-
[primary.sk.field]:
|
|
1651
|
+
[primary.pk.field]: sentinel.key.pk,
|
|
1652
|
+
[primary.sk.field]: sentinel.key.sk,
|
|
1645
1653
|
}),
|
|
1646
1654
|
},
|
|
1647
1655
|
});
|
|
@@ -1668,17 +1676,18 @@ const makeImpl = (config) => {
|
|
|
1668
1676
|
transactItems.push({
|
|
1669
1677
|
Delete: { TableName: tableName, Key: marshalledKey },
|
|
1670
1678
|
});
|
|
1671
|
-
// Delete sentinels
|
|
1679
|
+
// Delete sentinels (sparse — skip constraints whose fields were unset
|
|
1680
|
+
// on the live item; no sentinel was ever written for those)
|
|
1672
1681
|
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
1673
|
-
const
|
|
1674
|
-
|
|
1675
|
-
|
|
1682
|
+
const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, raw);
|
|
1683
|
+
if (!sentinel)
|
|
1684
|
+
continue;
|
|
1676
1685
|
transactItems.push({
|
|
1677
1686
|
Delete: {
|
|
1678
1687
|
TableName: tableName,
|
|
1679
1688
|
Key: toAttributeMap({
|
|
1680
|
-
[primary.pk.field]:
|
|
1681
|
-
[primary.sk.field]:
|
|
1689
|
+
[primary.pk.field]: sentinel.key.pk,
|
|
1690
|
+
[primary.sk.field]: sentinel.key.sk,
|
|
1682
1691
|
}),
|
|
1683
1692
|
},
|
|
1684
1693
|
});
|
|
@@ -1729,17 +1738,13 @@ const makeImpl = (config) => {
|
|
|
1729
1738
|
const upsert = (input) => new EntityPutImpl((mode, opts) => Effect.gen(function* () {
|
|
1730
1739
|
const client = yield* DynamoClient;
|
|
1731
1740
|
const { name: tableName } = yield* tableTag;
|
|
1732
|
-
//
|
|
1733
|
-
const
|
|
1734
|
-
entityType,
|
|
1735
|
-
operation: "upsert.decode",
|
|
1736
|
-
cause,
|
|
1737
|
-
})));
|
|
1741
|
+
// Encode user input → wire form (see `put` for strategy).
|
|
1742
|
+
const encodedInput = yield* encodeOrDecodeEncode(schemas.inputSchema, input, entityType, "upsert");
|
|
1738
1743
|
// Hydrate refs: replace ID fields with full entity domain data
|
|
1739
|
-
const
|
|
1740
|
-
? yield* hydrateRefs(
|
|
1741
|
-
:
|
|
1742
|
-
const item =
|
|
1744
|
+
const encoded = hasRefs
|
|
1745
|
+
? yield* hydrateRefs(encodedInput)
|
|
1746
|
+
: encodedInput;
|
|
1747
|
+
const item = encoded;
|
|
1743
1748
|
// Compose primary key
|
|
1744
1749
|
const primaryKey = composePrimaryKey(item);
|
|
1745
1750
|
const marshalledKey = toAttributeMap(primaryKey);
|
|
@@ -1759,10 +1764,7 @@ const makeImpl = (config) => {
|
|
|
1759
1764
|
systemColliders.add(systemFields.updatedAt);
|
|
1760
1765
|
if (systemFields.versionCollision && systemFields.version)
|
|
1761
1766
|
systemColliders.add(systemFields.version);
|
|
1762
|
-
//
|
|
1763
|
-
if (hasDateFields) {
|
|
1764
|
-
serializeDateFields(item);
|
|
1765
|
-
}
|
|
1767
|
+
// `item` is already in wire-form via `Schema.encode(inputSchema)`.
|
|
1766
1768
|
// All model fields (excluding PK composites, which are in the Key)
|
|
1767
1769
|
for (const [attr, val] of Object.entries(item)) {
|
|
1768
1770
|
if (pkComposites.has(attr))
|
|
@@ -1903,26 +1905,19 @@ const makeImpl = (config) => {
|
|
|
1903
1905
|
const orderByField = timeSeriesConfig.orderBy;
|
|
1904
1906
|
const ttlDuration = timeSeriesConfig.ttl;
|
|
1905
1907
|
const appendInputSchema = schemas.appendInputSchema;
|
|
1906
|
-
//
|
|
1907
|
-
const
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
cause,
|
|
1911
|
-
})));
|
|
1912
|
-
const decoded = decodedInput;
|
|
1913
|
-
// Compose current-item primary key (pk + sk derived from PK/SK composites in decoded)
|
|
1908
|
+
// Encode user input via appendInputSchema (see `put` for strategy).
|
|
1909
|
+
const encodedInput = yield* encodeOrDecodeEncode(appendInputSchema, input, entityType, "append");
|
|
1910
|
+
const encoded = encodedInput;
|
|
1911
|
+
// Compose current-item primary key (pk + sk derived from PK/SK composites)
|
|
1914
1912
|
const primary = config.indexes.primary;
|
|
1915
|
-
const pkValue = KeyComposer.composePk(schema, entityType, primary,
|
|
1916
|
-
const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary,
|
|
1913
|
+
const pkValue = KeyComposer.composePk(schema, entityType, primary, encoded);
|
|
1914
|
+
const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary, encoded);
|
|
1917
1915
|
const marshalledKey = toAttributeMap({
|
|
1918
1916
|
[primary.pk.field]: pkValue,
|
|
1919
1917
|
[primary.sk.field]: currentSk,
|
|
1920
1918
|
});
|
|
1921
|
-
//
|
|
1922
|
-
|
|
1923
|
-
// before marshalling. This mirrors the put path.
|
|
1924
|
-
const serialisedInput = { ...decoded };
|
|
1925
|
-
serializeDateFields(serialisedInput);
|
|
1919
|
+
// `encoded` is already in wire-form via Schema.encode — used directly.
|
|
1920
|
+
const serialisedInput = { ...encoded };
|
|
1926
1921
|
// ---------- Build UpdateItem (scoped SET + CAS) ----------
|
|
1927
1922
|
const setClauses = [];
|
|
1928
1923
|
const names = {};
|
|
@@ -1951,7 +1946,7 @@ const makeImpl = (config) => {
|
|
|
1951
1946
|
// never change during an append. Non-PK composites in appendInput that
|
|
1952
1947
|
// overlap a GSI's composites trigger per-GSI resolution via indexPolicy.
|
|
1953
1948
|
const nonPkAppendFields = {};
|
|
1954
|
-
for (const [attr, val] of Object.entries(
|
|
1949
|
+
for (const [attr, val] of Object.entries(encoded)) {
|
|
1955
1950
|
if (!pkCompositeSet.has(attr)) {
|
|
1956
1951
|
nonPkAppendFields[attr] = val;
|
|
1957
1952
|
}
|
|
@@ -1986,7 +1981,7 @@ const makeImpl = (config) => {
|
|
|
1986
1981
|
},
|
|
1987
1982
|
};
|
|
1988
1983
|
}
|
|
1989
|
-
const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, policyAwareIndexes, nonPkAppendFields,
|
|
1984
|
+
const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, policyAwareIndexes, nonPkAppendFields, encoded);
|
|
1990
1985
|
for (const [field, value] of Object.entries(gsiUpdate.sets)) {
|
|
1991
1986
|
const nameKey = `#a${counter}`;
|
|
1992
1987
|
const valKey = `:a${counter}`;
|
|
@@ -2046,9 +2041,9 @@ const makeImpl = (config) => {
|
|
|
2046
2041
|
const updateExpression = exprParts.join(" ");
|
|
2047
2042
|
// ---------- Build Put of event item ----------
|
|
2048
2043
|
const eventSk = KeyComposer.composeEventSk(currentSk, newObValue, schema.casing);
|
|
2049
|
-
|
|
2050
|
-
//
|
|
2051
|
-
|
|
2044
|
+
// `encoded` is already in wire-form via Schema.encode; no extra
|
|
2045
|
+
// serialisation step is required for the Put.
|
|
2046
|
+
const eventItem = { ...encoded };
|
|
2052
2047
|
// Rename domain → DB names for the Put (matches put path)
|
|
2053
2048
|
renameToDynamo(eventItem);
|
|
2054
2049
|
// pk + sk (event)
|
|
@@ -2142,7 +2137,6 @@ const makeImpl = (config) => {
|
|
|
2142
2137
|
const prefix = KeyComposer.composeEventSkPrefix(currentSk, schema.casing);
|
|
2143
2138
|
const decodeHistory = (raw) => {
|
|
2144
2139
|
renameFromDynamo(raw);
|
|
2145
|
-
deserializeDateFields(raw);
|
|
2146
2140
|
return Schema.decodeUnknownEffect(schemas.historyRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
|
|
2147
2141
|
entityType,
|
|
2148
2142
|
operation: "history.decode",
|
|
@@ -2393,18 +2387,21 @@ const makeImpl = (config) => {
|
|
|
2393
2387
|
},
|
|
2394
2388
|
});
|
|
2395
2389
|
}
|
|
2396
|
-
// Re-establish unique constraint sentinels
|
|
2390
|
+
// Re-establish unique constraint sentinels (sparse — fields that are
|
|
2391
|
+
// unset on the restored item don't get a sentinel; matches put semantics)
|
|
2392
|
+
const sentinelConstraints = [];
|
|
2397
2393
|
if (config.unique && Object.keys(config.unique).length > 0) {
|
|
2398
2394
|
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
2399
|
-
const
|
|
2400
|
-
|
|
2401
|
-
|
|
2395
|
+
const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, restoredItem);
|
|
2396
|
+
if (!sentinel)
|
|
2397
|
+
continue;
|
|
2398
|
+
sentinelConstraints.push(constraintName);
|
|
2402
2399
|
transactItems.push({
|
|
2403
2400
|
Put: {
|
|
2404
2401
|
TableName: tableName,
|
|
2405
2402
|
Item: toAttributeMap({
|
|
2406
|
-
[primary.pk.field]:
|
|
2407
|
-
[primary.sk.field]:
|
|
2403
|
+
[primary.pk.field]: sentinel.key.pk,
|
|
2404
|
+
[primary.sk.field]: sentinel.key.sk,
|
|
2408
2405
|
__edd_e__: `${entityType}._unique.${constraintName}`,
|
|
2409
2406
|
_entity_pk: restoredKeys[primary.pk.field],
|
|
2410
2407
|
_entity_sk: restoredKeys[primary.sk.field],
|
|
@@ -2424,17 +2421,20 @@ const makeImpl = (config) => {
|
|
|
2424
2421
|
if (isAwsTransactionCancelled(err.cause)) {
|
|
2425
2422
|
const reasons = err.cause.CancellationReasons;
|
|
2426
2423
|
if (reasons && config.unique) {
|
|
2427
|
-
// Sentinel
|
|
2424
|
+
// Sentinel Puts come after Delete + Put + optional snapshot, in the
|
|
2425
|
+
// order recorded in `sentinelConstraints` (sparse-skipped entries absent)
|
|
2428
2426
|
const sentinelStart = isRetainEnabled() ? 3 : 2;
|
|
2429
|
-
const constraintNames = Object.keys(config.unique);
|
|
2430
2427
|
for (let i = sentinelStart; i < reasons.length; i++) {
|
|
2431
2428
|
if (reasons[i]?.Code === "ConditionalCheckFailed") {
|
|
2432
|
-
const constraintName =
|
|
2429
|
+
const constraintName = sentinelConstraints[i - sentinelStart] ?? "unknown";
|
|
2433
2430
|
const constraintDef = config.unique[constraintName];
|
|
2434
2431
|
const uniqueFields = constraintDef ? resolveUniqueFields(constraintDef) : [];
|
|
2435
2432
|
const fieldsRecord = {};
|
|
2436
2433
|
for (const f of uniqueFields) {
|
|
2437
|
-
|
|
2434
|
+
const v = restoredItem[f];
|
|
2435
|
+
if (v !== undefined && v !== null) {
|
|
2436
|
+
fieldsRecord[f] = KeyComposer.serializeValue(v);
|
|
2437
|
+
}
|
|
2438
2438
|
}
|
|
2439
2439
|
return new UniqueConstraintViolation({
|
|
2440
2440
|
entityType,
|
|
@@ -2518,14 +2518,15 @@ const makeImpl = (config) => {
|
|
|
2518
2518
|
}
|
|
2519
2519
|
}
|
|
2520
2520
|
if (entityItem) {
|
|
2521
|
-
// Add sentinel keys to delete list
|
|
2521
|
+
// Add sentinel keys to delete list (sparse — fields that were unset
|
|
2522
|
+
// never had a sentinel written, so nothing to enqueue)
|
|
2522
2523
|
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
2523
|
-
const
|
|
2524
|
-
|
|
2525
|
-
|
|
2524
|
+
const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, entityItem);
|
|
2525
|
+
if (!sentinel)
|
|
2526
|
+
continue;
|
|
2526
2527
|
allItems.push(toAttributeMap({
|
|
2527
|
-
[primary.pk.field]:
|
|
2528
|
-
[primary.sk.field]:
|
|
2528
|
+
[primary.pk.field]: sentinel.key.pk,
|
|
2529
|
+
[primary.sk.field]: sentinel.key.sk,
|
|
2529
2530
|
}));
|
|
2530
2531
|
}
|
|
2531
2532
|
}
|
|
@@ -2606,9 +2607,8 @@ const makeImpl = (config) => {
|
|
|
2606
2607
|
identifier: resolvedIdentifier,
|
|
2607
2608
|
timeSeries: config.timeSeries,
|
|
2608
2609
|
_resolvedRefs: resolvedRefs,
|
|
2609
|
-
/** @internal Full decode pipeline: rename +
|
|
2610
|
+
/** @internal Full decode pipeline: rename + schema decode. Used by Batch/Aggregate. */
|
|
2610
2611
|
_decodeRecord: decodeRecord,
|
|
2611
|
-
_serializeDateFields: serializeDateFields,
|
|
2612
2612
|
_attachPrototype: attachPrototype,
|
|
2613
2613
|
_configure: (injectedSchema, injectedTableTag) => {
|
|
2614
2614
|
schema = injectedSchema;
|