effect-dynamodb 1.5.0 → 1.7.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/Entity.js CHANGED
@@ -6,11 +6,11 @@
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, Option, Schema, Stream } from "effect";
9
+ import { DateTime, Duration, Effect, Option, Schema, SchemaAST, Stream } from "effect";
10
10
  import { DynamoClient } from "./DynamoClient.js";
11
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, StaleAppend, TransactionOverflow, UniqueConstraintViolation, ValidationError, } from "./Errors.js";
13
+ import { CascadePartialFailure, CompositeKeyHoleError, ConditionalCheckFailed, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled, makeCompositeNullableError, OptimisticLockError, RefNotFound, StaleAppend, TransactionOverflow, UniqueConstraintViolation, ValidationError, } from "./Errors.js";
14
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";
@@ -206,6 +206,108 @@ const makeImpl = (config) => {
206
206
  }
207
207
  }
208
208
  // ---------------------------------------------------------------------------
209
+ // EDD-9025: composite attribute schemas must not include `null`.
210
+ //
211
+ // Composites participate in string composition (`acc#X#alert#Y`); `null` is
212
+ // not a meaningful slot value. Allowing `Schema.NullOr` / `NullishOr` /
213
+ // `Schema.Union([..., Schema.Null])` on a composite would let
214
+ // `set({ composite: null })` typecheck (after the v1.6 update-payload
215
+ // widening was reverted), then either blow up at runtime or silently
216
+ // produce a key with the literal string `"null"` as a slot.
217
+ //
218
+ // Sparse pattern is still expressible via `Schema.optional(...)` (T |
219
+ // undefined), which produces no Null AST node. See DESIGN.md §7.
220
+ // ---------------------------------------------------------------------------
221
+ // Resolve the AST for a domain composite name. For ref-derived `${name}Id`
222
+ // composites, the schema is the referenced entity's identifier schema (not
223
+ // the model field). For all other composites, it's the model field's
224
+ // schema. Returns undefined when the composite isn't declared on the model
225
+ // (already caught by EDD-9002 above, so this is defensive).
226
+ const resolveCompositeSchemaForNullCheck = (compositeAttr) => {
227
+ if (compositeAttr in modelFields) {
228
+ return { schema: modelFields[compositeAttr], source: `model field` };
229
+ }
230
+ // Ref-derived ${name}Id composite — look up the identifier schema on the
231
+ // referenced entity's model. We can't use the resolvedRefs array here
232
+ // (it's built later); recompute inline against config.refs.
233
+ if (config.refs) {
234
+ for (const [refFieldName, refValue] of Object.entries(config.refs)) {
235
+ if (`${refFieldName}Id` !== compositeAttr)
236
+ continue;
237
+ const refEntity = refValue.entity;
238
+ const idField = getIdentifierField(refEntity.model);
239
+ if (!idField)
240
+ return undefined;
241
+ return { schema: idField.schema, source: `ref "${refFieldName}" identifier` };
242
+ }
243
+ }
244
+ return undefined;
245
+ };
246
+ // Recursively walk an AST and return a path-string if `null` is reachable
247
+ // in the type union (anywhere — direct, nested in Union, behind Suspend).
248
+ // `Schema.optional(X)` produces `T | undefined` (no Null) and is allowed.
249
+ // `Schema.NullOr(X)` produces `T | null` and is rejected.
250
+ const findNullInAst = (ast, path, seen = new Set()) => {
251
+ if (seen.has(ast))
252
+ return undefined;
253
+ seen.add(ast);
254
+ if (SchemaAST.isNull(ast))
255
+ return `${path}.Null`;
256
+ if (SchemaAST.isUnion(ast)) {
257
+ for (let i = 0; i < ast.types.length; i++) {
258
+ const found = findNullInAst(ast.types[i], `${path}.Union[${i}]`, seen);
259
+ if (found)
260
+ return found;
261
+ }
262
+ }
263
+ // Other AST shapes (Declaration, String, Number, Suspend, ...) cannot
264
+ // introduce `null` themselves. Suspend wrappers are handled
265
+ // conservatively — we don't recurse into thunks because the EDD-9025
266
+ // check is about static type unions, and recursive schemas wrapping
267
+ // composite-eligible scalars are pathological for keys anyway.
268
+ return undefined;
269
+ };
270
+ const checkCompositeForNull = (surface, compositeAttr) => {
271
+ const resolved = resolveCompositeSchemaForNullCheck(compositeAttr);
272
+ if (!resolved)
273
+ return; // EDD-9002 already caught the unknown-attribute case
274
+ const found = findNullInAst(resolved.schema.ast, resolved.source);
275
+ if (found) {
276
+ throw makeCompositeNullableError({
277
+ entityType: config.entityType,
278
+ surface,
279
+ compositeAttribute: compositeAttr,
280
+ schemaPath: found,
281
+ });
282
+ }
283
+ };
284
+ // primaryKey composites
285
+ for (const attr of [
286
+ ...config.indexes.primary.pk.composite,
287
+ ...config.indexes.primary.sk.composite,
288
+ ]) {
289
+ checkCompositeForNull("primaryKey", attr);
290
+ }
291
+ // GSI composites (each index)
292
+ for (const [indexName, indexDef] of Object.entries(config.indexes)) {
293
+ if (indexName === "primary")
294
+ continue;
295
+ for (const attr of [...indexDef.pk.composite, ...indexDef.sk.composite]) {
296
+ checkCompositeForNull(`index "${indexName}"`, attr);
297
+ }
298
+ }
299
+ // Unique constraint composites — `unique` is keyed by constraint name,
300
+ // value is a single field name OR an array of field names OR a config
301
+ // object. resolveUniqueFields normalises all three to a list of field names.
302
+ if (config.unique) {
303
+ for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
304
+ const fields = resolveUniqueFields(constraintDef);
305
+ for (const f of fields) {
306
+ checkCompositeForNull(`unique:${constraintName}`, f);
307
+ }
308
+ }
309
+ }
310
+ // ---------------------------------------------------------------------------
209
311
  // Validate timeSeries config (EDD-9010..9016)
