effect-dynamodb 1.3.3 → 1.5.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/DynamoModel.d.ts +50 -2
- package/dist/DynamoModel.d.ts.map +1 -1
- package/dist/DynamoModel.js +73 -1
- package/dist/DynamoModel.js.map +1 -1
- package/dist/Entity.d.ts +48 -21
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +586 -223
- package/dist/Entity.js.map +1 -1
- package/dist/Errors.d.ts +48 -1
- package/dist/Errors.d.ts.map +1 -1
- package/dist/Errors.js +38 -1
- package/dist/Errors.js.map +1 -1
- package/dist/Marshaller.d.ts +29 -12
- package/dist/Marshaller.d.ts.map +1 -1
- package/dist/Marshaller.js +104 -84
- package/dist/Marshaller.js.map +1 -1
- package/dist/index.d.ts +2 -2
- 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 +68 -0
- package/dist/internal/BoundCrud.d.ts.map +1 -1
- package/dist/internal/BoundCrud.js +42 -1
- package/dist/internal/BoundCrud.js.map +1 -1
- package/dist/internal/EntityCombinators.d.ts +28 -0
- package/dist/internal/EntityCombinators.d.ts.map +1 -1
- package/dist/internal/EntityCombinators.js +40 -0
- package/dist/internal/EntityCombinators.js.map +1 -1
- package/dist/internal/EntityOps.d.ts +18 -0
- package/dist/internal/EntityOps.d.ts.map +1 -1
- package/dist/internal/EntityOps.js +2 -0
- package/dist/internal/EntityOps.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/EntityTypes.d.ts +7 -12
- package/dist/internal/EntityTypes.d.ts.map +1 -1
- package/dist/internal/PathBuilder.d.ts +29 -4
- package/dist/internal/PathBuilder.d.ts.map +1 -1
- package/dist/internal/PathBuilder.js +47 -6
- package/dist/internal/PathBuilder.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 +45 -27
- package/dist/internal/TransactableOps.js.map +1 -1
- package/package.json +1 -1
package/dist/Entity.js
CHANGED
|
@@ -6,26 +6,26 @@
|
|
|
6
6
|
* carries derived schemas for 7 type extractors (Model, Record, Input, Update,
|
|
7
7
|
* Key, Item, Marshalled).
|
|
8
8
|
*/
|
|
9
|
-
import { DateTime, Duration, Effect, Schema, Stream } from "effect";
|
|
9
|
+
import { DateTime, Duration, Effect, Option, Schema, Stream } from "effect";
|
|
10
10
|
import { DynamoClient } from "./DynamoClient.js";
|
|
11
|
-
import {
|
|
11
|
+
import { getIdentifierField, getSparseFields, isConfiguredModel, isHidden, isRecordAst, isRecordSchema, isRef, isRefField, } from "./DynamoModel.js";
|
|
12
12
|
import * as DynamoSchema from "./DynamoSchema.js";
|
|
13
|
-
import { CascadePartialFailure, ConditionalCheckFailed, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled, OptimisticLockError, RefNotFound, TransactionOverflow, UniqueConstraintViolation, ValidationError, } from "./Errors.js";
|
|
14
|
-
import { makeBoundDelete, makeBoundPut, makeBoundUpdate } from "./internal/BoundCrud.js";
|
|
13
|
+
import { CascadePartialFailure, ConditionalCheckFailed, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled, OptimisticLockError, RefNotFound, StaleAppend, TransactionOverflow, UniqueConstraintViolation, ValidationError, } from "./Errors.js";
|
|
14
|
+
import { makeBoundAppend, makeBoundDelete, makeBoundPut, makeBoundUpdate, } from "./internal/BoundCrud.js";
|
|
15
15
|
import { BoundQueryImpl } from "./internal/BoundQuery.js";
|
|
16
16
|
import { compileExpr, createConditionOps, isExpr, parseShorthand, parseSimpleShorthand, } from "./internal/Expr.js";
|
|
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 { decodeSparseFields, encodeSparseFields, 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";
|
|
24
24
|
import { EntityDeleteImpl, EntityDeleteTypeId, EntityGetImpl, EntityOpTypeId, EntityPutImpl, EntityUpdateImpl, emptyUpdateState, returnValuesMap, } from "./internal/EntityOps.js";
|
|
25
25
|
import * as Projection from "./Projection.js";
|
|
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";
|
|
26
|
+
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";
|
|
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
29
|
// ---------------------------------------------------------------------------
|
|
30
30
|
// Sparse-aware unique sentinel composition
|
|
31
31
|
// ---------------------------------------------------------------------------
|
|
@@ -61,6 +61,40 @@ const composeUniqueSentinel = (schema, entityType, constraintName, constraintDef
|
|
|
61
61
|
};
|
|
62
62
|
};
|
|
63
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
|
+
}))))));
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
64
98
|
// Transaction limit pre-check (100-item DynamoDB limit)
|
|
65
99
|
// ---------------------------------------------------------------------------
|
|
66
100
|
/**
|
|
@@ -306,7 +340,15 @@ const makeImpl = (config) => {
|
|
|
306
340
|
}
|
|
307
341
|
// Resolve identifier field name from model annotation
|
|
308
342
|
const resolvedIdentifier = getIdentifierField(config.model)?.name;
|
|
309
|
-
|
|
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);
|
|
310
352
|
// schema and tableTag are injected via _configure() when the entity is registered
|
|
311
353
|
// on a Table and bound through DynamoClient.make(). They are captured by operation
|
|
312
354
|
// closures and resolved at runtime (inside Effects), not at definition time.
|
|
@@ -315,37 +357,68 @@ const makeImpl = (config) => {
|
|
|
315
357
|
let tableTag;
|
|
316
358
|
const entityType = config.entityType;
|
|
317
359
|
const entityVersion = 1;
|
|
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.
|
|
318
364
|
// ---------------------------------------------------------------------------
|
|
319
|
-
//
|
|
320
|
-
// Merges schema annotations with ConfiguredModel storage overrides.
|
|
365
|
+
// Sparse Map fields (storedAs: 'sparse') — validation + resolution
|
|
321
366
|
// ---------------------------------------------------------------------------
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
367
|
+
const sparseFields = getSparseFields(config.model);
|
|
368
|
+
const hasSparseFields = Object.keys(sparseFields).length > 0;
|
|
369
|
+
if (hasSparseFields) {
|
|
370
|
+
// EDD-9020: storedAs: 'sparse' is only valid on Schema.Record fields.
|
|
371
|
+
for (const fieldName of Object.keys(sparseFields)) {
|
|
372
|
+
const fieldSchema = modelFields[fieldName];
|
|
373
|
+
if (!fieldSchema) {
|
|
374
|
+
throw new Error(`[EDD-9020] Entity "${config.entityType}": sparse field "${fieldName}" does not exist on the model`);
|
|
375
|
+
}
|
|
376
|
+
const recordInfo = isRecordSchema(fieldSchema);
|
|
377
|
+
if (!recordInfo) {
|
|
378
|
+
throw new Error(`[EDD-9020] Entity "${config.entityType}": sparse field "${fieldName}" must be a Schema.Record. ` +
|
|
379
|
+
`Got a non-Record schema. Sparse storage flattens Record entries into per-key top-level attributes.`);
|
|
380
|
+
}
|
|
381
|
+
// EDD-9021: inner value must not be another Record (no nested sparse).
|
|
382
|
+
if (isRecordAst(recordInfo.valueAst)) {
|
|
383
|
+
throw new Error(`[EDD-9021] Entity "${config.entityType}": sparse field "${fieldName}" has a Record-typed value schema. ` +
|
|
384
|
+
`Nested sparse Records are not supported — use Schema.Struct, Schema.Number, etc. for the inner value.`);
|
|
385
|
+
}
|
|
327
386
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
387
|
+
// EDD-9022: sparse fields cannot participate in primary key, GSI composites,
|
|
388
|
+
// or unique constraints — keys aren't statically known at make() time.
|
|
389
|
+
const allComposites = new Set(allCompositeAttributes(allIndexes));
|
|
390
|
+
for (const fieldName of Object.keys(sparseFields)) {
|
|
391
|
+
if (allComposites.has(fieldName)) {
|
|
392
|
+
throw new Error(`[EDD-9022] Entity "${config.entityType}": sparse field "${fieldName}" cannot be a primary-key or GSI composite. ` +
|
|
393
|
+
`Composite values must be known at make() time; sparse-map keys are not.`);
|
|
394
|
+
}
|
|
333
395
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
396
|
+
if (config.unique) {
|
|
397
|
+
const sparseSet = new Set(Object.keys(sparseFields));
|
|
398
|
+
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
399
|
+
const fields = resolveUniqueFields(constraintDef);
|
|
400
|
+
for (const f of fields) {
|
|
401
|
+
if (sparseSet.has(f)) {
|
|
402
|
+
throw new Error(`[EDD-9022] Entity "${config.entityType}": unique constraint "${constraintName}" cannot reference sparse field "${f}".`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// EDD-9023: sparse prefixes must be unique among themselves and must not
|
|
408
|
+
// collide with non-sparse top-level model field names.
|
|
409
|
+
const seenPrefix = new Map(); // prefix → fieldName
|
|
410
|
+
for (const [fieldName, sparse] of Object.entries(sparseFields)) {
|
|
411
|
+
const existing = seenPrefix.get(sparse.prefix);
|
|
412
|
+
if (existing) {
|
|
413
|
+
throw new Error(`[EDD-9023] Entity "${config.entityType}": sparse fields "${existing}" and "${fieldName}" share prefix "${sparse.prefix}". Prefixes must be distinct.`);
|
|
414
|
+
}
|
|
415
|
+
seenPrefix.set(sparse.prefix, fieldName);
|
|
416
|
+
// Prefix must not collide with another (non-sparse) model field name.
|
|
417
|
+
if (sparse.prefix !== fieldName && sparse.prefix in modelFields) {
|
|
418
|
+
throw new Error(`[EDD-9023] Entity "${config.entityType}": sparse field "${fieldName}" prefix "${sparse.prefix}" collides with non-sparse field "${sparse.prefix}".`);
|
|
419
|
+
}
|
|
343
420
|
}
|
|
344
421
|
}
|
|
345
|
-
// Note: System timestamp encodings are NOT added to fieldEncodings.
|
|
346
|
-
// generateTimestamp() handles serialization directly for timestamps.
|
|
347
|
-
// This avoids double-serialization in serializeDateFields.
|
|
348
|
-
const hasDateFields = Object.keys(fieldEncodings).length > 0;
|
|
349
422
|
// ---------------------------------------------------------------------------
|
|
350
423
|
// Field renaming: domain name → DynamoDB attribute name (from ConfiguredModel)
|
|
351
424
|
// ---------------------------------------------------------------------------
|
|
@@ -386,30 +459,27 @@ const makeImpl = (config) => {
|
|
|
386
459
|
/** Resolve a domain field name to its DynamoDB attribute name. */
|
|
387
460
|
const resolveDbName = (domainName) => fieldRenames[domainName] ?? domainName;
|
|
388
461
|
/**
|
|
389
|
-
*
|
|
390
|
-
* Called
|
|
462
|
+
* Flatten sparse Map fields into per-entry top-level attributes.
|
|
463
|
+
* Called on the write path after rename + date serialization, before
|
|
464
|
+
* `toAttributeMap`. No-op when the entity has no sparse fields.
|
|
465
|
+
*
|
|
466
|
+
* Throws on key validation errors (caught by callers and translated into
|
|
467
|
+
* tagged `ValidationError` at the entity boundary).
|
|
391
468
|
*/
|
|
392
|
-
const
|
|
393
|
-
if (!
|
|
469
|
+
const serializeSparseFields = (item) => {
|
|
470
|
+
if (!hasSparseFields)
|
|
394
471
|
return;
|
|
395
|
-
|
|
396
|
-
if (field in item && item[field] != null) {
|
|
397
|
-
item[field] = serializeDateForDynamo(item[field], encoding);
|
|
398
|
-
}
|
|
399
|
-
}
|
|
472
|
+
encodeSparseFields(item, sparseFields);
|
|
400
473
|
};
|
|
401
474
|
/**
|
|
402
|
-
*
|
|
403
|
-
* Called after fromAttributeMap
|
|
475
|
+
* Rebuild sparse Map fields from flattened top-level attributes.
|
|
476
|
+
* Called on the read path after `fromAttributeMap`, before rename + date
|
|
477
|
+
* deserialization. No-op when the entity has no sparse fields.
|
|
404
478
|
*/
|
|
405
|
-
const
|
|
406
|
-
if (!
|
|
479
|
+
const deserializeSparseFields = (raw) => {
|
|
480
|
+
if (!hasSparseFields)
|
|
407
481
|
return;
|
|
408
|
-
|
|
409
|
-
if (field in raw && raw[field] != null) {
|
|
410
|
-
raw[field] = deserializeDateFromDynamo(raw[field], encoding);
|
|
411
|
-
}
|
|
412
|
-
}
|
|
482
|
+
decodeSparseFields(raw, sparseFields);
|
|
413
483
|
};
|
|
414
484
|
// ---------------------------------------------------------------------------
|
|
415
485
|
// Helpers
|
|
@@ -425,8 +495,8 @@ const makeImpl = (config) => {
|
|
|
425
495
|
/** Attach the model class prototype to a decoded plain object (when model is Schema.Class). */
|
|
426
496
|
const attachPrototype = (decoded) => isSchemaClass ? Object.assign(Object.create(rawModel.prototype), decoded) : decoded;
|
|
427
497
|
const decodeRecord = (raw) => {
|
|
498
|
+
deserializeSparseFields(raw);
|
|
428
499
|
renameFromDynamo(raw);
|
|
429
|
-
deserializeDateFields(raw);
|
|
430
500
|
return Schema.decodeUnknownEffect(schemas.recordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
|
|
431
501
|
entityType,
|
|
432
502
|
operation: "decode",
|
|
@@ -437,15 +507,15 @@ const makeImpl = (config) => {
|
|
|
437
507
|
const decodeAs = (raw, marshalled, mode) => {
|
|
438
508
|
if (mode === "native")
|
|
439
509
|
return Effect.succeed(marshalled);
|
|
440
|
-
//
|
|
510
|
+
// Collect flattened sparse-map attributes back into the domain Record.
|
|
511
|
+
// Must happen before rename so the rebuilt Record sits at the domain field
|
|
512
|
+
// name (sparse field renaming is not supported).
|
|
513
|
+
deserializeSparseFields(raw);
|
|
514
|
+
// Rename DynamoDB attributes back to domain field names. Substituted
|
|
515
|
+
// schemas handle wire→domain conversion (date primitives, Redacted, etc.).
|
|
441
516
|
renameFromDynamo(raw);
|
|
442
|
-
// Convert date storage primitives to domain values before Schema decode.
|
|
443
|
-
// recordSchema/itemSchema use fromSelf variants for annotated fields,
|
|
444
|
-
// so they accept domain values directly. modelSchema also uses fromSelf
|
|
445
|
-
// for the decode path after deserialization.
|
|
446
|
-
deserializeDateFields(raw);
|
|
447
517
|
const targetSchema = mode === "model"
|
|
448
|
-
? schemas.
|
|
518
|
+
? schemas.modelSchema
|
|
449
519
|
: mode === "record"
|
|
450
520
|
? schemas.recordSchema
|
|
451
521
|
: schemas.itemSchema;
|
|
@@ -461,33 +531,26 @@ const makeImpl = (config) => {
|
|
|
461
531
|
};
|
|
462
532
|
const nowIso = () => new Date().toISOString();
|
|
463
533
|
/**
|
|
464
|
-
* Generate a timestamp value
|
|
534
|
+
* Generate a timestamp value as a wire primitive ready for marshalling.
|
|
465
535
|
* Default (no encoding): ISO string. Custom encoding: serialized primitive.
|
|
466
536
|
*
|
|
467
|
-
* Used for
|
|
468
|
-
*
|
|
469
|
-
* `serializeDateFields` (because the field is absent from `fieldEncodings`).
|
|
537
|
+
* Used for system fields whether colliding or not — the value is always a
|
|
538
|
+
* wire primitive (string or number) ready for `toAttributeMap`.
|
|
470
539
|
*/
|
|
471
540
|
const generateTimestamp = (encoding) => {
|
|
472
541
|
if (!encoding)
|
|
473
542
|
return nowIso();
|
|
474
|
-
const now =
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
case "DateTime.Utc":
|
|
486
|
-
return DateTime.makeUnsafe(new Date());
|
|
487
|
-
case "DateTime.Zoned":
|
|
488
|
-
return DateTime.makeZonedUnsafe(new Date(), { timeZone: "UTC" });
|
|
489
|
-
case "Date":
|
|
490
|
-
return new Date();
|
|
543
|
+
const now = Date.now();
|
|
544
|
+
switch (encoding.storage) {
|
|
545
|
+
case "string":
|
|
546
|
+
// For DateTime.Zoned this would need the extended ISO format, but
|
|
547
|
+
// system timestamps generated here are UTC — DateTime.Zoned-typed
|
|
548
|
+
// collision fields are rare and not previously special-cased either.
|
|
549
|
+
return new Date(now).toISOString();
|
|
550
|
+
case "epochMs":
|
|
551
|
+
return now;
|
|
552
|
+
case "epochSeconds":
|
|
553
|
+
return Math.floor(now / 1000);
|
|
491
554
|
}
|
|
492
555
|
};
|
|
493
556
|
// ---------------------------------------------------------------------------
|
|
@@ -777,41 +840,41 @@ const makeImpl = (config) => {
|
|
|
777
840
|
const put = (input) => new EntityPutImpl((mode, opts) => Effect.gen(function* () {
|
|
778
841
|
const client = yield* DynamoClient;
|
|
779
842
|
const { name: tableName } = yield* tableTag;
|
|
780
|
-
//
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
//
|
|
787
|
-
|
|
843
|
+
// Encode user input → wire form. Users typically construct domain
|
|
844
|
+
// values for transforms (DateTime, Redacted, Number) and plain
|
|
845
|
+
// objects for Schema.Class fields. To handle both:
|
|
846
|
+
// 1. Try `encode` directly — the canonical Type → Encoded
|
|
847
|
+
// conversion. Works when the input is fully Type-shaped
|
|
848
|
+
// (which is the case for the transforms that issue #29
|
|
849
|
+
// surfaced: RedactedFromValue, NumberFromString, etc.).
|
|
850
|
+
// 2. On failure, fall back to `decode → encode` — `decode` is
|
|
851
|
+
// forgiving for Schema.Class (lifts plain objects to
|
|
852
|
+
// instances) and the substituted date/Redacted transforms
|
|
853
|
+
// tolerate either form. The subsequent `encode` produces
|
|
854
|
+
// the canonical wire shape.
|
|
855
|
+
const encodedInput = yield* encodeOrDecodeEncode(schemas.inputSchema, input, entityType, "put");
|
|
856
|
+
// Compose all keys from the encoded input (ID fields still present
|
|
857
|
+
// and key composites are wire-format strings/numbers).
|
|
858
|
+
const keys = composeAllKeys(encodedInput);
|
|
788
859
|
// Hydrate refs: replace ID fields with full entity domain data
|
|
789
|
-
const
|
|
790
|
-
? yield* hydrateRefs(
|
|
791
|
-
:
|
|
792
|
-
// Build the DynamoDB item
|
|
793
|
-
const item = { ...
|
|
860
|
+
const encoded = hasRefs
|
|
861
|
+
? yield* hydrateRefs(encodedInput)
|
|
862
|
+
: encodedInput;
|
|
863
|
+
// Build the DynamoDB item — already in wire-format after encode
|
|
864
|
+
const item = { ...encoded };
|
|
794
865
|
// Add system fields. When a timestamp field collides with a
|
|
795
866
|
// model-declared field, the user may have supplied their own value
|
|
796
|
-
// (
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
// through `serializeDateFields` downstream. Non-colliding fields
|
|
800
|
-
// get a storage primitive directly (no `serializeDateFields` pass).
|
|
867
|
+
// (optional on collision in `inputSchema`). Respect their value if
|
|
868
|
+
// present (already encoded to wire); otherwise generate a wire
|
|
869
|
+
// primitive directly.
|
|
801
870
|
if (systemFields.createdAt) {
|
|
802
871
|
if (item[systemFields.createdAt] === undefined) {
|
|
803
|
-
item[systemFields.createdAt] =
|
|
804
|
-
systemFields.createdAtCollision && systemFields.createdAtEncoding
|
|
805
|
-
? generateDomainTimestamp(systemFields.createdAtEncoding)
|
|
806
|
-
: generateTimestamp(systemFields.createdAtEncoding);
|
|
872
|
+
item[systemFields.createdAt] = generateTimestamp(systemFields.createdAtEncoding);
|
|
807
873
|
}
|
|
808
874
|
}
|
|
809
875
|
if (systemFields.updatedAt) {
|
|
810
876
|
if (item[systemFields.updatedAt] === undefined) {
|
|
811
|
-
item[systemFields.updatedAt] =
|
|
812
|
-
systemFields.updatedAtCollision && systemFields.updatedAtEncoding
|
|
813
|
-
? generateDomainTimestamp(systemFields.updatedAtEncoding)
|
|
814
|
-
: generateTimestamp(systemFields.updatedAtEncoding);
|
|
877
|
+
item[systemFields.updatedAt] = generateTimestamp(systemFields.updatedAtEncoding);
|
|
815
878
|
}
|
|
816
879
|
}
|
|
817
880
|
if (systemFields.version)
|
|
@@ -820,10 +883,20 @@ const makeImpl = (config) => {
|
|
|
820
883
|
item.__edd_e__ = entityType;
|
|
821
884
|
// Apply composed keys
|
|
822
885
|
Object.assign(item, keys);
|
|
823
|
-
// Convert date fields from domain values to storage primitives
|
|
824
|
-
serializeDateFields(item);
|
|
825
886
|
// Rename domain fields to DynamoDB attribute names
|
|
826
887
|
renameToDynamo(item);
|
|
888
|
+
// Flatten sparse Map fields into per-entry top-level attributes.
|
|
889
|
+
// Wrapped to surface key-validation errors as ValidationError.
|
|
890
|
+
try {
|
|
891
|
+
serializeSparseFields(item);
|
|
892
|
+
}
|
|
893
|
+
catch (e) {
|
|
894
|
+
return yield* new ValidationError({
|
|
895
|
+
entityType,
|
|
896
|
+
operation: "put.sparse",
|
|
897
|
+
cause: e instanceof Error ? e.message : String(e),
|
|
898
|
+
});
|
|
899
|
+
}
|
|
827
900
|
const marshalledItem = toAttributeMap(item);
|
|
828
901
|
// Build user condition expression if provided
|
|
829
902
|
const userCondition = opts.condition
|
|
@@ -851,7 +924,7 @@ const makeImpl = (config) => {
|
|
|
851
924
|
const sentinelConstraints = [];
|
|
852
925
|
if (hasUniqueConstraints) {
|
|
853
926
|
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
854
|
-
const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef,
|
|
927
|
+
const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, encoded);
|
|
855
928
|
if (!sentinel)
|
|
856
929
|
continue;
|
|
857
930
|
sentinelConstraints.push(constraintName);
|
|
@@ -894,7 +967,7 @@ const makeImpl = (config) => {
|
|
|
894
967
|
if (opts.condition && reasons[0]?.Code === "ConditionalCheckFailed") {
|
|
895
968
|
return new ConditionalCheckFailed({
|
|
896
969
|
entityType,
|
|
897
|
-
key:
|
|
970
|
+
key: encoded,
|
|
898
971
|
});
|
|
899
972
|
}
|
|
900
973
|
// Sentinel items start at index 1, in the order recorded in
|
|
@@ -908,7 +981,7 @@ const makeImpl = (config) => {
|
|
|
908
981
|
: [];
|
|
909
982
|
const fieldsRecord = {};
|
|
910
983
|
for (const f of uniqueFields) {
|
|
911
|
-
const v =
|
|
984
|
+
const v = encoded[f];
|
|
912
985
|
if (v !== undefined && v !== null) {
|
|
913
986
|
fieldsRecord[f] = KeyComposer.serializeValue(v);
|
|
914
987
|
}
|
|
@@ -940,7 +1013,10 @@ const makeImpl = (config) => {
|
|
|
940
1013
|
}
|
|
941
1014
|
yield* client.putItem(putInput).pipe(Effect.mapError((err) => {
|
|
942
1015
|
if (opts.condition && isAwsConditionalCheckFailed(err.cause)) {
|
|
943
|
-
return new ConditionalCheckFailed({
|
|
1016
|
+
return new ConditionalCheckFailed({
|
|
1017
|
+
entityType,
|
|
1018
|
+
key: encoded,
|
|
1019
|
+
});
|
|
944
1020
|
}
|
|
945
1021
|
return err;
|
|
946
1022
|
}));
|
|
@@ -1025,17 +1101,13 @@ const makeImpl = (config) => {
|
|
|
1025
1101
|
operation: "update.decodeKey",
|
|
1026
1102
|
cause,
|
|
1027
1103
|
})));
|
|
1028
|
-
//
|
|
1029
|
-
const
|
|
1030
|
-
entityType,
|
|
1031
|
-
operation: "update.decodeUpdates",
|
|
1032
|
-
cause,
|
|
1033
|
-
})));
|
|
1104
|
+
// Encode update payload → wire form (see `put` for the strategy).
|
|
1105
|
+
const encodedUpdates = yield* encodeOrDecodeEncode(schemas.updateSchema, updates ?? {}, entityType, "update");
|
|
1034
1106
|
// Hydrate refs in updates: for any ${field}Id present, fetch and embed
|
|
1035
|
-
let hydratedUpdates =
|
|
1107
|
+
let hydratedUpdates = encodedUpdates;
|
|
1036
1108
|
if (hasRefs) {
|
|
1037
|
-
const refsToHydrate = resolvedRefs.filter((ref) => ref.idFieldName in
|
|
1038
|
-
|
|
1109
|
+
const refsToHydrate = resolvedRefs.filter((ref) => ref.idFieldName in encodedUpdates &&
|
|
1110
|
+
encodedUpdates[ref.idFieldName] !==
|
|
1039
1111
|
undefined);
|
|
1040
1112
|
if (refsToHydrate.length > 0) {
|
|
1041
1113
|
hydratedUpdates = yield* hydrateRefs(hydratedUpdates, refsToHydrate);
|
|
@@ -1083,26 +1155,37 @@ const makeImpl = (config) => {
|
|
|
1083
1155
|
});
|
|
1084
1156
|
}
|
|
1085
1157
|
// Build new item: merge current + updates + rich ops + recompose keys.
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
|
|
1092
|
-
// `toAttributeMap(newItem)` marshals the DateTime class instances
|
|
1093
|
-
// as DynamoDB Maps (via AWS SDK's `convertClassInstanceToMap`),
|
|
1094
|
-
// which corrupts the write and makes subsequent reads fail to
|
|
1095
|
-
// decode.
|
|
1096
|
-
const serializedRetainUpdates = {
|
|
1097
|
-
...hydratedUpdates,
|
|
1098
|
-
};
|
|
1099
|
-
serializeDateFields(serializedRetainUpdates);
|
|
1100
|
-
const newItem = {
|
|
1158
|
+
// `currentRaw` uses DynamoDB attribute names (e.g. `dn`, `hd`);
|
|
1159
|
+
// `hydratedUpdates` uses domain names. To avoid the merge leaving
|
|
1160
|
+
// both names (which would later have the rename clobber the user's
|
|
1161
|
+
// value), translate the current item's keys to domain names first
|
|
1162
|
+
// so the spread + assign overlay cleanly.
|
|
1163
|
+
const currentDomainItem = {
|
|
1101
1164
|
...currentRaw,
|
|
1102
1165
|
};
|
|
1103
|
-
|
|
1104
|
-
|
|
1166
|
+
renameFromDynamo(currentDomainItem);
|
|
1167
|
+
// Rebuild sparse Map fields from flattened attrs into domain Records.
|
|
1168
|
+
// Done in-place on the renamed domain item so subsequent merge logic
|
|
1169
|
+
// operates on domain shape and re-flattening happens once at the end.
|
|
1170
|
+
deserializeSparseFields(currentDomainItem);
|
|
1171
|
+
const newItem = { ...currentDomainItem };
|
|
1172
|
+
// Apply user updates. For sparse fields, merge bucket-by-bucket
|
|
1173
|
+
// rather than whole-field replace — concurrent writers to disjoint
|
|
1174
|
+
// buckets must coexist (the version CAS protects against same-bucket
|
|
1175
|
+
// races). Non-sparse fields use the existing replace semantics.
|
|
1176
|
+
for (const [attr, val] of Object.entries(hydratedUpdates)) {
|
|
1177
|
+
if (val === undefined)
|
|
1178
|
+
continue;
|
|
1179
|
+
if (hasSparseFields &&
|
|
1180
|
+
attr in sparseFields &&
|
|
1181
|
+
val !== null &&
|
|
1182
|
+
typeof val === "object") {
|
|
1183
|
+
const existing = newItem[attr] ?? {};
|
|
1184
|
+
newItem[attr] = { ...existing, ...val };
|
|
1185
|
+
}
|
|
1186
|
+
else {
|
|
1105
1187
|
newItem[attr] = val;
|
|
1188
|
+
}
|
|
1106
1189
|
}
|
|
1107
1190
|
// Apply rich operations to in-memory item
|
|
1108
1191
|
if (uState.remove) {
|
|
@@ -1110,6 +1193,29 @@ const makeImpl = (config) => {
|
|
|
1110
1193
|
delete newItem[attr];
|
|
1111
1194
|
}
|
|
1112
1195
|
}
|
|
1196
|
+
// Sparse-map removes: drop named entries from the in-memory Record.
|
|
1197
|
+
if (uState.sparseRemoveEntries) {
|
|
1198
|
+
for (const op of uState.sparseRemoveEntries) {
|
|
1199
|
+
if (!hasSparseFields || !(op.field in sparseFields))
|
|
1200
|
+
continue;
|
|
1201
|
+
const bucket = newItem[op.field];
|
|
1202
|
+
if (!bucket)
|
|
1203
|
+
continue;
|
|
1204
|
+
for (const k of op.keys)
|
|
1205
|
+
delete bucket[k];
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
// Sparse-map clear: blow away the entire bucket Record. The retain
|
|
1209
|
+
// path always reads-then-writes the full item, so the version CAS
|
|
1210
|
+
// already gives clearMap atomic semantics for free — no per-bucket
|
|
1211
|
+
// GET dance needed here.
|
|
1212
|
+
if (uState.sparseClearFields) {
|
|
1213
|
+
for (const field of uState.sparseClearFields) {
|
|
1214
|
+
if (!hasSparseFields || !(field in sparseFields))
|
|
1215
|
+
continue;
|
|
1216
|
+
newItem[field] = {};
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1113
1219
|
if (uState.add) {
|
|
1114
1220
|
for (const [attr, val] of Object.entries(uState.add)) {
|
|
1115
1221
|
newItem[attr] = (newItem[attr] ?? 0) + val;
|
|
@@ -1138,24 +1244,21 @@ const makeImpl = (config) => {
|
|
|
1138
1244
|
}
|
|
1139
1245
|
// Increment version and update timestamp. If the caller supplied a
|
|
1140
1246
|
// value for `updatedAt` (allowed when the field collides with a
|
|
1141
|
-
// model-declared field), respect it
|
|
1142
|
-
//
|
|
1143
|
-
// (ISO string / epoch number) — not a domain DateTime instance.
|
|
1247
|
+
// model-declared field), respect it (already wire-form via encode);
|
|
1248
|
+
// else generate a fresh wire primitive.
|
|
1144
1249
|
const newVersion = currentVersion + 1;
|
|
1145
1250
|
if (systemFields.version)
|
|
1146
1251
|
newItem[systemFields.version] = newVersion;
|
|
1147
1252
|
if (systemFields.updatedAt) {
|
|
1148
|
-
const userSupplied =
|
|
1253
|
+
const userSupplied = hydratedUpdates[systemFields.updatedAt];
|
|
1149
1254
|
newItem[systemFields.updatedAt] =
|
|
1150
1255
|
userSupplied !== undefined
|
|
1151
1256
|
? userSupplied
|
|
1152
1257
|
: generateTimestamp(systemFields.updatedAtEncoding);
|
|
1153
1258
|
}
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
//
|
|
1157
|
-
renameFromDynamo(newItem);
|
|
1158
|
-
// Recompose all keys with updated attributes
|
|
1259
|
+
// `newItem` is already in domain names (the merge built it from
|
|
1260
|
+
// `currentDomainItem` + domain-keyed updates) — no rename needed
|
|
1261
|
+
// here. Recompose all keys with the updated attributes.
|
|
1159
1262
|
const newKeys = composeAllKeys(newItem);
|
|
1160
1263
|
Object.assign(newItem, newKeys);
|
|
1161
1264
|
const sentinelRotations = [];
|
|
@@ -1182,6 +1285,17 @@ const makeImpl = (config) => {
|
|
|
1182
1285
|
}
|
|
1183
1286
|
// Convert back to DynamoDB attribute names for storage
|
|
1184
1287
|
renameToDynamo(newItem);
|
|
1288
|
+
// Flatten sparse Map fields into per-entry top-level attributes.
|
|
1289
|
+
try {
|
|
1290
|
+
serializeSparseFields(newItem);
|
|
1291
|
+
}
|
|
1292
|
+
catch (e) {
|
|
1293
|
+
return yield* new ValidationError({
|
|
1294
|
+
entityType,
|
|
1295
|
+
operation: "update.sparse",
|
|
1296
|
+
cause: e instanceof Error ? e.message : String(e),
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1185
1299
|
const marshalledNewItem = toAttributeMap(newItem);
|
|
1186
1300
|
// Build snapshot of the pre-update state (only when retain is enabled)
|
|
1187
1301
|
const snapshotItem = isRetainEnabled()
|
|
@@ -1320,22 +1434,46 @@ const makeImpl = (config) => {
|
|
|
1320
1434
|
const names = {};
|
|
1321
1435
|
const values = {};
|
|
1322
1436
|
let counter = 0;
|
|
1323
|
-
//
|
|
1324
|
-
//
|
|
1325
|
-
//
|
|
1326
|
-
const
|
|
1327
|
-
serializeDateFields(serializedUpdates);
|
|
1437
|
+
// `hydratedUpdates` was produced by `Schema.encode(updateSchema)` and
|
|
1438
|
+
// is already in wire-format (ISO string / epoch number / etc.). Use
|
|
1439
|
+
// it directly — no `serializeDateFields` pass required.
|
|
1440
|
+
const encodedUpdatesMap = hydratedUpdates;
|
|
1328
1441
|
// System-colliding updatedAt is handled by the system-field block below.
|
|
1329
|
-
// createdAt and version are already excluded by `
|
|
1442
|
+
// createdAt and version are already excluded by `updateSchema`.
|
|
1330
1443
|
const updateSystemColliders = new Set();
|
|
1331
1444
|
if (systemFields.updatedAtCollision && systemFields.updatedAt)
|
|
1332
1445
|
updateSystemColliders.add(systemFields.updatedAt);
|
|
1333
1446
|
// Add user-provided updates
|
|
1334
|
-
for (const [attr, val] of Object.entries(
|
|
1447
|
+
for (const [attr, val] of Object.entries(encodedUpdatesMap)) {
|
|
1335
1448
|
if (val === undefined)
|
|
1336
1449
|
continue;
|
|
1337
1450
|
if (updateSystemColliders.has(attr))
|
|
1338
1451
|
continue;
|
|
1452
|
+
// Sparse fields: emit one SET per bucket. Whole-bucket replace
|
|
1453
|
+
// semantics — concurrent writers to disjoint buckets are safe.
|
|
1454
|
+
// `null` is NOT REMOVE — that's footgunny; explicit removal goes
|
|
1455
|
+
// through `.removeEntries(field, keys)`.
|
|
1456
|
+
if (hasSparseFields && attr in sparseFields) {
|
|
1457
|
+
if (val === null || typeof val !== "object")
|
|
1458
|
+
continue;
|
|
1459
|
+
const sparse = sparseFields[attr];
|
|
1460
|
+
for (const [k, v] of Object.entries(val)) {
|
|
1461
|
+
if (typeof k !== "string" || k.length === 0 || k.includes("#")) {
|
|
1462
|
+
return yield* new ValidationError({
|
|
1463
|
+
entityType,
|
|
1464
|
+
operation: "update.sparse",
|
|
1465
|
+
cause: `Sparse map "${attr}": invalid key ${JSON.stringify(k)}`,
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
const nameKey = `#u${counter}`;
|
|
1469
|
+
const valKey = `:u${counter}`;
|
|
1470
|
+
names[nameKey] = `${sparse.prefix}#${k}`;
|
|
1471
|
+
values[valKey] = toAttributeValue(v);
|
|
1472
|
+
setClauses.push(`${nameKey} = ${valKey}`);
|
|
1473
|
+
counter++;
|
|
1474
|
+
}
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1339
1477
|
const nameKey = `#u${counter}`;
|
|
1340
1478
|
const valKey = `:u${counter}`;
|
|
1341
1479
|
names[nameKey] = resolveDbName(attr);
|
|
@@ -1343,10 +1481,11 @@ const makeImpl = (config) => {
|
|
|
1343
1481
|
setClauses.push(`${nameKey} = ${valKey}`);
|
|
1344
1482
|
counter++;
|
|
1345
1483
|
}
|
|
1346
|
-
// Add updatedAt timestamp. User-supplied value wins (
|
|
1347
|
-
//
|
|
1484
|
+
// Add updatedAt timestamp. User-supplied value wins (already in wire
|
|
1485
|
+
// form via encode); else fall back to a freshly generated wire
|
|
1486
|
+
// primitive.
|
|
1348
1487
|
if (systemFields.updatedAt) {
|
|
1349
|
-
const userSupplied =
|
|
1488
|
+
const userSupplied = encodedUpdatesMap[systemFields.updatedAt];
|
|
1350
1489
|
const nameKey = `#u${counter}`;
|
|
1351
1490
|
const valKey = `:u${counter}`;
|
|
1352
1491
|
names[nameKey] = systemFields.updatedAt;
|
|
@@ -1521,6 +1660,73 @@ const makeImpl = (config) => {
|
|
|
1521
1660
|
deleteClauses.push(`${pathExpr} ${valKey}`);
|
|
1522
1661
|
}
|
|
1523
1662
|
}
|
|
1663
|
+
// Sparse-map .removeEntries — explicit REMOVE on `<prefix>#<key>`.
|
|
1664
|
+
// Each key emits one REMOVE clause through ExpressionAttributeNames
|
|
1665
|
+
// (the literal "#" survives via compilePath's raw-segment escape).
|
|
1666
|
+
if (uState.sparseRemoveEntries) {
|
|
1667
|
+
for (const op of uState.sparseRemoveEntries) {
|
|
1668
|
+
if (!hasSparseFields || !(op.field in sparseFields)) {
|
|
1669
|
+
return yield* new ValidationError({
|
|
1670
|
+
entityType,
|
|
1671
|
+
operation: "update.removeEntries",
|
|
1672
|
+
cause: `field "${op.field}" is not configured as a sparse map`,
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
const sparse = sparseFields[op.field];
|
|
1676
|
+
for (const k of op.keys) {
|
|
1677
|
+
if (typeof k !== "string" || k.length === 0 || k.includes("#")) {
|
|
1678
|
+
return yield* new ValidationError({
|
|
1679
|
+
entityType,
|
|
1680
|
+
operation: "update.removeEntries",
|
|
1681
|
+
cause: `Sparse map "${op.field}": invalid key ${JSON.stringify(k)}`,
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
const nameKey = `#sr${removeClauses.length}`;
|
|
1685
|
+
names[nameKey] = `${sparse.prefix}#${k}`;
|
|
1686
|
+
removeClauses.push(nameKey);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
// Sparse-map .clearMap — Get-then-Update helper. Reads the current
|
|
1691
|
+
// item with a consistent read to discover which `<prefix>#*` attrs
|
|
1692
|
+
// exist, then folds the resulting REMOVEs into this same UpdateItem.
|
|
1693
|
+
// The version CAS (when configured) provides atomicity; non-versioned
|
|
1694
|
+
// entities are best-effort (a concurrent writer can add a new bucket
|
|
1695
|
+
// between the read and the update — that bucket survives).
|
|
1696
|
+
if (uState.sparseClearFields && uState.sparseClearFields.length > 0) {
|
|
1697
|
+
for (const field of uState.sparseClearFields) {
|
|
1698
|
+
if (!hasSparseFields || !(field in sparseFields)) {
|
|
1699
|
+
return yield* new ValidationError({
|
|
1700
|
+
entityType,
|
|
1701
|
+
operation: "update.clearMap",
|
|
1702
|
+
cause: `field "${field}" is not configured as a sparse map`,
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
// Read once for all clearMap fields combined.
|
|
1707
|
+
const clearGetResult = yield* client.getItem({
|
|
1708
|
+
TableName: tableName,
|
|
1709
|
+
Key: marshalledKey,
|
|
1710
|
+
ConsistentRead: true,
|
|
1711
|
+
});
|
|
1712
|
+
if (clearGetResult.Item) {
|
|
1713
|
+
const clearItemKeys = Object.keys(clearGetResult.Item);
|
|
1714
|
+
for (const field of uState.sparseClearFields) {
|
|
1715
|
+
const sparse = sparseFields[field];
|
|
1716
|
+
const prefixWithDelim = `${sparse.prefix}#`;
|
|
1717
|
+
for (const attrName of clearItemKeys) {
|
|
1718
|
+
if (attrName.startsWith(prefixWithDelim)) {
|
|
1719
|
+
const nameKey = `#sc${removeClauses.length}`;
|
|
1720
|
+
names[nameKey] = attrName;
|
|
1721
|
+
removeClauses.push(nameKey);
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
// If the item doesn't exist, clearMap is a no-op — the subsequent
|
|
1727
|
+
// UpdateItem will create the row (with whatever other clauses the
|
|
1728
|
+
// builder accumulated) per DynamoDB UpdateItem semantics.
|
|
1729
|
+
}
|
|
1524
1730
|
const hasAnyUpdate = setClauses.length > 0 ||
|
|
1525
1731
|
removeClauses.length > 0 ||
|
|
1526
1732
|
addClauses.length > 0 ||
|
|
@@ -1554,13 +1760,16 @@ const makeImpl = (config) => {
|
|
|
1554
1760
|
Object.assign(values, uc.values);
|
|
1555
1761
|
}
|
|
1556
1762
|
const conditionExpression = condParts.length > 0 ? condParts.join(" AND ") : undefined;
|
|
1763
|
+
// DynamoDB rejects an empty `ExpressionAttributeValues` map. When
|
|
1764
|
+
// the UpdateExpression is REMOVE-only (e.g. clearMap with no other
|
|
1765
|
+
// combinators), `values` may be empty — omit the property entirely.
|
|
1557
1766
|
const result = yield* client
|
|
1558
1767
|
.updateItem({
|
|
1559
1768
|
TableName: tableName,
|
|
1560
1769
|
Key: marshalledKey,
|
|
1561
1770
|
UpdateExpression: updateExpression,
|
|
1562
1771
|
ExpressionAttributeNames: names,
|
|
1563
|
-
ExpressionAttributeValues: values,
|
|
1772
|
+
ExpressionAttributeValues: Object.keys(values).length > 0 ? values : undefined,
|
|
1564
1773
|
ConditionExpression: conditionExpression,
|
|
1565
1774
|
ReturnValues: returnValuesMap[uState.returnValues ?? "allNew"],
|
|
1566
1775
|
})
|
|
@@ -1774,17 +1983,13 @@ const makeImpl = (config) => {
|
|
|
1774
1983
|
const upsert = (input) => new EntityPutImpl((mode, opts) => Effect.gen(function* () {
|
|
1775
1984
|
const client = yield* DynamoClient;
|
|
1776
1985
|
const { name: tableName } = yield* tableTag;
|
|
1777
|
-
//
|
|
1778
|
-
const
|
|
1779
|
-
entityType,
|
|
1780
|
-
operation: "upsert.decode",
|
|
1781
|
-
cause,
|
|
1782
|
-
})));
|
|
1986
|
+
// Encode user input → wire form (see `put` for strategy).
|
|
1987
|
+
const encodedInput = yield* encodeOrDecodeEncode(schemas.inputSchema, input, entityType, "upsert");
|
|
1783
1988
|
// Hydrate refs: replace ID fields with full entity domain data
|
|
1784
|
-
const
|
|
1785
|
-
? yield* hydrateRefs(
|
|
1786
|
-
:
|
|
1787
|
-
const item =
|
|
1989
|
+
const encoded = hasRefs
|
|
1990
|
+
? yield* hydrateRefs(encodedInput)
|
|
1991
|
+
: encodedInput;
|
|
1992
|
+
const item = encoded;
|
|
1788
1993
|
// Compose primary key
|
|
1789
1994
|
const primaryKey = composePrimaryKey(item);
|
|
1790
1995
|
const marshalledKey = toAttributeMap(primaryKey);
|
|
@@ -1804,10 +2009,7 @@ const makeImpl = (config) => {
|
|
|
1804
2009
|
systemColliders.add(systemFields.updatedAt);
|
|
1805
2010
|
if (systemFields.versionCollision && systemFields.version)
|
|
1806
2011
|
systemColliders.add(systemFields.version);
|
|
1807
|
-
//
|
|
1808
|
-
if (hasDateFields) {
|
|
1809
|
-
serializeDateFields(item);
|
|
1810
|
-
}
|
|
2012
|
+
// `item` is already in wire-form via `Schema.encode(inputSchema)`.
|
|
1811
2013
|
// All model fields (excluding PK composites, which are in the Key)
|
|
1812
2014
|
for (const [attr, val] of Object.entries(item)) {
|
|
1813
2015
|
if (pkComposites.has(attr))
|
|
@@ -1816,6 +2018,35 @@ const makeImpl = (config) => {
|
|
|
1816
2018
|
continue;
|
|
1817
2019
|
if (val === undefined)
|
|
1818
2020
|
continue;
|
|
2021
|
+
// Sparse fields: expand into one SET per bucket. Each bucket
|
|
2022
|
+
// attribute is named `<prefix>#<key>` and goes through
|
|
2023
|
+
// ExpressionAttributeNames as a literal (the `#` survives).
|
|
2024
|
+
if (hasSparseFields && attr in sparseFields) {
|
|
2025
|
+
if (val === null || typeof val !== "object")
|
|
2026
|
+
continue;
|
|
2027
|
+
const sparse = sparseFields[attr];
|
|
2028
|
+
for (const [k, v] of Object.entries(val)) {
|
|
2029
|
+
try {
|
|
2030
|
+
if (typeof k !== "string" || k.length === 0 || k.includes("#")) {
|
|
2031
|
+
throw new Error(`Sparse map "${attr}": invalid key ${JSON.stringify(k)}`);
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
catch (e) {
|
|
2035
|
+
return yield* new ValidationError({
|
|
2036
|
+
entityType,
|
|
2037
|
+
operation: "upsert.sparse",
|
|
2038
|
+
cause: e instanceof Error ? e.message : String(e),
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
const nameKey = `#u${counter}`;
|
|
2042
|
+
const valKey = `:u${counter}`;
|
|
2043
|
+
names[nameKey] = `${sparse.prefix}#${k}`;
|
|
2044
|
+
values[valKey] = toAttributeValue(v);
|
|
2045
|
+
setClauses.push(`${nameKey} = ${valKey}`);
|
|
2046
|
+
counter++;
|
|
2047
|
+
}
|
|
2048
|
+
continue;
|
|
2049
|
+
}
|
|
1819
2050
|
const nameKey = `#u${counter}`;
|
|
1820
2051
|
const valKey = `:u${counter}`;
|
|
1821
2052
|
names[nameKey] = resolveDbName(attr);
|
|
@@ -1919,6 +2150,47 @@ const makeImpl = (config) => {
|
|
|
1919
2150
|
const raw = fromAttributeMap(result.Attributes);
|
|
1920
2151
|
return yield* decodeAs(raw, result.Attributes, mode);
|
|
1921
2152
|
}), self, input);
|
|
2153
|
+
/**
|
|
2154
|
+
* @internal Compare stored vs attempted `orderBy` to decide whether the CAS
|
|
2155
|
+
* predicate `attribute_not_exists(pk) OR stored < attempted` failed.
|
|
2156
|
+
*
|
|
2157
|
+
* Returns `true` (CAS failed → stale) when `stored >= attempted`.
|
|
2158
|
+
* Returns `false` (CAS held; the user's `.condition()` is the rejecter)
|
|
2159
|
+
* when `stored < attempted` or when `stored` is missing.
|
|
2160
|
+
*
|
|
2161
|
+
* `stored` arrives as a decoded domain value matching the configured schema
|
|
2162
|
+
* for the attribute: a `DateTime.Utc` for `Schema.DateTimeUtc`, a `Date` for
|
|
2163
|
+
* `Schema.Date`, a `number`/`bigint` for numeric schemas, a `string` for
|
|
2164
|
+
* lexicographic comparisons.
|
|
2165
|
+
*/
|
|
2166
|
+
const isCasFailure = (stored, attempted) => {
|
|
2167
|
+
if (stored == null)
|
|
2168
|
+
return false;
|
|
2169
|
+
// DateTime.Utc — compare epoch ms.
|
|
2170
|
+
if (DateTime.isDateTime(stored) && DateTime.isDateTime(attempted)) {
|
|
2171
|
+
return DateTime.toEpochMillis(stored) >= DateTime.toEpochMillis(attempted);
|
|
2172
|
+
}
|
|
2173
|
+
// Date — compare epoch ms.
|
|
2174
|
+
if (stored instanceof Date && attempted instanceof Date) {
|
|
2175
|
+
return stored.getTime() >= attempted.getTime();
|
|
2176
|
+
}
|
|
2177
|
+
// Numeric — number or bigint (cast comparable via JS coercion).
|
|
2178
|
+
if (typeof stored === "number" && typeof attempted === "number") {
|
|
2179
|
+
return stored >= attempted;
|
|
2180
|
+
}
|
|
2181
|
+
if (typeof stored === "bigint" && typeof attempted === "bigint") {
|
|
2182
|
+
return stored >= attempted;
|
|
2183
|
+
}
|
|
2184
|
+
// Strings — lexicographic.
|
|
2185
|
+
if (typeof stored === "string" && typeof attempted === "string") {
|
|
2186
|
+
return stored >= attempted;
|
|
2187
|
+
}
|
|
2188
|
+
// Mixed / unknown shapes — fall back to string compare on String(...).
|
|
2189
|
+
// Defensive: return true so we report StaleAppend (the safer default —
|
|
2190
|
+
// never suppress a CAS rejection by mis-classifying it as a user-condition
|
|
2191
|
+
// rejection).
|
|
2192
|
+
return String(stored) >= String(attempted);
|
|
2193
|
+
};
|
|
1922
2194
|
// ---------------------------------------------------------------------------
|
|
1923
2195
|
// append operation — time-series primitive (only when `timeSeries` configured)
|
|
1924
2196
|
//
|
|
@@ -1929,13 +2201,25 @@ const makeImpl = (config) => {
|
|
|
1929
2201
|
// - Put of event: full decoded input + __edd_e__ + _ttl (if configured),
|
|
1930
2202
|
// GSI keys stripped, SK replaced with `<currentSk>#e#<orderByValue>`.
|
|
1931
2203
|
//
|
|
1932
|
-
//
|
|
1933
|
-
//
|
|
1934
|
-
//
|
|
1935
|
-
//
|
|
2204
|
+
// Concurrency outcome (stale-as-error contract — supersedes
|
|
2205
|
+
// `docs/designs/timeseries.md` §4.7 v1 stale-as-value):
|
|
2206
|
+
//
|
|
2207
|
+
// - Success path (default): on transaction success, issue a follow-up
|
|
2208
|
+
// GetItem and return `{ current }` (decoded model).
|
|
2209
|
+
// - Stale path (default): on TransactionCancelled, issue a follow-up
|
|
2210
|
+
// GetItem to disambiguate. If the stored `orderBy >= attempted`, the CAS
|
|
2211
|
+
// fired → fail with `StaleAppend(current: Option.some)`. Otherwise the
|
|
2212
|
+
// user-supplied `.condition()` rejected the write → fail with
|
|
2213
|
+
// `ConditionalCheckFailed(current: Option.some)`.
|
|
2214
|
+
// - skipFollowUp success path: return `void`.
|
|
2215
|
+
// - skipFollowUp stale path: cannot disambiguate (no GetItem), so fail
|
|
2216
|
+
// with `StaleAppend(current: Option.none)` for both cancellation modes.
|
|
2217
|
+
// Documented as a deliberate trade-off in `guides/timeseries.mdx`.
|
|
2218
|
+
// - TTL-race / row vanished after success: surface as
|
|
2219
|
+
// `ValidationError("append.followUp")`. Undetected on skipFollowUp path.
|
|
1936
2220
|
// ---------------------------------------------------------------------------
|
|
1937
2221
|
const timeSeriesConfig = config.timeSeries;
|
|
1938
|
-
const append = (input, userCondition) => Effect.gen(function* () {
|
|
2222
|
+
const append = (input, userCondition, skipFollowUp = false) => Effect.gen(function* () {
|
|
1939
2223
|
if (!timeSeriesConfig) {
|
|
1940
2224
|
return yield* new ValidationError({
|
|
1941
2225
|
entityType,
|
|
@@ -1948,26 +2232,19 @@ const makeImpl = (config) => {
|
|
|
1948
2232
|
const orderByField = timeSeriesConfig.orderBy;
|
|
1949
2233
|
const ttlDuration = timeSeriesConfig.ttl;
|
|
1950
2234
|
const appendInputSchema = schemas.appendInputSchema;
|
|
1951
|
-
//
|
|
1952
|
-
const
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
cause,
|
|
1956
|
-
})));
|
|
1957
|
-
const decoded = decodedInput;
|
|
1958
|
-
// Compose current-item primary key (pk + sk derived from PK/SK composites in decoded)
|
|
2235
|
+
// Encode user input via appendInputSchema (see `put` for strategy).
|
|
2236
|
+
const encodedInput = yield* encodeOrDecodeEncode(appendInputSchema, input, entityType, "append");
|
|
2237
|
+
const encoded = encodedInput;
|
|
2238
|
+
// Compose current-item primary key (pk + sk derived from PK/SK composites)
|
|
1959
2239
|
const primary = config.indexes.primary;
|
|
1960
|
-
const pkValue = KeyComposer.composePk(schema, entityType, primary,
|
|
1961
|
-
const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary,
|
|
2240
|
+
const pkValue = KeyComposer.composePk(schema, entityType, primary, encoded);
|
|
2241
|
+
const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary, encoded);
|
|
1962
2242
|
const marshalledKey = toAttributeMap({
|
|
1963
2243
|
[primary.pk.field]: pkValue,
|
|
1964
2244
|
[primary.sk.field]: currentSk,
|
|
1965
2245
|
});
|
|
1966
|
-
//
|
|
1967
|
-
|
|
1968
|
-
// before marshalling. This mirrors the put path.
|
|
1969
|
-
const serialisedInput = { ...decoded };
|
|
1970
|
-
serializeDateFields(serialisedInput);
|
|
2246
|
+
// `encoded` is already in wire-form via Schema.encode — used directly.
|
|
2247
|
+
const serialisedInput = { ...encoded };
|
|
1971
2248
|
// ---------- Build UpdateItem (scoped SET + CAS) ----------
|
|
1972
2249
|
const setClauses = [];
|
|
1973
2250
|
const names = {};
|
|
@@ -1996,7 +2273,7 @@ const makeImpl = (config) => {
|
|
|
1996
2273
|
// never change during an append. Non-PK composites in appendInput that
|
|
1997
2274
|
// overlap a GSI's composites trigger per-GSI resolution via indexPolicy.
|
|
1998
2275
|
const nonPkAppendFields = {};
|
|
1999
|
-
for (const [attr, val] of Object.entries(
|
|
2276
|
+
for (const [attr, val] of Object.entries(encoded)) {
|
|
2000
2277
|
if (!pkCompositeSet.has(attr)) {
|
|
2001
2278
|
nonPkAppendFields[attr] = val;
|
|
2002
2279
|
}
|
|
@@ -2031,7 +2308,7 @@ const makeImpl = (config) => {
|
|
|
2031
2308
|
},
|
|
2032
2309
|
};
|
|
2033
2310
|
}
|
|
2034
|
-
const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, policyAwareIndexes, nonPkAppendFields,
|
|
2311
|
+
const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, policyAwareIndexes, nonPkAppendFields, encoded);
|
|
2035
2312
|
for (const [field, value] of Object.entries(gsiUpdate.sets)) {
|
|
2036
2313
|
const nameKey = `#a${counter}`;
|
|
2037
2314
|
const valKey = `:a${counter}`;
|
|
@@ -2091,9 +2368,9 @@ const makeImpl = (config) => {
|
|
|
2091
2368
|
const updateExpression = exprParts.join(" ");
|
|
2092
2369
|
// ---------- Build Put of event item ----------
|
|
2093
2370
|
const eventSk = KeyComposer.composeEventSk(currentSk, newObValue, schema.casing);
|
|
2094
|
-
|
|
2095
|
-
//
|
|
2096
|
-
|
|
2371
|
+
// `encoded` is already in wire-form via Schema.encode; no extra
|
|
2372
|
+
// serialisation step is required for the Put.
|
|
2373
|
+
const eventItem = { ...encoded };
|
|
2097
2374
|
// Rename domain → DB names for the Put (matches put path)
|
|
2098
2375
|
renameToDynamo(eventItem);
|
|
2099
2376
|
// pk + sk (event)
|
|
@@ -2111,6 +2388,22 @@ const makeImpl = (config) => {
|
|
|
2111
2388
|
for (const field of gsiKeyFields()) {
|
|
2112
2389
|
delete eventItem[field];
|
|
2113
2390
|
}
|
|
2391
|
+
// Sparse-map fields are aggregate state, not event state. They live on
|
|
2392
|
+
// the current item only — strip from event items entirely. (Same
|
|
2393
|
+
// treatment as enrichment fields outside `appendInput`. By design,
|
|
2394
|
+
// `appendInput` cannot include sparse fields, so this is defensive.)
|
|
2395
|
+
if (hasSparseFields) {
|
|
2396
|
+
for (const fieldName of Object.keys(sparseFields)) {
|
|
2397
|
+
delete eventItem[fieldName];
|
|
2398
|
+
// Also strip any flattened bucket attrs that might have leaked in
|
|
2399
|
+
// via a future code change — defensive cleanup.
|
|
2400
|
+
const prefixWithDelim = `${sparseFields[fieldName].prefix}#`;
|
|
2401
|
+
for (const k of Object.keys(eventItem)) {
|
|
2402
|
+
if (k.startsWith(prefixWithDelim))
|
|
2403
|
+
delete eventItem[k];
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2114
2407
|
const marshalledEventItem = toAttributeMap(eventItem);
|
|
2115
2408
|
// ---------- Execute TransactWriteItems ----------
|
|
2116
2409
|
const transactResult = yield* client
|
|
@@ -2138,40 +2431,83 @@ const makeImpl = (config) => {
|
|
|
2138
2431
|
onSuccess: () => Effect.succeed({ stale: false }),
|
|
2139
2432
|
onFailure: (err) => {
|
|
2140
2433
|
// TransactionCancelled or ConditionalCheckFailed at the transaction
|
|
2141
|
-
// level both
|
|
2142
|
-
//
|
|
2434
|
+
// level both surface here. Disambiguation between CAS and
|
|
2435
|
+
// user-condition happens after the follow-up GetItem (when run).
|
|
2143
2436
|
if (isAwsTransactionCancelled(err.cause) || isAwsConditionalCheckFailed(err.cause)) {
|
|
2144
2437
|
return Effect.succeed({ stale: true });
|
|
2145
2438
|
}
|
|
2146
2439
|
return Effect.fail(err);
|
|
2147
2440
|
},
|
|
2148
2441
|
}));
|
|
2149
|
-
//
|
|
2150
|
-
//
|
|
2151
|
-
//
|
|
2442
|
+
// -----------------------------------------------------------------
|
|
2443
|
+
// skipFollowUp branch — no GetItem, cannot decode current, cannot
|
|
2444
|
+
// disambiguate CAS vs user-condition cancellation. Both modes
|
|
2445
|
+
// collapse to StaleAppend(current: Option.none) — documented.
|
|
2446
|
+
// -----------------------------------------------------------------
|
|
2447
|
+
if (skipFollowUp) {
|
|
2448
|
+
if (transactResult.stale) {
|
|
2449
|
+
return yield* new StaleAppend({
|
|
2450
|
+
entityType,
|
|
2451
|
+
orderByField,
|
|
2452
|
+
attemptedOrderBy: newObValue,
|
|
2453
|
+
current: Option.none(),
|
|
2454
|
+
});
|
|
2455
|
+
}
|
|
2456
|
+
return undefined;
|
|
2457
|
+
}
|
|
2458
|
+
// -----------------------------------------------------------------
|
|
2459
|
+
// Default branch — one follow-up GetItem either way. Carries:
|
|
2460
|
+
// - Decoded model on success (the post-append current).
|
|
2461
|
+
// - Disambiguation data on cancellation (stored orderBy vs attempted)
|
|
2462
|
+
// plus a model snapshot to attach to the error for the caller.
|
|
2463
|
+
// -----------------------------------------------------------------
|
|
2152
2464
|
const followUp = yield* client.getItem({
|
|
2153
2465
|
TableName: tableName,
|
|
2154
2466
|
Key: marshalledKey,
|
|
2155
2467
|
});
|
|
2156
2468
|
if (!followUp.Item) {
|
|
2157
|
-
// TTL race or out-of-band delete —
|
|
2158
|
-
//
|
|
2159
|
-
return yield*
|
|
2469
|
+
// TTL race or out-of-band delete — the previous state vanished
|
|
2470
|
+
// under us. Distinct from a CAS-stale outcome.
|
|
2471
|
+
return yield* new ValidationError({
|
|
2160
2472
|
entityType,
|
|
2161
2473
|
operation: "append.followUp",
|
|
2162
2474
|
cause: "Current item not found after append — possible TTL race or concurrent delete.",
|
|
2163
|
-
})
|
|
2475
|
+
});
|
|
2164
2476
|
}
|
|
2165
2477
|
const rawCurrent = fromAttributeMap(followUp.Item);
|
|
2166
2478
|
const currentModel = yield* decodeAs(rawCurrent, followUp.Item, "model");
|
|
2167
2479
|
if (transactResult.stale) {
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2480
|
+
// Disambiguate CAS-stale from user-condition rejection. We compare
|
|
2481
|
+
// in the WIRE form (what DynamoDB actually evaluated): `newObValue`
|
|
2482
|
+
// is the encoded value passed to the CAS, and `rawCurrent` carries
|
|
2483
|
+
// the encoded values read back. `decodeAs` mutated `rawCurrent` to
|
|
2484
|
+
// rename DB columns to domain field names, so `rawCurrent[orderByField]`
|
|
2485
|
+
// is the wire-form value under its domain name. Wire-form
|
|
2486
|
+
// comparison matches DynamoDB's evaluation: ISO-8601 strings sort
|
|
2487
|
+
// lexicographically (= chronologically), numbers/bigints are
|
|
2488
|
+
// numeric, strings are lexicographic.
|
|
2489
|
+
const storedOrderBy = rawCurrent[orderByField];
|
|
2490
|
+
const attempted = newObValue;
|
|
2491
|
+
const casFired = isCasFailure(storedOrderBy, attempted);
|
|
2492
|
+
if (!casFired && userCondition !== undefined) {
|
|
2493
|
+
// CAS held; the user's `.condition()` is the only thing that
|
|
2494
|
+
// could have rejected.
|
|
2495
|
+
return yield* new ConditionalCheckFailed({
|
|
2496
|
+
entityType,
|
|
2497
|
+
key: encoded,
|
|
2498
|
+
current: Option.some(currentModel),
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
2501
|
+
// Either CAS fired, or no user condition was supplied (CAS is the
|
|
2502
|
+
// only possibility) — both map to StaleAppend.
|
|
2503
|
+
return yield* new StaleAppend({
|
|
2504
|
+
entityType,
|
|
2505
|
+
orderByField,
|
|
2506
|
+
attemptedOrderBy: attempted,
|
|
2507
|
+
current: Option.some(currentModel),
|
|
2508
|
+
});
|
|
2173
2509
|
}
|
|
2174
|
-
return {
|
|
2510
|
+
return { current: currentModel };
|
|
2175
2511
|
});
|
|
2176
2512
|
// ---------------------------------------------------------------------------
|
|
2177
2513
|
// history operation — BoundQuery for event items only
|
|
@@ -2186,8 +2522,12 @@ const makeImpl = (config) => {
|
|
|
2186
2522
|
const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary, decodedKey);
|
|
2187
2523
|
const prefix = KeyComposer.composeEventSkPrefix(currentSk, schema.casing);
|
|
2188
2524
|
const decodeHistory = (raw) => {
|
|
2525
|
+
// Sparse fields are aggregate state, not event state — event items DO
|
|
2526
|
+
// NOT carry `<prefix>#*` attributes. `deserializeSparseFields` populates
|
|
2527
|
+
// empty `{}` Records under the domain field names so the historyRecord
|
|
2528
|
+
// schema (which still includes the sparse Record fields) decodes.
|
|
2529
|
+
deserializeSparseFields(raw);
|
|
2189
2530
|
renameFromDynamo(raw);
|
|
2190
|
-
deserializeDateFields(raw);
|
|
2191
2531
|
return Schema.decodeUnknownEffect(schemas.historyRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
|
|
2192
2532
|
entityType,
|
|
2193
2533
|
operation: "history.decode",
|
|
@@ -2331,6 +2671,11 @@ const makeImpl = (config) => {
|
|
|
2331
2671
|
// (itemSchema would fail because it expects GSI key fields that aren't present)
|
|
2332
2672
|
if (mode === "native")
|
|
2333
2673
|
return result.Items[0];
|
|
2674
|
+
// Sparse Map fields: rebuild domain Records from flattened attrs so
|
|
2675
|
+
// soft-deleted items decode correctly. Sparse data is preserved
|
|
2676
|
+
// verbatim across soft-delete (GSI keys are stripped, sparse data is
|
|
2677
|
+
// not).
|
|
2678
|
+
deserializeSparseFields(raw);
|
|
2334
2679
|
const targetSchema = schemas.deletedRecordSchema;
|
|
2335
2680
|
return yield* Schema.decodeUnknownEffect(targetSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
|
|
2336
2681
|
entityType,
|
|
@@ -2343,11 +2688,16 @@ const makeImpl = (config) => {
|
|
|
2343
2688
|
const primary = config.indexes.primary;
|
|
2344
2689
|
const pkValue = KeyComposer.composePk(schema, entityType, primary, decodedKey);
|
|
2345
2690
|
const deletedPrefix = DynamoSchema.composeDeletedKeyPrefix(schema, entityType);
|
|
2346
|
-
const decodeDeleted = (raw) =>
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2691
|
+
const decodeDeleted = (raw) => {
|
|
2692
|
+
// Sparse fields are domain data and are preserved across soft-delete.
|
|
2693
|
+
// Rebuild Records from flattened attrs before schema decode.
|
|
2694
|
+
deserializeSparseFields(raw);
|
|
2695
|
+
return Schema.decodeUnknownEffect(schemas.deletedRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
|
|
2696
|
+
entityType,
|
|
2697
|
+
operation: "deleted.list.decode",
|
|
2698
|
+
cause,
|
|
2699
|
+
})));
|
|
2700
|
+
};
|
|
2351
2701
|
return Query.make({
|
|
2352
2702
|
tableName: "",
|
|
2353
2703
|
indexName: undefined,
|
|
@@ -2602,7 +2952,9 @@ const makeImpl = (config) => {
|
|
|
2602
2952
|
// ---------------------------------------------------------------------------
|
|
2603
2953
|
// Expression combinators: condition, filter, select
|
|
2604
2954
|
// ---------------------------------------------------------------------------
|
|
2605
|
-
|
|
2955
|
+
// Sparse-field map used by createPathBuilder to wire `.entry(key)` accessors.
|
|
2956
|
+
const sparseFieldsForPath = hasSparseFields ? sparseFields : undefined;
|
|
2957
|
+
const entityPathBuilder = createPathBuilder([], resolveDbName, sparseFieldsForPath);
|
|
2606
2958
|
const entityConditionOps = createConditionOps();
|
|
2607
2959
|
/**
|
|
2608
2960
|
* Build a condition expression combinator from callback or shorthand.
|
|
@@ -2658,9 +3010,9 @@ const makeImpl = (config) => {
|
|
|
2658
3010
|
identifier: resolvedIdentifier,
|
|
2659
3011
|
timeSeries: config.timeSeries,
|
|
2660
3012
|
_resolvedRefs: resolvedRefs,
|
|
2661
|
-
/** @internal Full decode pipeline: rename +
|
|
3013
|
+
/** @internal Full decode pipeline: rename + schema decode. Used by Batch/Aggregate. */
|
|
2662
3014
|
_decodeRecord: decodeRecord,
|
|
2663
|
-
|
|
3015
|
+
_serializeSparseFields: serializeSparseFields,
|
|
2664
3016
|
_attachPrototype: attachPrototype,
|
|
2665
3017
|
_configure: (injectedSchema, injectedTableTag) => {
|
|
2666
3018
|
schema = injectedSchema;
|
|
@@ -2728,10 +3080,15 @@ const makeImpl = (config) => {
|
|
|
2728
3080
|
export const bind = (entity) => Effect.gen(function* () {
|
|
2729
3081
|
const ctx = yield* Effect.context();
|
|
2730
3082
|
const provide = (effect) => Effect.provide(effect, ctx);
|
|
3083
|
+
// Sparse-field map (if any) — extracted from the Entity's configured model
|
|
3084
|
+
// so the PathBuilder used by `.condition((t, ops) => ...)` exposes
|
|
3085
|
+
// `.entry(key)` accessors on sparse-map fields.
|
|
3086
|
+
const boundSparseFields = getSparseFields(entity.model);
|
|
3087
|
+
const boundSparseFieldsForPath = Object.keys(boundSparseFields).length > 0 ? boundSparseFields : undefined;
|
|
2731
3088
|
// Shared config for the bound-CRUD builders — pre-resolved services plus
|
|
2732
3089
|
// a typed PathBuilder/ConditionOps so `.condition((t, ops) => ...)` works.
|
|
2733
3090
|
const boundCrudConfig = {
|
|
2734
|
-
pathBuilder: createPathBuilder(),
|
|
3091
|
+
pathBuilder: createPathBuilder(undefined, undefined, boundSparseFieldsForPath),
|
|
2735
3092
|
conditionOps: createConditionOps(),
|
|
2736
3093
|
provide,
|
|
2737
3094
|
};
|
|
@@ -2775,7 +3132,13 @@ export const bind = (entity) => Effect.gen(function* () {
|
|
|
2775
3132
|
},
|
|
2776
3133
|
// Time-series (no-ops when entity is not configured; runtime check in
|
|
2777
3134
|
// the entity-level `append` returns a ValidationError).
|
|
2778
|
-
append: (input
|
|
3135
|
+
append: (input) => {
|
|
3136
|
+
const appendCfg = {
|
|
3137
|
+
...boundCrudConfig,
|
|
3138
|
+
run: (opts) => provide(entity.append(opts.input, opts.condition, opts.skipFollowUp)),
|
|
3139
|
+
};
|
|
3140
|
+
return makeBoundAppend(input, appendCfg);
|
|
3141
|
+
},
|
|
2779
3142
|
history: (key) => {
|
|
2780
3143
|
const q = entity.history(key);
|
|
2781
3144
|
const pathBuilder = createPathBuilder();
|