effect-dynamodb 1.14.0 → 1.16.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 +17 -1
- package/dist/Aggregate.d.ts.map +1 -1
- package/dist/Aggregate.js +413 -82
- package/dist/Aggregate.js.map +1 -1
- package/dist/Batch.d.ts +10 -2
- package/dist/Batch.d.ts.map +1 -1
- package/dist/Batch.js +45 -6
- package/dist/Batch.js.map +1 -1
- package/dist/Collection.d.ts +0 -7
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +84 -2
- package/dist/Collection.js.map +1 -1
- package/dist/DynamoClient.d.ts +3 -1
- package/dist/DynamoClient.d.ts.map +1 -1
- package/dist/DynamoClient.js +253 -26
- package/dist/DynamoClient.js.map +1 -1
- package/dist/Entity.d.ts +87 -6
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +413 -144
- package/dist/Entity.js.map +1 -1
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +49 -17
- package/dist/EventStore.js.map +1 -1
- package/dist/Query.d.ts +56 -5
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +206 -30
- package/dist/Query.js.map +1 -1
- package/dist/Transaction.d.ts +2 -2
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +29 -9
- package/dist/Transaction.js.map +1 -1
- package/dist/internal/BoundCrud.d.ts +76 -9
- package/dist/internal/BoundCrud.d.ts.map +1 -1
- package/dist/internal/BoundCrud.js +22 -1
- package/dist/internal/BoundCrud.js.map +1 -1
- package/dist/internal/BoundQuery.d.ts +75 -13
- package/dist/internal/BoundQuery.d.ts.map +1 -1
- package/dist/internal/BoundQuery.js +85 -11
- package/dist/internal/BoundQuery.js.map +1 -1
- package/dist/internal/BoundVectorQuery.d.ts +9 -0
- package/dist/internal/BoundVectorQuery.d.ts.map +1 -1
- package/dist/internal/BoundVectorQuery.js +3 -2
- package/dist/internal/BoundVectorQuery.js.map +1 -1
- package/dist/internal/EntityCombinators.d.ts +31 -5
- package/dist/internal/EntityCombinators.d.ts.map +1 -1
- package/dist/internal/EntityCombinators.js +7 -7
- package/dist/internal/EntityCombinators.js.map +1 -1
- package/dist/internal/EntityOps.d.ts +14 -1
- package/dist/internal/EntityOps.d.ts.map +1 -1
- package/dist/internal/EntityOps.js +4 -1
- package/dist/internal/EntityOps.js.map +1 -1
- package/dist/internal/TransactWriteOps.d.ts +44 -5
- package/dist/internal/TransactWriteOps.d.ts.map +1 -1
- package/dist/internal/TransactWriteOps.js +98 -20
- package/dist/internal/TransactWriteOps.js.map +1 -1
- package/dist/internal/TransactableOps.d.ts +69 -1
- package/dist/internal/TransactableOps.d.ts.map +1 -1
- package/dist/internal/TransactableOps.js +138 -4
- package/dist/internal/TransactableOps.js.map +1 -1
- 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,
|
|
288
|
-
[primary.sk.field]: KeyComposer.composeSk(schema, entityType, entityVersion, primary,
|
|
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 = (
|
|
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 = { ...
|
|
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
|
-
|
|
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
|
-
//
|
|
1175
|
-
const
|
|
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(
|
|
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:
|
|
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
|
-
//
|
|
1226
|
-
const
|
|
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(
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
?
|
|
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,
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
?
|
|
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
|
-
//
|
|
2073
|
-
const
|
|
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(
|
|
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:
|
|
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
|
-
|
|
2131
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
2190
|
-
|
|
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
|
|
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(
|
|
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
|
|
2618
|
-
const
|
|
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
|
|
3099
|
+
const encodedKey = keyForm(encodeKeySync(key));
|
|
2889
3100
|
const primary = config.indexes.primary;
|
|
2890
|
-
const pkValue = KeyComposer.composePk(schema, entityType, primary,
|
|
2891
|
-
const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary,
|
|
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] = (
|
|
2926
|
-
|
|
2927
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2962
|
-
const
|
|
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,
|
|
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:
|
|
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
|
|
3212
|
+
const encodedKey = keyForm(encodeKeySync(key));
|
|
2991
3213
|
const primary = config.indexes.primary;
|
|
2992
|
-
const pkValue = KeyComposer.composePk(schema, entityType, primary,
|
|
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
|
-
//
|
|
3012
|
-
const
|
|
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,
|
|
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:
|
|
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
|
|
3275
|
+
const encodedKey = keyForm(encodeKeySync(key));
|
|
3057
3276
|
const primary = config.indexes.primary;
|
|
3058
|
-
const pkValue = KeyComposer.composePk(schema, entityType, primary,
|
|
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
|
-
//
|
|
3092
|
-
const
|
|
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,
|
|
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:
|
|
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((
|
|
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
|
-
//
|
|
3247
|
-
const
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
3726
|
-
if (
|
|
3727
|
-
return { opType: "get", entity:
|
|
3728
|
-
}
|
|
3729
|
-
if (
|
|
3730
|
-
return {
|
|
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 (
|
|
3733
|
-
return {
|
|
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(
|
|
3738
|
-
return {
|
|
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
|
};
|