210
312
  // ---------------------------------------------------------------------------
211
313
  if (config.timeSeries !== undefined && config.timeSeries !== null) {
@@ -1173,13 +1275,20 @@ const makeImpl = (config) => {
1173
1275
  // rather than whole-field replace — concurrent writers to disjoint
1174
1276
  // buckets must coexist (the version CAS protects against same-bucket
1175
1277
  // races). Non-sparse fields use the existing replace semantics.
1278
+ //
1279
+ // null and undefined collapse: both mean "absent" under v3's
1280
+ // two-way classification (DESIGN.md §7). For the in-memory item
1281
+ // we DELETE the attribute (mirrors a DynamoDB REMOVE), so
1282
+ // subsequent decode sees the field as absent rather than as a
1283
+ // typed null that the model schema may not permit. The composer
1284
+ // below sees the attr as absent in `newItem` and applies the
1285
+ // structural rule.
1176
1286
  for (const [attr, val] of Object.entries(hydratedUpdates)) {
1177
- if (val === undefined)
1287
+ if (val === null || val === undefined) {
1288
+ delete newItem[attr];
1178
1289
  continue;
1179
- if (hasSparseFields &&
1180
- attr in sparseFields &&
1181
- val !== null &&
1182
- typeof val === "object") {
1290
+ }
1291
+ if (hasSparseFields && attr in sparseFields && typeof val === "object") {
1183
1292
  const existing = newItem[attr] ?? {};
1184
1293
  newItem[attr] = { ...existing, ...val };
1185
1294
  }
@@ -1259,8 +1368,55 @@ const makeImpl = (config) => {
1259
1368
  // `newItem` is already in domain names (the merge built it from
1260
1369
  // `currentDomainItem` + domain-keyed updates) — no rename needed
1261
1370
  // here. Recompose all keys with the updated attributes.
1262
- const newKeys = composeAllKeys(newItem);
1263
- Object.assign(newItem, newKeys);
1371
+ //
1372
+ // Primary key always recomposes from `newItem` (Put-style).
1373
+ const primaryKeyMap = composePrimaryKey(newItem);
1374
+ Object.assign(newItem, primaryKeyMap);
1375
+ // GSI keys: route through the policy-aware composer so the v3
1376
+ // structural rule + `Entity.remove` cascade match the standard
1377
+ // update path. `newItem` carries stored values for any composite
1378
+ // the user did not touch — passing it as `keyRecord` lets the
1379
+ // composer treat omissions as no-ops (the half stays untouched
1380
+ // because the stored value is present), while still firing the
1381
+ // cascade and the structural truncation rule. See DESIGN.md §7.
1382
+ const retainRemovedSet = uState.remove ? new Set(uState.remove) : undefined;
1383
+ try {
1384
+ const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, allIndexes, hydratedUpdates, newItem, { removedSet: retainRemovedSet });
1385
+ for (const [field, value] of Object.entries(gsiUpdate.sets)) {
1386
+ newItem[field] = value;
1387
+ }
1388
+ for (const field of gsiUpdate.removes) {
1389
+ delete newItem[field];
1390
+ }
1391
+ // Untouched GSIs (no policy + no payload + no cascade): retain
1392
+ // path semantics are Put-style — recompose from `newItem` and
1393
+ // drop both keys when any composite is missing. Matches the
1394
+ // existing `composeAllKeys` behavior for those GSIs.
1395
+ const addressed = new Set([
1396
+ ...Object.keys(gsiUpdate.sets),
1397
+ ...gsiUpdate.removes,
1398
+ ]);
1399
+ for (const [indexName, indexDef] of Object.entries(allIndexes)) {
1400
+ if (indexName === "primary")
1401
+ continue;
1402
+ if (addressed.has(indexDef.pk.field) || addressed.has(indexDef.sk.field))
1403
+ continue;
1404
+ const keys = KeyComposer.tryComposeIndexKeys(schema, entityType, entityVersion, indexDef, newItem);
1405
+ if (keys) {
1406
+ Object.assign(newItem, keys);
1407
+ }
1408
+ else {
1409
+ delete newItem[indexDef.pk.field];
1410
+ delete newItem[indexDef.sk.field];
1411
+ }
1412
+ }
1413
+ }
1414
+ catch (e) {
1415
+ if (e instanceof CompositeKeyHoleError) {
1416
+ return yield* e;
1417
+ }
1418
+ throw e;
1419
+ }
1264
1420
  const sentinelRotations = [];
1265
1421
  if (touchesUniqueFields) {
1266
1422
  const currentRawRecord = currentRaw;
@@ -1443,18 +1599,32 @@ const makeImpl = (config) => {
1443
1599
  const updateSystemColliders = new Set();
1444
1600
  if (systemFields.updatedAtCollision && systemFields.updatedAt)
1445
1601
  updateSystemColliders.add(systemFields.updatedAt);
1446
- // Add user-provided updates
1602
+ // Add user-provided updates. Buffered REMOVE clauses for null
1603
+ // payload entries land in `removeClauses` below — under v3,
1604
+ // `set({ attr: null | undefined })` REMOVEs the attribute from the
1605
+ // item but does NOT separately cascade-drop GSI keys. The structural
1606
+ // composer treats the cleared attribute as absent and recomposes
1607
+ // (or truncates / drops / no-ops) per the half's policy.
1608
+ // EDD-9025 guarantees no composite is nullable, so `set` of `null`
1609
+ // is only reachable for non-composite, model-declared-nullable
1610
+ // attributes. See DESIGN.md §7.
1611
+ const nullClears = [];
1447
1612
  for (const [attr, val] of Object.entries(encodedUpdatesMap)) {
1448
- if (val === undefined)
1449
- continue;
1450
1613
  if (updateSystemColliders.has(attr))
1451
1614
  continue;
1615
+ if (val === null || val === undefined) {
1616
+ // Sparse fields: clearing a sparse field with null is a no-op
1617
+ // here — the `.removeEntries` API is the explicit per-key
1618
+ // remove. Skip to avoid REMOVE'ing the whole prefix erroneously.
1619
+ if (hasSparseFields && attr in sparseFields)
1620
+ continue;
1621
+ nullClears.push(attr);
1622
+ continue;
1623
+ }
1452
1624
  // Sparse fields: emit one SET per bucket. Whole-bucket replace
1453
1625
  // 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
1626
  if (hasSparseFields && attr in sparseFields) {
1457
- if (val === null || typeof val !== "object")
1627
+ if (typeof val !== "object")
1458
1628
  continue;
1459
1629
  const sparse = sparseFields[attr];
1460
1630
  for (const [k, v] of Object.entries(val)) {
@@ -1530,6 +1700,17 @@ const makeImpl = (config) => {
1530
1700
  }
1531
1701
  }
1532
1702
  // REMOVE
1703
+ // Two channels feed REMOVE clauses:
1704
+ // 1. `Entity.remove([attr])` — explicit per-attribute drop. Cascades
1705
+ // to drop the GSI keys for any GSI containing the attr as a
1706
+ // composite (one of v3's two drop triggers — see DESIGN.md §7).
1707
+ // Tracked in `removedSet` so the composer can apply the cascade
1708
+ // rule.
1709
+ // 2. Null payload entries (`set({ attr: null })`). These REMOVE the
1710
+ // attribute from the item only. They do NOT cascade GSI drops —
1711
+ // under v3 two-way classification, the structural composer just
1712
+ // treats the attribute as absent. (EDD-9025 guarantees these
1713
+ // are never composites.)
1533
1714
  const removedSet = uState.remove ? new Set(uState.remove) : undefined;
1534
1715
  if (uState.remove) {
1535
1716
  for (const attr of uState.remove) {
@@ -1538,11 +1719,31 @@ const makeImpl = (config) => {
1538
1719
  removeClauses.push(nameKey);
1539
1720
  }
1540
1721
  }
1541
- // Policy-aware GSI key composition. One call covers SETs (full
1542
- // recompose or half-wise), sparse dropout REMOVEs, and cascade
1543
- // REMOVEs when an Entity.remove() targets a GSI composite.
1544
- // See DESIGN.md §7 Policy-Aware GSI Composition.
1545
- const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, allIndexes, hydratedUpdates, decodedKey, { removedSet });
1722
+ for (const attr of nullClears) {
1723
+ const nameKey = `#r${removeClauses.length}`;
1724
+ names[nameKey] = resolveDbName(attr);
1725
+ removeClauses.push(nameKey);
1726
+ }
1727
+ // Policy-aware GSI key composition (v3 — per-half structural rule).
1728
+ // One call covers SETs (full recompose, truncated leading prefix,
1729
+ // or no-op), sparse whole-half-empty REMOVEs, and cascade REMOVEs
1730
+ // when an Entity.remove() targets a GSI composite. See DESIGN.md
1731
+ // §7 Policy-Aware GSI Composition.
1732
+ //
1733
+ // The composer throws CompositeKeyHoleError (EDD-9024) when the
1734
+ // merged payload describes a hole pattern AND the relevant half's
1735
+ // policy is `'preserve'`. Under `'sparse'`, holes silently truncate.
1736
+ // Catch the error synchronously and surface as a typed Effect failure.
1737
+ let gsiUpdate;
1738
+ try {
1739
+ gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, allIndexes, hydratedUpdates, decodedKey, { removedSet });
1740
+ }
1741
+ catch (e) {
1742
+ if (e instanceof CompositeKeyHoleError) {
1743
+ return yield* e;
1744
+ }
1745
+ throw e;
1746
+ }
1546
1747
  for (const [field, value] of Object.entries(gsiUpdate.sets)) {
1547
1748
  const nameKey = `#u${counter}`;
1548
1749
  const valKey = `:u${counter}`;
@@ -2267,48 +2468,33 @@ const makeImpl = (config) => {
2267
2468
  setClauses.push(`${nameKey} = ${valKey}`);
2268
2469
  counter++;
2269
2470
  }
2270
- // GSI key recomposition: any GSI whose composites are touched by input.
2271
- // Reuses the policy-aware helper from `.update()`. PK composites are
2272
- // excluded from the update payload — they're already in the key and
2273
- // never change during an append. Non-PK composites in appendInput that
2274
- // overlap a GSI's composites trigger per-GSI resolution via indexPolicy.
2471
+ // GSI key recomposition. v3 unifies append with update — both call
2472
+ // the same composer. PK composites are excluded from the update
2473
+ // payload — they're already in the key and never change during an
2474
+ // append. The composer treats composites outside `appendInput` as
2475
+ // absent under the structural rule (per-half policy decides whether
2476
+ // a half empties to drop or no-op). See DESIGN.md §7 — "append uses
2477
+ // the same composer."
2275
2478
  const nonPkAppendFields = {};
2276
2479
  for (const [attr, val] of Object.entries(encoded)) {
2277
2480
  if (!pkCompositeSet.has(attr)) {
2278
2481
  nonPkAppendFields[attr] = val;
2279
2482
  }
2280
2483
  }
2281
- // At append-time, indexPolicy is restricted to composites that are
2282
- // members of appendInput. Non-appendInput composites are owned by
2283
- // other writers and are by contract never touched by .append(); their
2284
- // policy cannot fire at append-time. Each GSI's indexPolicy return is
2285
- // filtered to appendInput attrs only — non-appendInput keys fall through
2286
- // to the implicit `"preserve"` default, leaving the half containing
2287
- // them untouched.
2288
- const appendInputFieldSet = new Set(Object.keys(appendInputSchema
2289
- .fields ?? {}));
2290
- const policyAwareIndexes = {};
2291
- for (const [indexName, indexDef] of Object.entries(allIndexes)) {
2292
- if (!indexDef.indexPolicy) {
2293
- policyAwareIndexes[indexName] = indexDef;
2294
- continue;
2484
+ // The composer can throw CompositeKeyHoleError (EDD-9024) when a hole
2485
+ // pattern is encountered AND the relevant half's policy is `'preserve'`.
2486
+ // Under `'sparse'`, holes silently truncate. Surface the error as a
2487
+ // typed Effect failure.
2488
+ let gsiUpdate;
2489
+ try {
2490
+ gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, allIndexes, nonPkAppendFields, encoded);
2491
+ }
2492
+ catch (e) {
2493
+ if (e instanceof CompositeKeyHoleError) {
2494
+ return yield* e;
2295
2495
  }
2296
- const originalPolicy = indexDef.indexPolicy;
2297
- policyAwareIndexes[indexName] = {
2298
- ...indexDef,
2299
- indexPolicy: (item) => {
2300
- const raw = originalPolicy(item);
2301
- const filtered = {};
2302
- for (const [attr, policy] of Object.entries(raw)) {
2303
- if (appendInputFieldSet.has(attr) && policy !== undefined) {
2304
- filtered[attr] = policy;
2305
- }
2306
- }
2307
- return filtered;
2308
- },
2309
- };
2496
+ throw e;
2310
2497
  }
2311
- const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, policyAwareIndexes, nonPkAppendFields, encoded);
2312
2498
  for (const [field, value] of Object.entries(gsiUpdate.sets)) {
2313
2499
  const nameKey = `#a${counter}`;
2314
2500
  const valKey = `:a${counter}`;