effect-dynamodb 1.15.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/Aggregate.d.ts +49 -5
  2. package/dist/Aggregate.d.ts.map +1 -1
  3. package/dist/Aggregate.js +479 -88
  4. package/dist/Aggregate.js.map +1 -1
  5. package/dist/Batch.d.ts +10 -2
  6. package/dist/Batch.d.ts.map +1 -1
  7. package/dist/Batch.js +45 -6
  8. package/dist/Batch.js.map +1 -1
  9. package/dist/Collection.d.ts +0 -7
  10. package/dist/Collection.d.ts.map +1 -1
  11. package/dist/Collection.js +84 -2
  12. package/dist/Collection.js.map +1 -1
  13. package/dist/DynamoClient.d.ts +3 -1
  14. package/dist/DynamoClient.d.ts.map +1 -1
  15. package/dist/DynamoClient.js +253 -26
  16. package/dist/DynamoClient.js.map +1 -1
  17. package/dist/Entity.d.ts +87 -6
  18. package/dist/Entity.d.ts.map +1 -1
  19. package/dist/Entity.js +413 -144
  20. package/dist/Entity.js.map +1 -1
  21. package/dist/EventStore.d.ts.map +1 -1
  22. package/dist/EventStore.js +49 -17
  23. package/dist/EventStore.js.map +1 -1
  24. package/dist/Query.d.ts +56 -5
  25. package/dist/Query.d.ts.map +1 -1
  26. package/dist/Query.js +218 -34
  27. package/dist/Query.js.map +1 -1
  28. package/dist/Transaction.d.ts +2 -2
  29. package/dist/Transaction.d.ts.map +1 -1
  30. package/dist/Transaction.js +29 -9
  31. package/dist/Transaction.js.map +1 -1
  32. package/dist/internal/BoundCrud.d.ts +76 -9
  33. package/dist/internal/BoundCrud.d.ts.map +1 -1
  34. package/dist/internal/BoundCrud.js +22 -1
  35. package/dist/internal/BoundCrud.js.map +1 -1
  36. package/dist/internal/BoundQuery.d.ts +75 -13
  37. package/dist/internal/BoundQuery.d.ts.map +1 -1
  38. package/dist/internal/BoundQuery.js +85 -11
  39. package/dist/internal/BoundQuery.js.map +1 -1
  40. package/dist/internal/BoundVectorQuery.d.ts +9 -0
  41. package/dist/internal/BoundVectorQuery.d.ts.map +1 -1
  42. package/dist/internal/BoundVectorQuery.js +3 -2
  43. package/dist/internal/BoundVectorQuery.js.map +1 -1
  44. package/dist/internal/EntityCombinators.d.ts +31 -5
  45. package/dist/internal/EntityCombinators.d.ts.map +1 -1
  46. package/dist/internal/EntityCombinators.js +7 -7
  47. package/dist/internal/EntityCombinators.js.map +1 -1
  48. package/dist/internal/EntityOps.d.ts +14 -1
  49. package/dist/internal/EntityOps.d.ts.map +1 -1
  50. package/dist/internal/EntityOps.js +4 -1
  51. package/dist/internal/EntityOps.js.map +1 -1
  52. package/dist/internal/TransactWriteOps.d.ts +44 -5
  53. package/dist/internal/TransactWriteOps.d.ts.map +1 -1
  54. package/dist/internal/TransactWriteOps.js +98 -20
  55. package/dist/internal/TransactWriteOps.js.map +1 -1
  56. package/dist/internal/TransactableOps.d.ts +69 -1
  57. package/dist/internal/TransactableOps.d.ts.map +1 -1
  58. package/dist/internal/TransactableOps.js +138 -4
  59. package/dist/internal/TransactableOps.js.map +1 -1
  60. package/package.json +2 -2
package/dist/Entity.js CHANGED
@@ -10,14 +10,15 @@ import { getIdentifierField, getSparseFields, isRefField, } from "@effect-dynamo
10
10
  import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
11
11
  import { Embedder } from "@effect-dynamodb/schema/Embedder.js";
12
12
  import { CascadePartialFailure, ConditionalCheckFailed, EmbeddingError, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled, OptimisticLockError, RefNotFound, StaleAppend, TransactionOverflow, UniqueConstraintViolation, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
13
+ import { makeCompositeKeyForm, toCompositeKeyRecord, } from "@effect-dynamodb/schema/internal/CompositeCodec.js";
13
14
  import { makeDefaultCrypto } from "@effect-dynamodb/schema/internal/DefaultCrypto.js";
14
15
  import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
15
16
  import { normalizeGsiConfig } from "@effect-dynamodb/schema/KeyComposer.js";
16
17
  import { deriveSourceText } from "@effect-dynamodb/schema/VectorIndex.js";
17
18
  import { Context, Crypto, DateTime, Effect, Option, Schema, Stream } from "effect";
18
19
  import { DynamoClient } from "./DynamoClient.js";
19
- import { makeBoundAppend, makeBoundDelete, makeBoundPut, makeBoundUpdate, } from "./internal/BoundCrud.js";
20
- import { BoundQueryImpl } from "./internal/BoundQuery.js";
20
+ import { isBoundOp, makeBoundAppend, makeBoundDelete, makeBoundPut, makeBoundUpdate, } from "./internal/BoundCrud.js";
21
+ import { BoundQueryImpl, } from "./internal/BoundQuery.js";
21
22
  import { compileExpr, createConditionOps, isExpr, parseShorthand, parseSimpleShorthand, } from "./internal/Expr.js";
22
23
  import { compilePath, createPathBuilder } from "./internal/PathBuilder.js";
23
24
  import { generateTimestampPrimitive } from "./internal/TransactableOps.js";
@@ -97,6 +98,25 @@ const composeUniqueSentinel = (schema, entityType, constraintName, constraintDef
97
98
  *
98
99
  * @internal
99
100
  */
101
+ /**
102
+ * @internal Build a key-form normaliser for an arbitrary entity-like value.
103
+ *
104
+ * `makeImpl` closes over its own `keyForm`; this is the escape hatch for the
105
+ * two places that compose against a DIFFERENT entity's index (`cascade`) or run
106
+ * outside `makeImpl` entirely (`bind`'s `reembed` / `history`). Same rule, same
107
+ * `CompositeCodec` — just resolved per target.
108
+ */
109
+ const keyFormFor = (target, record) => {
110
+ const t = target;
111
+ const source = t.schemas?.inputSchema ?? t.model;
112
+ if (source === undefined)
113
+ return record;
114
+ const form = makeCompositeKeyForm(source, (attr, value) => {
115
+ throw new Error(`[EDD-9050] Composite "${attr}" could not be put into its key form: ` +
116
+ `${JSON.stringify(String(value))} resolves under neither encode nor decode->encode.`);
117
+ });
118
+ return toCompositeKeyRecord(form, record);
119
+ };
100
120
  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)),
101
121
  // On fallback failure, surface the original encode error — its
102
122
  // message is keyed on the caller's input shape, which is what the
@@ -281,14 +301,85 @@ const makeImpl = (config, precomputedData) => {
281
301
  // ---------------------------------------------------------------------------
282
302
  // Helpers
283
303
  // ---------------------------------------------------------------------------
304
+ // Read-path composite encoder — see `internal/CompositeCodec.ts`. The write
305
+ // path composes keys from the encoded record (`put` encodes, THEN composes);
306
+ // key and query inputs arrive as decoded model values, so they are encoded
307
+ // through the model's own field codecs before composition. Encoding is
308
+ // idempotent via the `decode -> encode` fallback, so callers that already
309
+ // hold an encoded record are unaffected.
310
+ // Source: `inputSchema`, the EXACT schema `put` encodes through before
311
+ // `composeAllKeys`. The raw model is not equivalent — entity derivation
312
+ // substitutes date/Redacted fields with their wire transforms, so reading
313
+ // fields off the model would miss encodings the write path applies.
314
+ const compositeKeyForm = makeCompositeKeyForm(schemas.inputSchema, (attr, value) => {
315
+ throw new Error(`[EDD-9050] Composite "${attr}" on entity "${entityType}" could not be encoded to its ` +
316
+ `stored form. The attribute's schema carries an encoding transformation, so the ` +
317
+ `stored key holds the ENCODED value, but ${JSON.stringify(String(value))} encodes ` +
318
+ `under neither encode nor decode->encode. Supply a value of the attribute's own type.`);
319
+ });
320
+ /**
321
+ * PUBLIC key boundary. Converts a caller-supplied key record from the model's
322
+ * **Type** side — the one convention the whole API takes — into the ENCODED
323
+ * form key composition expects.
324
+ *
325
+ * `put` composes keys from the encoded record, so the stored key holds the
326
+ * wire value. Key-taking operations used to run a plain `decode`, which reads
327
+ * the Encoded side and then handed the *decoded* result to the composer. On a
328
+ * transformed composite neither spelling worked: `get({ txn: 420n })` failed
329
+ * validation and `get({ txn: "420" })` silently returned `ItemNotFound` for a
330
+ * row that exists. Encoding the Type side fixes the one spelling that should
331
+ * have worked all along, and matches what the query path takes.
332
+ *
333
+ * Encode-ONLY, deliberately: no `decode -> encode` fallback. The Encoded side
334
+ * is not a public input, so `{ txn: "420" }` fails here — loudly, with the
335
+ * attribute named by the schema error — rather than composing a key by a
336
+ * second convention. Internal callers that legitimately hold encoded records
337
+ * do not come through here; see `composePrimaryKey`.
338
+ */
339
+ const encodeKey = (key, operation) => Schema.encodeUnknownEffect(schemas.keySchema)(key).pipe(Effect.map((encoded) => encoded), Effect.mapError((cause) => new ValidationError({ entityType, operation, cause })));
340
+ /**
341
+ * Synchronous `encodeKey`, for the query builders (`history`, `versions`,
342
+ * `deleted.list`) that compose a partition key outside an Effect. Same
343
+ * Type-side-only rule; throws the schema's own error, which names the
344
+ * offending attribute — these builders have always thrown on a bad key.
345
+ */
346
+ const encodeKeySync = (key) => Schema.encodeUnknownSync(schemas.keySchema)(key);
347
+ /**
348
+ * INTERNAL key composition. Takes an **already-encoded** record — either the
349
+ * output of `encodeKey` on the public path, or an item read back from
350
+ * DynamoDB on the retain / restore / soft-delete paths, which are wire-shaped
351
+ * by construction.
352
+ *
353
+ * The `encodeCompositeRecord` pass is a normalisation, not a second input
354
+ * convention: composite encoding is idempotent, so an already-encoded value
355
+ * round-trips to itself. It exists so that `keySchema` (built from raw model
356
+ * fields) and `inputSchema` (built from derivation-substituted fields) cannot
357
+ * disagree for a composite whose wire transform is added by derivation —
358
+ * `Schema.Date` being the case that matters.
359
+ */
360
+ /**
361
+ * THE key-form normaliser. Every record handed to `KeyComposer` in this
362
+ * module goes through it, so the write path, the update path, the lifecycle
363
+ * paths and the read path cannot disagree about how a composite is spelled
364
+ * in a key.
365
+ *
366
+ * Skipping it on ONE site is enough to corrupt data: `composeAllKeys` (put)
367
+ * normalised while `composeGsiKeysForUpdatePolicyAware` (update) did not, so
368
+ * an `update()` rewrote a padded `gsi1pk` to its unpadded form and evicted
369
+ * the row from its own GSI. `test/KeyFormInvariant.test.ts` scans this file
370
+ * and fails if a `KeyComposer.compose*` call takes a record that did not come
371
+ * through `keyForm(...)`.
372
+ */
373
+ const keyForm = (record) => toCompositeKeyRecord(compositeKeyForm, record);
284
374
  const composePrimaryKey = (record) => {
285
375
  const primary = config.indexes.primary;
376
+ const recordKeyForm = keyForm(record);
286
377
  return {
287
- [primary.pk.field]: KeyComposer.composePk(schema, entityType, primary, record),
288
- [primary.sk.field]: KeyComposer.composeSk(schema, entityType, entityVersion, primary, record),
378
+ [primary.pk.field]: KeyComposer.composePk(schema, entityType, primary, recordKeyForm),
379
+ [primary.sk.field]: KeyComposer.composeSk(schema, entityType, entityVersion, primary, recordKeyForm),
289
380
  };
290
381
  };
291
- const composeAllKeys = (record) => KeyComposer.composeAllKeys(schema, entityType, entityVersion, allIndexes, record);
382
+ const composeAllKeys = (record) => KeyComposer.composeAllKeys(schema, entityType, entityVersion, allIndexes, keyForm(record));
292
383
  /**
293
384
  * Fill the auto-generated id field on the raw `put` input when configured and
294
385
  * absent. Sources a UUID from the bundled `Crypto` service. Caller-supplied
@@ -392,6 +483,18 @@ const makeImpl = (config, precomputedData) => {
392
483
  return false;
393
484
  return config.softDelete.preserveUnique === true;
394
485
  };
486
+ /**
487
+ * The uniqueness sentinel's guard. `attribute_not_exists` must name the
488
+ * entity's CONFIGURED partition-key attribute — a literal `pk` is simply
489
+ * absent from an entity declaring `pk: { field: "PK" }`, so the condition is
490
+ * vacuously true and the constraint is silently unenforced (#111). Routed
491
+ * through `ExpressionAttributeNames` because the field name is user-supplied
492
+ * and may be a reserved word.
493
+ */
494
+ const sentinelGuard = () => ({
495
+ ConditionExpression: "attribute_not_exists(#sentinel_pk)",
496
+ ExpressionAttributeNames: { "#sentinel_pk": config.indexes.primary.pk.field },
497
+ });
395
498
  /** Collect all key field names (pk, sk, gsi*pk, gsi*sk) */
396
499
  const gsiKeyFields = () => {
397
500
  const fields = [];
@@ -494,7 +597,7 @@ const makeImpl = (config, precomputedData) => {
494
597
  const removes = [];
495
598
  for (const [logicalName, definition] of vectorIndexEntries) {
496
599
  const shouldEmbed = embedFor === "all" || embedFor.has(logicalName);
497
- const partition = KeyComposer.tryComposeVectorPartition(schema, entityType, definition, record);
600
+ const partition = KeyComposer.tryComposeVectorPartition(schema, entityType, definition, keyForm(record));
498
601
  if (partition === undefined) {
499
602
  // No partition value ⇒ the item cannot be in the index at all. Drop
500
603
  // both attributes so a previously-indexed item leaves the index
@@ -587,7 +690,7 @@ const makeImpl = (config, precomputedData) => {
587
690
  * description" produces the embedding of what actually remains — or drops the
588
691
  * item out of the index when nothing does.
589
692
  */
590
- const computeVectorUpdateAttributes = (decodedKey, marshalledKey, tableName, updatePayload, uState) => Effect.gen(function* () {
693
+ const computeVectorUpdateAttributes = (encodedKey, marshalledKey, tableName, updatePayload, uState) => Effect.gen(function* () {
591
694
  if (!hasVectorIndexes)
592
695
  return { sets: {}, removes: [] };
593
696
  const withVectors = uState.withVectors;
@@ -601,7 +704,7 @@ const makeImpl = (config, precomputedData) => {
601
704
  // Base record: key composites plus whatever the payload supplies. Enough
602
705
  // to compose partition values (their composites are normally primary-key
603
706
  // members) without any extra read.
604
- let merged = { ...decodedKey, ...updatePayload };
707
+ let merged = { ...encodedKey, ...updatePayload };
605
708
  const needsFullSource = needing.some(([logicalName, definition]) => withVectors?.[logicalName] === undefined &&
606
709
  definition.sourceFields.some((field) => !(field in merged)));
607
710
  if (needsFullSource) {
@@ -624,7 +727,7 @@ const makeImpl = (config, precomputedData) => {
624
727
  // Partition value: idempotent and cheap, so recompose whenever the
625
728
  // merged record can supply it (same reasoning as the PK-composites-only
626
729
  // GSI shape in §7).
627
- const partition = KeyComposer.tryComposeVectorPartition(schema, entityType, definition, merged);
730
+ const partition = KeyComposer.tryComposeVectorPartition(schema, entityType, definition, keyForm(merged));
628
731
  if (partition === undefined) {
629
732
  // Only drop the partition when THIS writer invalidated it. Otherwise
630
733
  // the composite simply wasn't in scope for this call and dropping it
@@ -682,6 +785,73 @@ const makeImpl = (config, precomputedData) => {
682
785
  return snapshot;
683
786
  };
684
787
  // ---------------------------------------------------------------------------
788
+ // Multi-item write support for the transact compile path (#113)
789
+ // ---------------------------------------------------------------------------
790
+ /**
791
+ * Which parts of this entity's write contract need more than one item.
792
+ * Drives both the `put` expansion and the `delete` / `Batch.write` rejections.
793
+ */
794
+ const multiItemWriteFeatures = () => {
795
+ const features = [];
796
+ if (config.unique != null && Object.keys(config.unique).length > 0)
797
+ features.push("unique");
798
+ if (isRetainEnabled())
799
+ features.push("retain");
800
+ if (isSoftDeleteEnabled())
801
+ features.push("softDelete");
802
+ return features;
803
+ };
804
+ /**
805
+ * Side items for a `put`, derived purely from the payload being written —
806
+ * see the `_buildPutSideItems` doc on the Entity interface for why the delete
807
+ * side has no counterpart.
808
+ *
809
+ * Mirrors the emission in `put`'s own transact path so the two cannot drift:
810
+ * one sentinel per satisfiable constraint (sparse — a constraint whose fields
811
+ * are unset produces none), then the v1 snapshot.
812
+ */
813
+ const buildPutSideItems = (item, now, ttlAttrName) => {
814
+ const out = [];
815
+ const pkField = config.indexes.primary.pk.field;
816
+ const skField = config.indexes.primary.sk.field;
817
+ if (config.unique != null) {
818
+ for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
819
+ const sentinel = composeUniqueSentinel(schema, entityType, constraintName, constraintDef, item);
820
+ // Sparse: a constraint whose composing fields are unset never had a
821
+ // sentinel, so writing one would reserve `undefined` for everybody.
822
+ if (!sentinel)
823
+ continue;
824
+ const sentinelItem = {
825
+ [pkField]: sentinel.key.pk,
826
+ [skField]: sentinel.key.sk,
827
+ __edd_e__: `${entityType}._unique.${constraintName}`,
828
+ _entity_pk: item[pkField],
829
+ _entity_sk: item[skField],
830
+ };
831
+ const uniqueTtl = resolveUniqueTtl(constraintDef);
832
+ if (uniqueTtl !== undefined) {
833
+ sentinelItem[ttlAttrName] = DateTime.toEpochSeconds(now) + normalizeTtlSeconds(uniqueTtl);
834
+ }
835
+ out.push({
836
+ kind: "sentinel",
837
+ constraintName,
838
+ fields: sentinel.fieldsRecord,
839
+ item: sentinelItem,
840
+ // The guard IS the constraint — without it the sentinel would happily
841
+ // overwrite another row's reservation and enforce nothing.
842
+ guard: sentinelGuard(),
843
+ });
844
+ }
845
+ }
846
+ if (isRetainEnabled()) {
847
+ out.push({
848
+ kind: "snapshot",
849
+ item: buildSnapshotItem(item, 1, pkField, skField, ttlAttrName, now),
850
+ });
851
+ }
852
+ return out;
853
+ };
854
+ // ---------------------------------------------------------------------------
685
855
  // Ref hydration
686
856
  // ---------------------------------------------------------------------------
687
857
  /**
@@ -820,7 +990,7 @@ const makeImpl = (config, precomputedData) => {
820
990
  const { name: targetTableName } = yield* target._tableTag;
821
991
  // Compose GSI PK for the source entity's ID
822
992
  const targetSchema = target._schema;
823
- const gsiPkValue = KeyComposer.composePk(targetSchema, target.entityType, cascadeIdx.indexDef, { [matchingRef.idFieldName]: sourceIdValue });
993
+ const gsiPkValue = KeyComposer.composePk(targetSchema, target.entityType, cascadeIdx.indexDef, keyFormFor(target, { [matchingRef.idFieldName]: sourceIdValue }));
824
994
  // Build query parameters
825
995
  const gsiPkField = cascadeIdx.indexDef.pk.field;
826
996
  const queryNames = {
@@ -1062,7 +1232,7 @@ const makeImpl = (config, precomputedData) => {
1062
1232
  Put: {
1063
1233
  TableName: tableName,
1064
1234
  Item: toAttributeMap(sentinelItem),
1065
- ConditionExpression: "attribute_not_exists(pk)",
1235
+ ...sentinelGuard(),
1066
1236
  },
1067
1237
  });
1068
1238
  }
@@ -1155,7 +1325,7 @@ const makeImpl = (config, precomputedData) => {
1155
1325
  const pkField = config.indexes.primary.pk.field;
1156
1326
  const skField = config.indexes.primary.sk.field;
1157
1327
  const op = put(input);
1158
- return new EntityPutImpl(op._builder, op._entity, op._input, { attributeNotExists: [pkField, skField] }, op._withVectors);
1328
+ return new EntityPutImpl(op._builder, op._entity, op._input, { attributeNotExists: [pkField, skField] }, op._withVectors, "create");
1159
1329
  };
1160
1330
  // ---------------------------------------------------------------------------
1161
1331
  // patch operation — update + attribute_exists condition
@@ -1171,14 +1341,10 @@ const makeImpl = (config, precomputedData) => {
1171
1341
  const get = (key) => new EntityGetImpl((mode, opts) => Effect.gen(function* () {
1172
1342
  const client = yield* DynamoClient;
1173
1343
  const { name: tableName } = yield* tableTag;
1174
- // Decode key
1175
- const decodedKey = yield* Schema.decodeUnknownEffect(schemas.keySchema)(key).pipe(Effect.mapError((cause) => new ValidationError({
1176
- entityType,
1177
- operation: "get.decode",
1178
- cause,
1179
- })));
1344
+ // Caller key: Type side in, ENCODED out (see `encodeKey`).
1345
+ const encodedKey = yield* encodeKey(key, "get.decode");
1180
1346
  // Compose primary key
1181
- const primaryKey = composePrimaryKey(decodedKey);
1347
+ const primaryKey = composePrimaryKey(encodedKey);
1182
1348
  const marshalledKey = toAttributeMap(primaryKey);
1183
1349
  // Build ProjectionExpression if projection attributes provided
1184
1350
  let projectionExpression;
@@ -1198,7 +1364,7 @@ const makeImpl = (config, precomputedData) => {
1198
1364
  : undefined,
1199
1365
  });
1200
1366
  if (!result.Item) {
1201
- return yield* new ItemNotFound({ entityType, key: decodedKey });
1367
+ return yield* new ItemNotFound({ entityType, key: encodedKey });
1202
1368
  }
1203
1369
  // Raw mode: return unmarshalled record without schema decode
1204
1370
  if (mode === "raw") {
@@ -1222,12 +1388,8 @@ const makeImpl = (config, precomputedData) => {
1222
1388
  // Clock-backed time source for the updatedAt timestamp + retain snapshot TTL.
1223
1389
  const now = yield* DateTime.now;
1224
1390
  yield* checkWithVectorNames(uState.withVectors, "update");
1225
- // Decode key
1226
- const decodedKey = yield* Schema.decodeUnknownEffect(schemas.keySchema)(key).pipe(Effect.mapError((cause) => new ValidationError({
1227
- entityType,
1228
- operation: "update.decodeKey",
1229
- cause,
1230
- })));
1391
+ // Caller key: Type side in, ENCODED out (see `encodeKey`).
1392
+ const encodedKey = yield* encodeKey(key, "update.decodeKey");
1231
1393
  // Encode update payload → wire form (see `put` for the strategy).
1232
1394
  const encodedUpdates = yield* encodeOrDecodeEncode(schemas.updateSchema, updates ?? {}, entityType, "update");
1233
1395
  // Hydrate refs in updates: for any ${field}Id present, fetch and embed
@@ -1241,7 +1403,7 @@ const makeImpl = (config, precomputedData) => {
1241
1403
  }
1242
1404
  }
1243
1405
  // Compose primary key
1244
- const primaryKey = composePrimaryKey(decodedKey);
1406
+ const primaryKey = composePrimaryKey(encodedKey);
1245
1407
  const marshalledKey = toAttributeMap(primaryKey);
1246
1408
  // Detect whether the update touches any unique constraint fields
1247
1409
  const hasUniqueConstraints = config.unique != null && Object.keys(config.unique).length > 0;
@@ -1266,7 +1428,7 @@ const makeImpl = (config, precomputedData) => {
1266
1428
  Key: marshalledKey,
1267
1429
  });
1268
1430
  if (!currentResult.Item) {
1269
- return yield* new ItemNotFound({ entityType, key: decodedKey });
1431
+ return yield* new ItemNotFound({ entityType, key: encodedKey });
1270
1432
  }
1271
1433
  const currentRaw = fromAttributeMap(currentResult.Item);
1272
1434
  const currentVersion = systemFields.version
@@ -1276,7 +1438,7 @@ const makeImpl = (config, precomputedData) => {
1276
1438
  if (evExpected !== undefined && currentVersion !== evExpected) {
1277
1439
  return yield* new OptimisticLockError({
1278
1440
  entityType,
1279
- key: decodedKey,
1441
+ key: encodedKey,
1280
1442
  expectedVersion: evExpected,
1281
1443
  actualVersion: currentVersion,
1282
1444
  });
@@ -1408,7 +1570,7 @@ const makeImpl = (config, precomputedData) => {
1408
1570
  // No try/catch needed — EDD-9024 was deprecated in v1.7.1 and
1409
1571
  // the composer no longer throws.
1410
1572
  const retainRemovedSet = uState.remove ? new Set(uState.remove) : undefined;
1411
- const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, allIndexes, hydratedUpdates, newItem, retainRemovedSet === undefined ? {} : { removedSet: retainRemovedSet });
1573
+ const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, allIndexes, keyForm(hydratedUpdates), keyForm(newItem), retainRemovedSet === undefined ? {} : { removedSet: retainRemovedSet });
1412
1574
  for (const [field, value] of Object.entries(gsiUpdate.sets)) {
1413
1575
  newItem[field] = value;
1414
1576
  }
@@ -1432,7 +1594,7 @@ const makeImpl = (config, precomputedData) => {
1432
1594
  // `newItem` from the read-then-write merge above.
1433
1595
  if (addressed.has(indexDef.pk.field) || addressed.has(indexDef.sk.field))
1434
1596
  continue;
1435
- const keys = KeyComposer.tryComposeIndexKeys(schema, entityType, entityVersion, indexDef, newItem);
1597
+ const keys = KeyComposer.tryComposeIndexKeys(schema, entityType, entityVersion, indexDef, keyForm(newItem));
1436
1598
  if (keys) {
1437
1599
  Object.assign(newItem, keys);
1438
1600
  }
@@ -1564,7 +1726,7 @@ const makeImpl = (config, precomputedData) => {
1564
1726
  _entity_pk: primaryKey[config.indexes.primary.pk.field],
1565
1727
  _entity_sk: primaryKey[config.indexes.primary.sk.field],
1566
1728
  }),
1567
- ConditionExpression: "attribute_not_exists(pk)",
1729
+ ...sentinelGuard(),
1568
1730
  },
1569
1731
  });
1570
1732
  }
@@ -1575,6 +1737,24 @@ const makeImpl = (config, precomputedData) => {
1575
1737
  TransactItems: transactItems.map((t) => "Put" in t ? { Put: t.Put } : { Delete: t.Delete }),
1576
1738
  })
1577
1739
  .pipe(Effect.mapError((err) => {
1740
+ // The main item's ConditionExpression carries the version CAS
1741
+ // (when the entity is versioned) ANDed with any user condition.
1742
+ // DynamoDB does not say which half rejected, so attribute the
1743
+ // failure to the version CAS whenever one is present, and to
1744
+ // the user condition only when it is the sole predicate —
1745
+ // otherwise an unversioned entity's `.condition()` rejection
1746
+ // would surface as a nonsensical OptimisticLockError.
1747
+ const mainItemRejection = () => {
1748
+ if (!systemFields.version && userCond) {
1749
+ return new ConditionalCheckFailed({ entityType, key: encodedKey });
1750
+ }
1751
+ return new OptimisticLockError({
1752
+ entityType,
1753
+ key: encodedKey,
1754
+ expectedVersion: currentVersion,
1755
+ actualVersion: -1,
1756
+ });
1757
+ };
1578
1758
  if (isAwsTransactionCancelled(err.cause)) {
1579
1759
  const reasons = err.cause.CancellationReasons;
1580
1760
  if (reasons) {
@@ -1590,22 +1770,12 @@ const makeImpl = (config, precomputedData) => {
1590
1770
  }
1591
1771
  // Index 0 is the main item — version conflict or user condition
1592
1772
  if (reasons[0]?.Code === "ConditionalCheckFailed") {
1593
- return new OptimisticLockError({
1594
- entityType,
1595
- key: decodedKey,
1596
- expectedVersion: currentVersion,
1597
- actualVersion: -1,
1598
- });
1773
+ return mainItemRejection();
1599
1774
  }
1600
1775
  }
1601
1776
  }
1602
1777
  if (isAwsConditionalCheckFailed(err.cause)) {
1603
- return new OptimisticLockError({
1604
- entityType,
1605
- key: decodedKey,
1606
- expectedVersion: currentVersion,
1607
- actualVersion: -1,
1608
- });
1778
+ return mainItemRejection();
1609
1779
  }
1610
1780
  return err;
1611
1781
  }));
@@ -1614,7 +1784,7 @@ const makeImpl = (config, precomputedData) => {
1614
1784
  if (uState.cascade) {
1615
1785
  const domainData = { ...retainDecoded };
1616
1786
  const sourceId = sourceIdentifierField != null
1617
- ? decodedKey[sourceIdentifierField]
1787
+ ? encodedKey[sourceIdentifierField]
1618
1788
  : undefined;
1619
1789
  if (sourceId != null) {
1620
1790
  yield* executeCascade(uState.cascade, domainData, String(sourceId));
@@ -1772,7 +1942,7 @@ const makeImpl = (config, precomputedData) => {
1772
1942
  // No try/catch — EDD-9024 was deprecated in v1.7.1 and the
1773
1943
  // composer no longer throws. Hole patterns collapse into the
1774
1944
  // unified can't-compose rule.
1775
- const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, allIndexes, hydratedUpdates, decodedKey, removedSet === undefined ? {} : { removedSet });
1945
+ const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, allIndexes, keyForm(hydratedUpdates), keyForm(encodedKey), removedSet === undefined ? {} : { removedSet });
1776
1946
  for (const [field, value] of Object.entries(gsiUpdate.sets)) {
1777
1947
  const nameKey = `#u${counter}`;
1778
1948
  const valKey = `:u${counter}`;
@@ -1791,7 +1961,7 @@ const makeImpl = (config, precomputedData) => {
1791
1961
  // touched a `source.fields` member or supplied `.withVector(...)`.
1792
1962
  // See `DESIGN.md §14 Write path`.
1793
1963
  if (hasVectorIndexes) {
1794
- const vectorWrite = yield* computeVectorUpdateAttributes(decodedKey, marshalledKey, tableName, hydratedUpdates, uState);
1964
+ const vectorWrite = yield* computeVectorUpdateAttributes(encodedKey, marshalledKey, tableName, hydratedUpdates, uState);
1795
1965
  for (const [field, value] of Object.entries(vectorWrite.sets)) {
1796
1966
  const nameKey = `#u${counter}`;
1797
1967
  const valKey = `:u${counter}`;
@@ -2030,7 +2200,7 @@ const makeImpl = (config, precomputedData) => {
2030
2200
  if (evExpected !== undefined) {
2031
2201
  return new OptimisticLockError({
2032
2202
  entityType,
2033
- key: decodedKey,
2203
+ key: encodedKey,
2034
2204
  expectedVersion: evExpected,
2035
2205
  actualVersion: -1,
2036
2206
  });
@@ -2038,14 +2208,14 @@ const makeImpl = (config, precomputedData) => {
2038
2208
  if (userCond) {
2039
2209
  return new ConditionalCheckFailed({
2040
2210
  entityType,
2041
- key: decodedKey,
2211
+ key: encodedKey,
2042
2212
  });
2043
2213
  }
2044
2214
  }
2045
2215
  return err;
2046
2216
  }));
2047
2217
  if (!result.Attributes) {
2048
- return yield* new ItemNotFound({ entityType, key: decodedKey });
2218
+ return yield* new ItemNotFound({ entityType, key: encodedKey });
2049
2219
  }
2050
2220
  const raw = fromAttributeMap(result.Attributes);
2051
2221
  const decoded = yield* decodeAs(raw, result.Attributes, mode);
@@ -2053,7 +2223,7 @@ const makeImpl = (config, precomputedData) => {
2053
2223
  if (uState.cascade) {
2054
2224
  const domainData = { ...decoded };
2055
2225
  const sourceId = sourceIdentifierField != null
2056
- ? decodedKey[sourceIdentifierField]
2226
+ ? encodedKey[sourceIdentifierField]
2057
2227
  : undefined;
2058
2228
  if (sourceId != null) {
2059
2229
  yield* executeCascade(uState.cascade, domainData, String(sourceId));
@@ -2069,14 +2239,10 @@ const makeImpl = (config, precomputedData) => {
2069
2239
  const tc = yield* tableTag;
2070
2240
  const tableName = tc.name;
2071
2241
  const ttlAttrName = resolveTtlAttributeName(tc);
2072
- // Decode key
2073
- const decodedKey = yield* Schema.decodeUnknownEffect(schemas.keySchema)(key).pipe(Effect.mapError((cause) => new ValidationError({
2074
- entityType,
2075
- operation: "delete.decode",
2076
- cause,
2077
- })));
2242
+ // Caller key: Type side in, ENCODED out (see `encodeKey`).
2243
+ const encodedKey = yield* encodeKey(key, "delete.decode");
2078
2244
  // Compose primary key
2079
- const primaryKey = composePrimaryKey(decodedKey);
2245
+ const primaryKey = composePrimaryKey(encodedKey);
2080
2246
  const marshalledKey = toAttributeMap(primaryKey);
2081
2247
  const primary = config.indexes.primary;
2082
2248
  const hasUniqueConstraints = config.unique != null && Object.keys(config.unique).length > 0;
@@ -2084,6 +2250,26 @@ const makeImpl = (config, precomputedData) => {
2084
2250
  const userCondition = opts.condition
2085
2251
  ? compileCondition(opts.condition, resolveDbName)
2086
2252
  : undefined;
2253
+ /**
2254
+ * Map a rejected user condition on either transaction delete path
2255
+ * (soft-delete, unique-constraint) to `ConditionalCheckFailed`.
2256
+ *
2257
+ * On both paths the current-item Delete sits at index 0 and is the
2258
+ * only item carrying a ConditionExpression — the sentinel Deletes and
2259
+ * the tombstone/snapshot Puts are unconditional — so an index-0
2260
+ * cancellation can only be the user's condition. Every other reason
2261
+ * falls through as the raw `DynamoClientError`.
2262
+ */
2263
+ const mapDeleteConditionFailure = (err) => {
2264
+ if (!userCondition)
2265
+ return err;
2266
+ const cancelledAtMainItem = isAwsTransactionCancelled(err.cause) &&
2267
+ err.cause.CancellationReasons?.[0]?.Code === "ConditionalCheckFailed";
2268
+ if (cancelledAtMainItem || isAwsConditionalCheckFailed(err.cause)) {
2269
+ return new ConditionalCheckFailed({ entityType, key: encodedKey });
2270
+ }
2271
+ return err;
2272
+ };
2087
2273
  if (isSoftDeleteEnabled()) {
2088
2274
  // --- Soft delete path ---
2089
2275
  // Read current item
@@ -2092,7 +2278,7 @@ const makeImpl = (config, precomputedData) => {
2092
2278
  Key: marshalledKey,
2093
2279
  });
2094
2280
  if (!result.Item) {
2095
- return yield* Effect.fail(new ItemNotFound({ entityType, key: decodedKey }));
2281
+ return yield* Effect.fail(new ItemNotFound({ entityType, key: encodedKey }));
2096
2282
  }
2097
2283
  const raw = fromAttributeMap(result.Item);
2098
2284
  // Clock-backed time source; `now` is the ISO timestamp used for the
@@ -2126,10 +2312,24 @@ const makeImpl = (config, precomputedData) => {
2126
2312
  deletedItem[ttlAttrName] = DateTime.toEpochSeconds(dtNow) + normalizeTtlSeconds(sdTtl);
2127
2313
  }
2128
2314
  const transactItems = [];
2129
- // Delete current entity item
2130
- transactItems.push({
2131
- Delete: { TableName: tableName, Key: marshalledKey },
2132
- });
2315
+ // Delete current entity item — index 0, and the ONLY item in this
2316
+ // transaction carrying a ConditionExpression, so a cancellation
2317
+ // naming index 0 is unambiguously the user's condition. The guard
2318
+ // rides the transaction rather than being pre-checked against the
2319
+ // item read above: a client-side check would leave a race window
2320
+ // between the read and the write.
2321
+ const currentDelete = {
2322
+ TableName: tableName,
2323
+ Key: marshalledKey,
2324
+ };
2325
+ if (userCondition) {
2326
+ currentDelete.ConditionExpression = userCondition.expression;
2327
+ currentDelete.ExpressionAttributeNames = userCondition.names;
2328
+ if (Object.keys(userCondition.values).length > 0) {
2329
+ currentDelete.ExpressionAttributeValues = userCondition.values;
2330
+ }
2331
+ }
2332
+ transactItems.push({ Delete: currentDelete });
2133
2333
  // Put soft-deleted item
2134
2334
  transactItems.push({
2135
2335
  Put: {
@@ -2169,9 +2369,11 @@ const makeImpl = (config, precomputedData) => {
2169
2369
  }
2170
2370
  }
2171
2371
  yield* checkTransactionLimit(entityType, "delete", transactItems);
2172
- yield* client.transactWriteItems({
2372
+ yield* client
2373
+ .transactWriteItems({
2173
2374
  TransactItems: transactItems,
2174
- });
2375
+ })
2376
+ .pipe(Effect.mapError(mapDeleteConditionFailure));
2175
2377
  }
2176
2378
  else if (hasUniqueConstraints) {
2177
2379
  // --- Hard delete with unique constraints ---
@@ -2181,14 +2383,25 @@ const makeImpl = (config, precomputedData) => {
2181
2383
  Key: marshalledKey,
2182
2384
  });
2183
2385
  if (!result.Item) {
2184
- return yield* Effect.fail(new ItemNotFound({ entityType, key: decodedKey }));
2386
+ return yield* Effect.fail(new ItemNotFound({ entityType, key: encodedKey }));
2185
2387
  }
2186
2388
  const raw = fromAttributeMap(result.Item);
2187
2389
  const transactItems = [];
2188
- // Delete entity item
2189
- transactItems.push({
2190
- Delete: { TableName: tableName, Key: marshalledKey },
2191
- });
2390
+ // Delete entity item — index 0, and the only conditioned item (the
2391
+ // sentinel Deletes below are unconditional), so an index-0
2392
+ // cancellation is unambiguously the user's condition.
2393
+ const entityDelete = {
2394
+ TableName: tableName,
2395
+ Key: marshalledKey,
2396
+ };
2397
+ if (userCondition) {
2398
+ entityDelete.ConditionExpression = userCondition.expression;
2399
+ entityDelete.ExpressionAttributeNames = userCondition.names;
2400
+ if (Object.keys(userCondition.values).length > 0) {
2401
+ entityDelete.ExpressionAttributeValues = userCondition.values;
2402
+ }
2403
+ }
2404
+ transactItems.push({ Delete: entityDelete });
2192
2405
  // Delete sentinels (sparse — skip constraints whose fields were unset
2193
2406
  // on the live item; no sentinel was ever written for those)
2194
2407
  for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
@@ -2206,9 +2419,11 @@ const makeImpl = (config, precomputedData) => {
2206
2419
  });
2207
2420
  }
2208
2421
  yield* checkTransactionLimit(entityType, "delete", transactItems);
2209
- yield* client.transactWriteItems({
2422
+ yield* client
2423
+ .transactWriteItems({
2210
2424
  TransactItems: transactItems.map((t) => ({ Delete: t.Delete })),
2211
- });
2425
+ })
2426
+ .pipe(Effect.mapError(mapDeleteConditionFailure));
2212
2427
  }
2213
2428
  else {
2214
2429
  // Simple delete
@@ -2226,15 +2441,7 @@ const makeImpl = (config, precomputedData) => {
2226
2441
  if (opts.returnValues) {
2227
2442
  deleteInput.ReturnValues = returnValuesMap[opts.returnValues];
2228
2443
  }
2229
- yield* client.deleteItem(deleteInput).pipe(Effect.mapError((err) => {
2230
- if (opts.condition && isAwsConditionalCheckFailed(err.cause)) {
2231
- return new ConditionalCheckFailed({
2232
- entityType,
2233
- key: decodedKey,
2234
- });
2235
- }
2236
- return err;
2237
- }));
2444
+ yield* client.deleteItem(deleteInput).pipe(Effect.mapError(mapDeleteConditionFailure));
2238
2445
  }
2239
2446
  }), self, key);
2240
2447
  // ---------------------------------------------------------------------------
@@ -2456,7 +2663,10 @@ const makeImpl = (config, precomputedData) => {
2456
2663
  }
2457
2664
  const raw = fromAttributeMap(result.Attributes);
2458
2665
  return yield* decodeAs(raw, result.Attributes, mode);
2459
- }), self, input);
2666
+ }), self, input, undefined, undefined,
2667
+ // Tagged so the transact/batch compilers can refuse it: this op is an
2668
+ // UpdateItem with `if_not_exists`, not a Put (#100).
2669
+ "upsert");
2460
2670
  /**
2461
2671
  * @internal Compare stored vs attempted `orderBy` to decide whether the CAS
2462
2672
  * predicate `attribute_not_exists(pk) OR stored < attempted` failed.
@@ -2614,8 +2824,9 @@ const makeImpl = (config, precomputedData) => {
2614
2824
  }
2615
2825
  // Compose current-item primary key (pk + sk derived from PK/SK composites)
2616
2826
  const primary = config.indexes.primary;
2617
- const pkValue = KeyComposer.composePk(schema, entityType, primary, encoded);
2618
- const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary, encoded);
2827
+ const appendKeyForm = keyForm(encoded);
2828
+ const pkValue = KeyComposer.composePk(schema, entityType, primary, appendKeyForm);
2829
+ const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary, appendKeyForm);
2619
2830
  const marshalledKey = toAttributeMap({
2620
2831
  [primary.pk.field]: pkValue,
2621
2832
  [primary.sk.field]: currentSk,
@@ -2660,7 +2871,7 @@ const makeImpl = (config, precomputedData) => {
2660
2871
  // touched on every write and the structural rule composes them.
2661
2872
  // No try/catch — EDD-9024 was deprecated in v1.7.1 and the composer
2662
2873
  // no longer throws.
2663
- const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, allIndexes, encoded, encoded, removedSet !== undefined ? { removedSet } : undefined);
2874
+ const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, allIndexes, keyForm(encoded), keyForm(encoded), removedSet !== undefined ? { removedSet } : undefined);
2664
2875
  for (const [field, value] of Object.entries(gsiUpdate.sets)) {
2665
2876
  const nameKey = `#a${counter}`;
2666
2877
  const valKey = `:a${counter}`;
@@ -2885,10 +3096,10 @@ const makeImpl = (config, precomputedData) => {
2885
3096
  if (!timeSeriesConfig) {
2886
3097
  throw new Error(`[EDD-9010] Entity "${entityType}": .history() requires timeSeries config on the entity.`);
2887
3098
  }
2888
- const decodedKey = Schema.decodeUnknownSync(schemas.keySchema)(key);
3099
+ const encodedKey = keyForm(encodeKeySync(key));
2889
3100
  const primary = config.indexes.primary;
2890
- const pkValue = KeyComposer.composePk(schema, entityType, primary, decodedKey);
2891
- const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary, decodedKey);
3101
+ const pkValue = KeyComposer.composePk(schema, entityType, primary, keyForm(encodedKey));
3102
+ const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary, keyForm(encodedKey));
2892
3103
  const prefix = KeyComposer.composeEventSkPrefix(currentSk, schema.casing);
2893
3104
  const decodeHistory = (raw) => {
2894
3105
  // Sparse fields are aggregate state, not event state — event items DO
@@ -2912,6 +3123,7 @@ const makeImpl = (config, precomputedData) => {
2912
3123
  entityTypes: [entityType],
2913
3124
  decoder: (raw) => decodeHistory(raw),
2914
3125
  resolveTableName: tableTag.useSync((tc) => tc.name),
3126
+ keyFields: [primary.pk.field, primary.sk.field],
2915
3127
  }).pipe(Query.where({ beginsWith: prefix }));
2916
3128
  };
2917
3129
  // ---------------------------------------------------------------------------
@@ -2922,9 +3134,12 @@ const makeImpl = (config, precomputedData) => {
2922
3134
  if (indexName === "primary")
2923
3135
  continue;
2924
3136
  const indexDef = config.indexes[indexName];
2925
- queryNamespace[indexName] = (pk) => {
2926
- const pkValue = KeyComposer.composePk(schema, entityType, indexDef, pk);
2927
- const hasSkComposites = indexDef.sk.composite.some((attr) => pk[attr] !== undefined);
3137
+ queryNamespace[indexName] = (rawPk) => {
3138
+ // Normalise composites to their key form before composing — same rule
3139
+ // and same function the write path uses (see `CompositeCodec`).
3140
+ const pkKeyForm = keyForm(rawPk);
3141
+ const pkValue = KeyComposer.composePk(schema, entityType, indexDef, pkKeyForm);
3142
+ const hasSkComposites = indexDef.sk.composite.some((attr) => pkKeyForm[attr] !== undefined);
2928
3143
  const query = Query.make({
2929
3144
  tableName: "",
2930
3145
  indexName: indexDef.index,
@@ -2934,9 +3149,19 @@ const makeImpl = (config, precomputedData) => {
2934
3149
  entityTypes: [entityType],
2935
3150
  decoder: (raw) => decodeRecord(raw),
2936
3151
  resolveTableName: tableTag.useSync((tc) => tc.name),
3152
+ keyFields: [
3153
+ indexDef.pk.field,
3154
+ indexDef.sk.field,
3155
+ config.indexes.primary.pk.field,
3156
+ config.indexes.primary.sk.field,
3157
+ ],
2937
3158
  });
2938
3159
  if (hasSkComposites) {
2939
- const skPrefix = KeyComposer.composeSortKeyPrefix(schema, entityType, entityVersion, indexDef, pk);
3160
+ // `composeSortKeyBeginsWith`, not `composeSortKeyPrefix` — the operand
3161
+ // must terminate on a segment boundary when composites remain, or it
3162
+ // matches sibling values that merely start with the supplied one
3163
+ // (`status_done` also matching `status_done_archived`, issue #115).
3164
+ const skPrefix = KeyComposer.composeSortKeyBeginsWith(schema, entityType, entityVersion, indexDef, pkKeyForm);
2940
3165
  return Query.where(query, { beginsWith: skPrefix });
2941
3166
  }
2942
3167
  return query;
@@ -2951,6 +3176,7 @@ const makeImpl = (config, precomputedData) => {
2951
3176
  entityTypes: [entityType],
2952
3177
  decoder: (raw) => decodeRecord(raw),
2953
3178
  resolveTableName: tableTag.useSync((tc) => tc.name),
3179
+ keyFields: [config.indexes.primary.pk.field, config.indexes.primary.sk.field],
2954
3180
  });
2955
3181
  // ---------------------------------------------------------------------------
2956
3182
  // getVersion operation
@@ -2958,15 +3184,11 @@ const makeImpl = (config, precomputedData) => {
2958
3184
  const getVersion = (key, versionNumber) => new EntityGetImpl((mode, opts) => Effect.gen(function* () {
2959
3185
  const client = yield* DynamoClient;
2960
3186
  const { name: tableName } = yield* tableTag;
2961
- // Decode key
2962
- const decodedKey = yield* Schema.decodeUnknownEffect(schemas.keySchema)(key).pipe(Effect.mapError((cause) => new ValidationError({
2963
- entityType,
2964
- operation: "getVersion.decode",
2965
- cause,
2966
- })));
3187
+ // Caller key: Type side in, ENCODED out (see `encodeKey`).
3188
+ const encodedKey = yield* encodeKey(key, "getVersion.decode");
2967
3189
  // Compose PK + version SK
2968
3190
  const primary = config.indexes.primary;
2969
- const pkValue = KeyComposer.composePk(schema, entityType, primary, decodedKey);
3191
+ const pkValue = KeyComposer.composePk(schema, entityType, primary, keyForm(encodedKey));
2970
3192
  const versionSk = DynamoSchema.composeVersionKey(schema, entityType, versionNumber);
2971
3193
  const marshalledKey = toAttributeMap({
2972
3194
  [primary.pk.field]: pkValue,
@@ -2978,7 +3200,7 @@ const makeImpl = (config, precomputedData) => {
2978
3200
  ConsistentRead: opts.consistentRead || undefined,
2979
3201
  });
2980
3202
  if (!result.Item) {
2981
- return yield* new ItemNotFound({ entityType, key: decodedKey });
3203
+ return yield* new ItemNotFound({ entityType, key: encodedKey });
2982
3204
  }
2983
3205
  const raw = fromAttributeMap(result.Item);
2984
3206
  return yield* decodeAs(raw, result.Item, mode);
@@ -2987,9 +3209,9 @@ const makeImpl = (config, precomputedData) => {
2987
3209
  // versions operation
2988
3210
  // ---------------------------------------------------------------------------
2989
3211
  const versions = (key) => {
2990
- const decodedKey = Schema.decodeUnknownSync(schemas.keySchema)(key);
3212
+ const encodedKey = keyForm(encodeKeySync(key));
2991
3213
  const primary = config.indexes.primary;
2992
- const pkValue = KeyComposer.composePk(schema, entityType, primary, decodedKey);
3214
+ const pkValue = KeyComposer.composePk(schema, entityType, primary, keyForm(encodedKey));
2993
3215
  const versionPrefix = DynamoSchema.composeVersionKeyPrefix(schema, entityType);
2994
3216
  return Query.make({
2995
3217
  tableName: "",
@@ -3000,6 +3222,7 @@ const makeImpl = (config, precomputedData) => {
3000
3222
  entityTypes: [entityType],
3001
3223
  decoder: (raw) => decodeRecord(raw),
3002
3224
  resolveTableName: tableTag.useSync((tc) => tc.name),
3225
+ keyFields: [primary.pk.field, primary.sk.field],
3003
3226
  }).pipe(Query.where({ beginsWith: versionPrefix }));
3004
3227
  };
3005
3228
  // ---------------------------------------------------------------------------
@@ -3008,15 +3231,11 @@ const makeImpl = (config, precomputedData) => {
3008
3231
  const deletedGet = (key) => new EntityGetImpl((mode, _opts) => Effect.gen(function* () {
3009
3232
  const client = yield* DynamoClient;
3010
3233
  const { name: tableName } = yield* tableTag;
3011
- // Decode key
3012
- const decodedKey = yield* Schema.decodeUnknownEffect(schemas.keySchema)(key).pipe(Effect.mapError((cause) => new ValidationError({
3013
- entityType,
3014
- operation: "deleted.get.decode",
3015
- cause,
3016
- })));
3234
+ // Caller key: Type side in, ENCODED out (see `encodeKey`).
3235
+ const encodedKey = yield* encodeKey(key, "deleted.get.decode");
3017
3236
  // Query for soft-deleted item using begins_with on deleted prefix
3018
3237
  const primary = config.indexes.primary;
3019
- const pkValue = KeyComposer.composePk(schema, entityType, primary, decodedKey);
3238
+ const pkValue = KeyComposer.composePk(schema, entityType, primary, keyForm(encodedKey));
3020
3239
  const deletedPrefix = DynamoSchema.composeDeletedKeyPrefix(schema, entityType);
3021
3240
  const result = yield* client.query({
3022
3241
  TableName: tableName,
@@ -3033,7 +3252,7 @@ const makeImpl = (config, precomputedData) => {
3033
3252
  ScanIndexForward: false,
3034
3253
  });
3035
3254
  if (!result.Items || result.Items.length === 0) {
3036
- return yield* new ItemNotFound({ entityType, key: decodedKey });
3255
+ return yield* new ItemNotFound({ entityType, key: encodedKey });
3037
3256
  }
3038
3257
  const raw = fromAttributeMap(result.Items[0]);
3039
3258
  // Soft-deleted items have GSI keys stripped — always use deletedRecordSchema
@@ -3053,9 +3272,9 @@ const makeImpl = (config, precomputedData) => {
3053
3272
  })));
3054
3273
  }), self, key);
3055
3274
  const deletedList = (key) => {
3056
- const decodedKey = Schema.decodeUnknownSync(schemas.keySchema)(key);
3275
+ const encodedKey = keyForm(encodeKeySync(key));
3057
3276
  const primary = config.indexes.primary;
3058
- const pkValue = KeyComposer.composePk(schema, entityType, primary, decodedKey);
3277
+ const pkValue = KeyComposer.composePk(schema, entityType, primary, keyForm(encodedKey));
3059
3278
  const deletedPrefix = DynamoSchema.composeDeletedKeyPrefix(schema, entityType);
3060
3279
  const decodeDeleted = (raw) => {
3061
3280
  // Sparse fields are domain data and are preserved across soft-delete.
@@ -3076,6 +3295,7 @@ const makeImpl = (config, precomputedData) => {
3076
3295
  entityTypes: [],
3077
3296
  decoder: (raw) => decodeDeleted(raw),
3078
3297
  resolveTableName: tableTag.useSync((tc) => tc.name),
3298
+ keyFields: [primary.pk.field, primary.sk.field],
3079
3299
  }).pipe(Query.where({ beginsWith: deletedPrefix }));
3080
3300
  };
3081
3301
  // ---------------------------------------------------------------------------
@@ -3088,15 +3308,11 @@ const makeImpl = (config, precomputedData) => {
3088
3308
  const ttlAttrName = resolveTtlAttributeName(tc);
3089
3309
  // Clock-backed time source for the restored updatedAt + retain snapshot.
3090
3310
  const now = yield* DateTime.now;
3091
- // Decode key
3092
- const decodedKey = yield* Schema.decodeUnknownEffect(schemas.keySchema)(key).pipe(Effect.mapError((cause) => new ValidationError({
3093
- entityType,
3094
- operation: "restore.decode",
3095
- cause,
3096
- })));
3311
+ // Caller key: Type side in, ENCODED out (see `encodeKey`).
3312
+ const encodedKey = yield* encodeKey(key, "restore.decode");
3097
3313
  // Query for the soft-deleted item
3098
3314
  const primary = config.indexes.primary;
3099
- const pkValue = KeyComposer.composePk(schema, entityType, primary, decodedKey);
3315
+ const pkValue = KeyComposer.composePk(schema, entityType, primary, keyForm(encodedKey));
3100
3316
  const deletedPrefix = DynamoSchema.composeDeletedKeyPrefix(schema, entityType);
3101
3317
  const queryResult = yield* client.query({
3102
3318
  TableName: tableName,
@@ -3113,7 +3329,7 @@ const makeImpl = (config, precomputedData) => {
3113
3329
  ScanIndexForward: false,
3114
3330
  });
3115
3331
  if (!queryResult.Items || queryResult.Items.length === 0) {
3116
- return yield* new ItemNotFound({ entityType, key: decodedKey });
3332
+ return yield* new ItemNotFound({ entityType, key: encodedKey });
3117
3333
  }
3118
3334
  const deletedRaw = fromAttributeMap(queryResult.Items[0]);
3119
3335
  const deletedMarshalledKey = toAttributeMap({
@@ -3148,7 +3364,7 @@ const makeImpl = (config, precomputedData) => {
3148
3364
  }
3149
3365
  if (restoredItem[definition.vectorField] === undefined)
3150
3366
  continue;
3151
- const partition = KeyComposer.tryComposeVectorPartition(schema, entityType, definition, restoredItem);
3367
+ const partition = KeyComposer.tryComposeVectorPartition(schema, entityType, definition, keyForm(restoredItem));
3152
3368
  if (partition !== undefined)
3153
3369
  restoredItem[definition.partitionField] = partition;
3154
3370
  }
@@ -3240,17 +3456,29 @@ const makeImpl = (config, precomputedData) => {
3240
3456
  // ---------------------------------------------------------------------------
3241
3457
  // purge operation
3242
3458
  // ---------------------------------------------------------------------------
3243
- const purge = (key) => new EntityDeleteImpl((_opts) => Effect.gen(function* () {
3459
+ const purge = (key) => new EntityDeleteImpl((opts) => Effect.gen(function* () {
3460
+ // `purge` is partition-wide — it queries every item under the key and
3461
+ // batch-deletes in chunks, so there is no single item for a
3462
+ // ConditionExpression to guard and no way to make one atomic across
3463
+ // the batches. `.condition()` is structurally available because
3464
+ // `purge` returns an `EntityDelete`; refuse it loudly rather than
3465
+ // accept a guard that would never be sent.
3466
+ if (opts.condition) {
3467
+ return yield* new ValidationError({
3468
+ entityType,
3469
+ operation: "purge.condition",
3470
+ cause: "[EDD-9047] .condition() is not supported on purge() — purge deletes every item " +
3471
+ "in the partition across multiple batched writes, so a per-item ConditionExpression " +
3472
+ "cannot be applied atomically. Guard the individual delete with " +
3473
+ "delete(key).condition(...) instead.",
3474
+ });
3475
+ }
3244
3476
  const client = yield* DynamoClient;
3245
3477
  const { name: tableName } = yield* tableTag;
3246
- // Decode key
3247
- const decodedKey = yield* Schema.decodeUnknownEffect(schemas.keySchema)(key).pipe(Effect.mapError((cause) => new ValidationError({
3248
- entityType,
3249
- operation: "purge.decode",
3250
- cause,
3251
- })));
3478
+ // Caller key: Type side in, ENCODED out (see `encodeKey`).
3479
+ const encodedKey = yield* encodeKey(key, "purge.decode");
3252
3480
  const primary = config.indexes.primary;
3253
- const pkValue = KeyComposer.composePk(schema, entityType, primary, decodedKey);
3481
+ const pkValue = KeyComposer.composePk(schema, entityType, primary, keyForm(encodedKey));
3254
3482
  // Query ALL items in this partition (current + versions + deleted)
3255
3483
  // Use ProjectionExpression to only get keys
3256
3484
  const allItems = [];
@@ -3274,7 +3502,7 @@ const makeImpl = (config, precomputedData) => {
3274
3502
  // Get current or deleted item to extract field values for sentinel cleanup
3275
3503
  const marshalledKey = toAttributeMap({
3276
3504
  [primary.pk.field]: pkValue,
3277
- [primary.sk.field]: KeyComposer.composeSk(schema, entityType, entityVersion, primary, decodedKey),
3505
+ [primary.sk.field]: KeyComposer.composeSk(schema, entityType, entityVersion, primary, keyForm(encodedKey)),
3278
3506
  });
3279
3507
  const mainResult = yield* client.getItem({
3280
3508
  TableName: tableName,
@@ -3407,6 +3635,10 @@ const makeImpl = (config, precomputedData) => {
3407
3635
  /** @internal Entity schema version baked into composed keys. */
3408
3636
  _entityVersion: entityVersion,
3409
3637
  _serializeSparseFields: serializeSparseFields,
3638
+ _keyForm: keyForm,
3639
+ _renameToDynamo: renameToDynamo,
3640
+ _buildPutSideItems: buildPutSideItems,
3641
+ _multiItemWriteFeatures: multiItemWriteFeatures(),
3410
3642
  _attachPrototype: attachPrototype,
3411
3643
  _configure: (injectedSchema, injectedTableTag) => {
3412
3644
  schema = injectedSchema;
@@ -3549,12 +3781,13 @@ export const bind = (entity) => Effect.gen(function* () {
3549
3781
  const decoded = (yield* entity._decodeRecord(raw));
3550
3782
  // Snapshots / tombstones live in the same partition under a
3551
3783
  // rewritten SK — their SK cannot be recomposed from the record.
3552
- const liveSk = KeyComposer.composeSk(boundSchema, entity.entityType, boundEntityVersion, primary, decoded);
3784
+ const decodedKeyForm = keyFormFor(entity, decoded);
3785
+ const liveSk = KeyComposer.composeSk(boundSchema, entity.entityType, boundEntityVersion, primary, decodedKeyForm);
3553
3786
  if (storedSk !== liveSk)
3554
3787
  return false;
3555
3788
  const attributes = {};
3556
3789
  for (const [logicalName, definition] of vectorDefs) {
3557
- const partition = KeyComposer.tryComposeVectorPartition(boundSchema, entity.entityType, definition, decoded);
3790
+ const partition = KeyComposer.tryComposeVectorPartition(boundSchema, entity.entityType, definition, decodedKeyForm);
3558
3791
  if (partition === undefined)
3559
3792
  continue;
3560
3793
  const text = deriveSourceText(definition, decoded);
@@ -3674,9 +3907,15 @@ export const bind = (entity) => Effect.gen(function* () {
3674
3907
  const composeSkCondition = (cond) => {
3675
3908
  // We need the `currentSk` + prefix. The user's `key` lets us derive
3676
3909
  // `currentSk` via the same primary SK composer used by `history()`.
3677
- const currentSk = KeyComposer.composeSk(schemaRef, entityTypeRef, 1, primary, key);
3910
+ // `key` is the caller's Type-side key; the stored SK was composed
3911
+ // from the encoded form, so normalise it the same way
3912
+ // `composePrimaryKey` does before recomposing.
3913
+ const currentSk = KeyComposer.composeSk(schemaRef, entityTypeRef, 1, primary, keyFormFor(entityInternals, key));
3678
3914
  const prefix = KeyComposer.composeEventSkPrefix(currentSk, schemaRef.casing);
3679
- const rewrite = (v) => `${prefix}${DynamoSchema.applyCasing(KeyComposer.serializeValue(v), schemaRef.casing)}`;
3915
+ // Key form first: the event SK is composed from the key form of the
3916
+ // orderBy value (`Entity.append` composes from `serialisedInput`), so
3917
+ // a transformed orderBy composite must take the same route here.
3918
+ const rewrite = (v) => `${prefix}${DynamoSchema.applyCasing(KeyComposer.serializeValue(orderBy === undefined ? v : keyFormFor(entityInternals, { [orderBy]: v })[orderBy]), schemaRef.casing)}`;
3680
3919
  if ("eq" in cond)
3681
3920
  return { eq: rewrite(cond.eq) };
3682
3921
  if ("lt" in cond)
@@ -3717,25 +3956,55 @@ const isEntityDelete = (op) => EntityDeleteTypeId in op;
3717
3956
  /**
3718
3957
  * Extract transactable metadata from an Entity operation intermediate.
3719
3958
  * Returns undefined if the value is not a recognized entity operation.
3959
+ *
3960
+ * Bound-CRUD builders (`db.entities.X.put(...)`, `.create(...)`,
3961
+ * `.delete(...)`, …) are unwrapped to the `EntityOp` / `EntityDelete` they wrap.
3962
+ * That unwrapping is what lets entities authored with the pure, AWS-free
3963
+ * `@effect-dynamodb/schema` `Entity.make` take part in `Batch.write`,
3964
+ * `Transaction.transactWrite`, and `EventStore.append({ additionalItems })` — a
3965
+ * pure definition carries no operations, so the bound builder is the only write
3966
+ * descriptor its author can ever hold (#100).
3720
3967
  */
3721
3968
  export const extractTransactable = (op) => {
3722
3969
  if (op == null || typeof op !== "object")
3723
3970
  return undefined;
3971
+ // Unwrap a bound-CRUD builder to the intermediate it wraps. Combinators on the
3972
+ // builder (`.condition()`, `.set()`, …) are applied to that inner op, so the
3973
+ // unwrapped value carries the full request.
3974
+ const target = isBoundOp(op) ? op._op : op;
3975
+ if (target == null || typeof target !== "object")
3976
+ return undefined;
3724
3977
  // Check for EntityOp intermediates (get, put, update)
3725
- if (isEntityOp(op)) {
3726
- if (op._opType === "get") {
3727
- return { opType: "get", entity: op._entity, key: op._key };
3728
- }
3729
- if (op._opType === "put") {
3730
- return { opType: "put", entity: op._entity, input: op._input };
3978
+ if (isEntityOp(target)) {
3979
+ if (target._opType === "get") {
3980
+ return { opType: "get", entity: target._entity, key: target._key };
3981
+ }
3982
+ if (target._opType === "put") {
3983
+ return {
3984
+ opType: "put",
3985
+ entity: target._entity,
3986
+ input: target._input,
3987
+ condition: target._condition,
3988
+ putKind: target._putKind ?? "put",
3989
+ };
3731
3990
  }
3732
- if (op._opType === "update") {
3733
- return { opType: "update", entity: op._entity, key: op._key };
3991
+ if (target._opType === "update") {
3992
+ return {
3993
+ opType: "update",
3994
+ entity: target._entity,
3995
+ key: target._key,
3996
+ condition: target._updateState?.condition,
3997
+ };
3734
3998
  }
3735
3999
  }
3736
4000
  // Check for EntityDelete intermediate
3737
- if (isEntityDelete(op)) {
3738
- return { opType: "delete", entity: op._entity, key: op._key };
4001
+ if (isEntityDelete(target)) {
4002
+ return {
4003
+ opType: "delete",
4004
+ entity: target._entity,
4005
+ key: target._key,
4006
+ condition: target._condition,
4007
+ };
3739
4008
  }
3740
4009
  return undefined;
3741
4010
  };