effect-dynamodb 0.1.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/LICENSE +21 -0
- package/README.md +69 -0
- package/dist/Aggregate.d.ts +210 -0
- package/dist/Aggregate.d.ts.map +1 -0
- package/dist/Aggregate.js +1169 -0
- package/dist/Aggregate.js.map +1 -0
- package/dist/Batch.d.ts +62 -0
- package/dist/Batch.d.ts.map +1 -0
- package/dist/Batch.js +218 -0
- package/dist/Batch.js.map +1 -0
- package/dist/Collection.d.ts +58 -0
- package/dist/Collection.d.ts.map +1 -0
- package/dist/Collection.js +195 -0
- package/dist/Collection.js.map +1 -0
- package/dist/Collections.d.ts +121 -0
- package/dist/Collections.d.ts.map +1 -0
- package/dist/Collections.js +121 -0
- package/dist/Collections.js.map +1 -0
- package/dist/DynamoClient.d.ts +317 -0
- package/dist/DynamoClient.d.ts.map +1 -0
- package/dist/DynamoClient.js +653 -0
- package/dist/DynamoClient.js.map +1 -0
- package/dist/DynamoModel.d.ts +278 -0
- package/dist/DynamoModel.d.ts.map +1 -0
- package/dist/DynamoModel.js +453 -0
- package/dist/DynamoModel.js.map +1 -0
- package/dist/DynamoSchema.d.ts +137 -0
- package/dist/DynamoSchema.d.ts.map +1 -0
- package/dist/DynamoSchema.js +188 -0
- package/dist/DynamoSchema.js.map +1 -0
- package/dist/Entity.d.ts +628 -0
- package/dist/Entity.d.ts.map +1 -0
- package/dist/Entity.js +2371 -0
- package/dist/Entity.js.map +1 -0
- package/dist/Errors.d.ts +227 -0
- package/dist/Errors.d.ts.map +1 -0
- package/dist/Errors.js +93 -0
- package/dist/Errors.js.map +1 -0
- package/dist/EventStore.d.ts +208 -0
- package/dist/EventStore.d.ts.map +1 -0
- package/dist/EventStore.js +333 -0
- package/dist/EventStore.js.map +1 -0
- package/dist/Expression.d.ts +50 -0
- package/dist/Expression.d.ts.map +1 -0
- package/dist/Expression.js +170 -0
- package/dist/Expression.js.map +1 -0
- package/dist/KeyComposer.d.ts +125 -0
- package/dist/KeyComposer.d.ts.map +1 -0
- package/dist/KeyComposer.js +257 -0
- package/dist/KeyComposer.js.map +1 -0
- package/dist/Marshaller.d.ts +30 -0
- package/dist/Marshaller.d.ts.map +1 -0
- package/dist/Marshaller.js +115 -0
- package/dist/Marshaller.js.map +1 -0
- package/dist/Projection.d.ts +28 -0
- package/dist/Projection.d.ts.map +1 -0
- package/dist/Projection.js +39 -0
- package/dist/Projection.js.map +1 -0
- package/dist/Query.d.ts +214 -0
- package/dist/Query.d.ts.map +1 -0
- package/dist/Query.js +397 -0
- package/dist/Query.js.map +1 -0
- package/dist/Table.d.ts +178 -0
- package/dist/Table.d.ts.map +1 -0
- package/dist/Table.js +211 -0
- package/dist/Table.js.map +1 -0
- package/dist/Transaction.d.ts +79 -0
- package/dist/Transaction.d.ts.map +1 -0
- package/dist/Transaction.js +215 -0
- package/dist/Transaction.js.map +1 -0
- package/dist/_AggregateCursor.d.ts +44 -0
- package/dist/_AggregateCursor.d.ts.map +1 -0
- package/dist/_AggregateCursor.js +18 -0
- package/dist/_AggregateCursor.js.map +1 -0
- package/dist/_AggregateEdges.d.ts +77 -0
- package/dist/_AggregateEdges.d.ts.map +1 -0
- package/dist/_AggregateEdges.js +41 -0
- package/dist/_AggregateEdges.js.map +1 -0
- package/dist/_AggregateSchemas.d.ts +69 -0
- package/dist/_AggregateSchemas.d.ts.map +1 -0
- package/dist/_AggregateSchemas.js +194 -0
- package/dist/_AggregateSchemas.js.map +1 -0
- package/dist/_AggregateTypes.d.ts +94 -0
- package/dist/_AggregateTypes.d.ts.map +1 -0
- package/dist/_AggregateTypes.js +7 -0
- package/dist/_AggregateTypes.js.map +1 -0
- package/dist/_EntityCombinators.d.ts +155 -0
- package/dist/_EntityCombinators.d.ts.map +1 -0
- package/dist/_EntityCombinators.js +158 -0
- package/dist/_EntityCombinators.js.map +1 -0
- package/dist/_EntityConfig.d.ts +100 -0
- package/dist/_EntityConfig.d.ts.map +1 -0
- package/dist/_EntityConfig.js +7 -0
- package/dist/_EntityConfig.js.map +1 -0
- package/dist/_EntityOps.d.ts +205 -0
- package/dist/_EntityOps.d.ts.map +1 -0
- package/dist/_EntityOps.js +137 -0
- package/dist/_EntityOps.js.map +1 -0
- package/dist/_EntitySchemas.d.ts +57 -0
- package/dist/_EntitySchemas.d.ts.map +1 -0
- package/dist/_EntitySchemas.js +298 -0
- package/dist/_EntitySchemas.js.map +1 -0
- package/dist/_EntityTypes.d.ts +81 -0
- package/dist/_EntityTypes.d.ts.map +1 -0
- package/dist/_EntityTypes.js +7 -0
- package/dist/_EntityTypes.js.map +1 -0
- package/dist/_sparse_check.d.ts +2 -0
- package/dist/_sparse_check.d.ts.map +1 -0
- package/dist/_sparse_check.js +193 -0
- package/dist/_sparse_check.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/AggregateCursor.d.ts +44 -0
- package/dist/internal/AggregateCursor.d.ts.map +1 -0
- package/dist/internal/AggregateCursor.js +18 -0
- package/dist/internal/AggregateCursor.js.map +1 -0
- package/dist/internal/AggregateEdges.d.ts +77 -0
- package/dist/internal/AggregateEdges.d.ts.map +1 -0
- package/dist/internal/AggregateEdges.js +41 -0
- package/dist/internal/AggregateEdges.js.map +1 -0
- package/dist/internal/AggregateSchemas.d.ts +69 -0
- package/dist/internal/AggregateSchemas.d.ts.map +1 -0
- package/dist/internal/AggregateSchemas.js +193 -0
- package/dist/internal/AggregateSchemas.js.map +1 -0
- package/dist/internal/AggregateTypes.d.ts +94 -0
- package/dist/internal/AggregateTypes.d.ts.map +1 -0
- package/dist/internal/AggregateTypes.js +7 -0
- package/dist/internal/AggregateTypes.js.map +1 -0
- package/dist/internal/BoundQuery.d.ts +107 -0
- package/dist/internal/BoundQuery.d.ts.map +1 -0
- package/dist/internal/BoundQuery.js +111 -0
- package/dist/internal/BoundQuery.js.map +1 -0
- package/dist/internal/EntityCombinators.d.ts +173 -0
- package/dist/internal/EntityCombinators.d.ts.map +1 -0
- package/dist/internal/EntityCombinators.js +183 -0
- package/dist/internal/EntityCombinators.js.map +1 -0
- package/dist/internal/EntityConfig.d.ts +100 -0
- package/dist/internal/EntityConfig.d.ts.map +1 -0
- package/dist/internal/EntityConfig.js +7 -0
- package/dist/internal/EntityConfig.js.map +1 -0
- package/dist/internal/EntityOps.d.ts +268 -0
- package/dist/internal/EntityOps.d.ts.map +1 -0
- package/dist/internal/EntityOps.js +153 -0
- package/dist/internal/EntityOps.js.map +1 -0
- package/dist/internal/EntitySchemas.d.ts +57 -0
- package/dist/internal/EntitySchemas.d.ts.map +1 -0
- package/dist/internal/EntitySchemas.js +299 -0
- package/dist/internal/EntitySchemas.js.map +1 -0
- package/dist/internal/EntityTypes.d.ts +128 -0
- package/dist/internal/EntityTypes.d.ts.map +1 -0
- package/dist/internal/EntityTypes.js +7 -0
- package/dist/internal/EntityTypes.js.map +1 -0
- package/dist/internal/Expr.d.ts +141 -0
- package/dist/internal/Expr.d.ts.map +1 -0
- package/dist/internal/Expr.js +332 -0
- package/dist/internal/Expr.js.map +1 -0
- package/dist/internal/PathBuilder.d.ts +88 -0
- package/dist/internal/PathBuilder.d.ts.map +1 -0
- package/dist/internal/PathBuilder.js +100 -0
- package/dist/internal/PathBuilder.js.map +1 -0
- package/dist/internal/TransactableOps.d.ts +25 -0
- package/dist/internal/TransactableOps.d.ts.map +1 -0
- package/dist/internal/TransactableOps.js +58 -0
- package/dist/internal/TransactableOps.js.map +1 -0
- package/dist/internal/sparse_check.d.ts +2 -0
- package/dist/internal/sparse_check.d.ts.map +1 -0
- package/dist/internal/sparse_check.js +196 -0
- package/dist/internal/sparse_check.js.map +1 -0
- package/package.json +71 -0
package/dist/Entity.js
ADDED
|
@@ -0,0 +1,2371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity — Binds a domain model to a Table with ElectroDB-style index definitions,
|
|
3
|
+
* system field configuration, unique constraints, and CRUD operations.
|
|
4
|
+
*
|
|
5
|
+
* Entity.make() returns a definition + operations namespace. The Entity object
|
|
6
|
+
* carries derived schemas for 7 type extractors (Model, Record, Input, Update,
|
|
7
|
+
* Key, Item, Marshalled).
|
|
8
|
+
*/
|
|
9
|
+
import { Duration, Effect, Schema, Stream } from "effect";
|
|
10
|
+
import { DynamoClient } from "./DynamoClient.js";
|
|
11
|
+
import { getEncoding, getIdentifierField, isConfiguredModel, isHidden, isRef, isRefField, } from "./DynamoModel.js";
|
|
12
|
+
import * as DynamoSchema from "./DynamoSchema.js";
|
|
13
|
+
import { CascadePartialFailure, ConditionalCheckFailed, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled, OptimisticLockError, RefNotFound, TransactionOverflow, UniqueConstraintViolation, ValidationError, } from "./Errors.js";
|
|
14
|
+
import { BoundQueryImpl } from "./internal/BoundQuery.js";
|
|
15
|
+
import { compileExpr, createConditionOps, isExpr, parseShorthand, parseSimpleShorthand, } from "./internal/Expr.js";
|
|
16
|
+
import { compilePath, createPathBuilder } from "./internal/PathBuilder.js";
|
|
17
|
+
import * as KeyComposer from "./KeyComposer.js";
|
|
18
|
+
import { normalizeGsiConfig } from "./KeyComposer.js";
|
|
19
|
+
import { deserializeDateFromDynamo, fromAttributeMap, serializeDateForDynamo, toAttributeMap, toAttributeValue, } from "./Marshaller.js";
|
|
20
|
+
import * as Query from "./Query.js";
|
|
21
|
+
import { filterExpr, selectPaths } from "./Query.js";
|
|
22
|
+
export { EntityDeleteImpl, EntityDeleteTypeId, EntityGetImpl, EntityOpTypeId, EntityPutImpl, EntityUpdateImpl, EntityUpdateTypeId, emptyUpdateState, returnValuesMap, } from "./internal/EntityOps.js";
|
|
23
|
+
import { EntityDeleteImpl, EntityDeleteTypeId, EntityGetImpl, EntityOpTypeId, EntityPutImpl, EntityUpdateImpl, emptyUpdateState, returnValuesMap, } from "./internal/EntityOps.js";
|
|
24
|
+
import * as Projection from "./Projection.js";
|
|
25
|
+
export { add, append, asItem, asModel, asNative, asRecord, cascade, consistentRead, deleteFromSet, expectedVersion, pathAdd, pathAppend, pathDelete, pathIfNotExists, pathPrepend, pathRemove, pathSet, pathSubtract, project, remove, returnValues, set, subtract, } from "./internal/EntityCombinators.js";
|
|
26
|
+
import { asModel, condition as conditionCombinator, expectedVersion, set, } from "./internal/EntityCombinators.js";
|
|
27
|
+
import { allCompositeAttributes, allKeyFieldNames, buildDerivedSchemas, getFields, inferDefaultEncoding, primaryKeyComposites, resolveSystemFields, resolveUniqueFields, } from "./internal/EntitySchemas.js";
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Transaction limit pre-check (100-item DynamoDB limit)
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
/**
|
|
32
|
+
* Compile a condition that may be either an Expr ADT node or a ConditionInput object.
|
|
33
|
+
* Routes both paths through the Expr ADT compiler for a single compilation backend.
|
|
34
|
+
* Returns undefined if the condition is undefined.
|
|
35
|
+
*/
|
|
36
|
+
const compileCondition = (cond, resolveDbNameFn) => {
|
|
37
|
+
if (cond === undefined)
|
|
38
|
+
return undefined;
|
|
39
|
+
const expr = isExpr(cond) ? cond : parseShorthand(cond);
|
|
40
|
+
return compileExpr(expr, resolveDbNameFn);
|
|
41
|
+
};
|
|
42
|
+
const TRANSACTION_LIMIT = 100;
|
|
43
|
+
const checkTransactionLimit = (entityType, operation, items) => items.length > TRANSACTION_LIMIT
|
|
44
|
+
? Effect.fail(new TransactionOverflow({
|
|
45
|
+
entityType,
|
|
46
|
+
operation,
|
|
47
|
+
itemCount: items.length,
|
|
48
|
+
limit: TRANSACTION_LIMIT,
|
|
49
|
+
}))
|
|
50
|
+
: Effect.void;
|
|
51
|
+
/**
|
|
52
|
+
* Create an Entity definition.
|
|
53
|
+
*
|
|
54
|
+
* `primaryKey` defines the table's primary key. `indexes` optionally defines GSI
|
|
55
|
+
* access patterns. GSIs with a `collection` property are auto-discovered as
|
|
56
|
+
* cross-entity collections by `DynamoClient.make()`.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```typescript
|
|
60
|
+
* const Tasks = Entity.make({
|
|
61
|
+
* model: Task,
|
|
62
|
+
* entityType: "Task",
|
|
63
|
+
* primaryKey: {
|
|
64
|
+
* pk: { field: "pk", composite: ["taskId"] },
|
|
65
|
+
* sk: { field: "sk", composite: [] },
|
|
66
|
+
* },
|
|
67
|
+
* indexes: {
|
|
68
|
+
* byProject: {
|
|
69
|
+
* name: "gsi1",
|
|
70
|
+
* pk: { field: "gsi1pk", composite: ["projectId"] },
|
|
71
|
+
* sk: { field: "gsi1sk", composite: ["status"] },
|
|
72
|
+
* },
|
|
73
|
+
* assigned: {
|
|
74
|
+
* collection: "assignments",
|
|
75
|
+
* name: "gsi2",
|
|
76
|
+
* pk: { field: "gsi2pk", composite: ["employee"] },
|
|
77
|
+
* sk: { field: "gsi2sk", composite: ["project"] },
|
|
78
|
+
* },
|
|
79
|
+
* },
|
|
80
|
+
* })
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export const make = (config) => {
|
|
84
|
+
// Normalize GSI configs to internal IndexDefinition format
|
|
85
|
+
const gsiIndexes = {};
|
|
86
|
+
if (config.indexes) {
|
|
87
|
+
for (const [name, gsi] of Object.entries(config.indexes)) {
|
|
88
|
+
gsiIndexes[name] = normalizeGsiConfig(gsi);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const indexes = { primary: config.primaryKey, ...gsiIndexes };
|
|
92
|
+
// Delegate to the internal implementation with normalized indexes
|
|
93
|
+
return makeImpl({ ...config, indexes });
|
|
94
|
+
};
|
|
95
|
+
const makeImpl = (config) => {
|
|
96
|
+
// Unwrap ConfiguredModel to get the raw model and attribute overrides
|
|
97
|
+
const configured = isConfiguredModel(config.model) ? config.model : undefined;
|
|
98
|
+
const rawModel = configured ? configured.model : config.model;
|
|
99
|
+
const configuredAttributes = configured?.attributes ?? {};
|
|
100
|
+
const isSchemaClass = typeof rawModel === "function";
|
|
101
|
+
const modelFields = getFields(rawModel);
|
|
102
|
+
const hasHiddenFields = Object.values(modelFields).some(isHidden);
|
|
103
|
+
const systemFields = resolveSystemFields(config.timestamps, config.versioned);
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Validate indexes
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
const primaryIndex = config.indexes.primary;
|
|
108
|
+
if (primaryIndex.pk.composite.length === 0 && primaryIndex.sk.composite.length === 0) {
|
|
109
|
+
throw new Error(`[EDD-9001] Entity "${config.entityType}": primary key must have at least one composite attribute in pk or sk`);
|
|
110
|
+
}
|
|
111
|
+
// Build the set of valid composite attribute names: model fields + ref-derived ID fields
|
|
112
|
+
const validCompositeFields = new Set(Object.keys(modelFields));
|
|
113
|
+
for (const fieldName of Object.keys(modelFields)) {
|
|
114
|
+
if (isRefField(fieldName, config.model)) {
|
|
115
|
+
validCompositeFields.add(`${fieldName}Id`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const [indexName, indexDef] of Object.entries(config.indexes)) {
|
|
119
|
+
for (const attr of [...indexDef.pk.composite, ...indexDef.sk.composite]) {
|
|
120
|
+
if (!validCompositeFields.has(attr)) {
|
|
121
|
+
throw new Error(`[EDD-9002] Entity "${config.entityType}": index "${indexName}" references unknown attribute "${attr}". ` +
|
|
122
|
+
`Valid attributes: ${[...validCompositeFields].sort().join(", ")}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const resolvedRefs = config.refs
|
|
127
|
+
? Object.entries(config.refs).map(([fieldName, refValue]) => {
|
|
128
|
+
// Extract entity from shorthand or expanded form
|
|
129
|
+
const refEntity = refValue.entity;
|
|
130
|
+
// Validate the field exists in the model
|
|
131
|
+
const fieldSchema = modelFields[fieldName];
|
|
132
|
+
if (!fieldSchema) {
|
|
133
|
+
throw new Error(`Entity "${config.entityType}": refs config references field "${fieldName}" which does not exist in the model`);
|
|
134
|
+
}
|
|
135
|
+
// Check for ref annotation — either on the schema field or via DynamoModel.configure
|
|
136
|
+
if (!isRef(fieldSchema) && !isRefField(fieldName, config.model)) {
|
|
137
|
+
throw new Error(`Entity "${config.entityType}": refs config references field "${fieldName}" which does not have the DynamoModel.ref annotation. Add DynamoModel.ref to the model field or set { ref: true } in DynamoModel.configure.`);
|
|
138
|
+
}
|
|
139
|
+
// Resolve the identifier field from the referenced entity's model
|
|
140
|
+
// getIdentifierField handles both schema-level annotations and ConfiguredModel overrides
|
|
141
|
+
const idField = getIdentifierField(refEntity.model);
|
|
142
|
+
if (!idField) {
|
|
143
|
+
throw new Error(`Entity "${config.entityType}": ref field "${fieldName}" references entity "${refEntity.entityType}" which has no identifier field. Add DynamoModel.identifier to the model field or set { identifier: true } in DynamoModel.configure.`);
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
fieldName,
|
|
147
|
+
idFieldName: `${fieldName}Id`,
|
|
148
|
+
identifierField: idField.name,
|
|
149
|
+
identifierSchema: idField.schema,
|
|
150
|
+
refEntity: refEntity,
|
|
151
|
+
refEntityType: refEntity.entityType,
|
|
152
|
+
};
|
|
153
|
+
})
|
|
154
|
+
: [];
|
|
155
|
+
const hasRefs = resolvedRefs.length > 0;
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Auto-generate cascade indexes from expanded ref configs
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
const cascadeIndexes = {};
|
|
160
|
+
if (config.refs) {
|
|
161
|
+
for (const [refFieldName, refValue] of Object.entries(config.refs)) {
|
|
162
|
+
const cascadeConfig = refValue.cascade;
|
|
163
|
+
if (!cascadeConfig)
|
|
164
|
+
continue;
|
|
165
|
+
const ref = resolvedRefs.find((r) => r.fieldName === refFieldName);
|
|
166
|
+
cascadeIndexes[`_cascade_${refFieldName}`] = {
|
|
167
|
+
index: cascadeConfig.index,
|
|
168
|
+
pk: { field: cascadeConfig.pk.field, composite: [ref.idFieldName] },
|
|
169
|
+
sk: { field: cascadeConfig.sk.field, composite: [...config.indexes.primary.pk.composite] },
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
let allIndexes = {
|
|
174
|
+
...config.indexes,
|
|
175
|
+
...cascadeIndexes,
|
|
176
|
+
};
|
|
177
|
+
// Build immutable fields set from ConfiguredModel (for update schema exclusion + upsert if_not_exists wrapping)
|
|
178
|
+
const immutableFields = new Set();
|
|
179
|
+
for (const [fieldName, attrConfig] of Object.entries(configuredAttributes)) {
|
|
180
|
+
if (attrConfig.immutable)
|
|
181
|
+
immutableFields.add(fieldName);
|
|
182
|
+
}
|
|
183
|
+
// Resolve identifier field name from model annotation
|
|
184
|
+
const resolvedIdentifier = getIdentifierField(config.model)?.name;
|
|
185
|
+
const schemas = buildDerivedSchemas(modelFields, allIndexes, systemFields, resolvedRefs, immutableFields, resolvedIdentifier);
|
|
186
|
+
// schema and tableTag are injected via _configure() when the entity is registered
|
|
187
|
+
// on a Table and bound through DynamoClient.make(). They are captured by operation
|
|
188
|
+
// closures and resolved at runtime (inside Effects), not at definition time.
|
|
189
|
+
// Using definite assignment (!) since _configure is called before any operation executes.
|
|
190
|
+
let schema;
|
|
191
|
+
let tableTag;
|
|
192
|
+
const entityType = config.entityType;
|
|
193
|
+
const entityVersion = 1;
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// DynamoEncoding map: field name → encoding for date-annotated fields
|
|
196
|
+
// Merges schema annotations with ConfiguredModel storage overrides.
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
const fieldEncodings = {};
|
|
199
|
+
for (const [fieldName, fieldSchema] of Object.entries(modelFields)) {
|
|
200
|
+
const encoding = getEncoding(fieldSchema);
|
|
201
|
+
if (encoding) {
|
|
202
|
+
fieldEncodings[fieldName] = encoding;
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
// Auto-detect standard Effect date schemas (Pattern B: pure model)
|
|
206
|
+
const inferred = inferDefaultEncoding(fieldSchema);
|
|
207
|
+
if (inferred)
|
|
208
|
+
fieldEncodings[fieldName] = inferred;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Apply ConfiguredModel storage overrides (takes precedence over schema annotations)
|
|
212
|
+
for (const [fieldName, attrConfig] of Object.entries(configuredAttributes)) {
|
|
213
|
+
if (attrConfig.encoding) {
|
|
214
|
+
const existing = fieldEncodings[fieldName];
|
|
215
|
+
fieldEncodings[fieldName] = {
|
|
216
|
+
storage: attrConfig.encoding.storage,
|
|
217
|
+
domain: existing?.domain ?? attrConfig.encoding.domain,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Note: System timestamp encodings are NOT added to fieldEncodings.
|
|
222
|
+
// generateTimestamp() handles serialization directly for timestamps.
|
|
223
|
+
// This avoids double-serialization in serializeDateFields.
|
|
224
|
+
const hasDateFields = Object.keys(fieldEncodings).length > 0;
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Field renaming: domain name → DynamoDB attribute name (from ConfiguredModel)
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
const fieldRenames = {}; // domain → dynamo
|
|
229
|
+
for (const [domainName, attrConfig] of Object.entries(configuredAttributes)) {
|
|
230
|
+
if (attrConfig.field)
|
|
231
|
+
fieldRenames[domainName] = attrConfig.field;
|
|
232
|
+
}
|
|
233
|
+
const hasRenames = Object.keys(fieldRenames).length > 0;
|
|
234
|
+
/**
|
|
235
|
+
* Rename domain field names to DynamoDB attribute names.
|
|
236
|
+
* Called before toAttributeMap (put path).
|
|
237
|
+
*/
|
|
238
|
+
const renameToDynamo = (item) => {
|
|
239
|
+
if (!hasRenames)
|
|
240
|
+
return;
|
|
241
|
+
for (const [domain, dynamo] of Object.entries(fieldRenames)) {
|
|
242
|
+
if (domain in item) {
|
|
243
|
+
item[dynamo] = item[domain];
|
|
244
|
+
delete item[domain];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* Rename DynamoDB attribute names back to domain field names.
|
|
250
|
+
* Called after fromAttributeMap (get path).
|
|
251
|
+
*/
|
|
252
|
+
const renameFromDynamo = (item) => {
|
|
253
|
+
if (!hasRenames)
|
|
254
|
+
return;
|
|
255
|
+
for (const [domain, dynamo] of Object.entries(fieldRenames)) {
|
|
256
|
+
if (dynamo in item) {
|
|
257
|
+
item[domain] = item[dynamo];
|
|
258
|
+
delete item[dynamo];
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
/** Resolve a domain field name to its DynamoDB attribute name. */
|
|
263
|
+
const resolveDbName = (domainName) => fieldRenames[domainName] ?? domainName;
|
|
264
|
+
/**
|
|
265
|
+
* Convert domain date values to storage primitives for DynamoEncoding-annotated fields.
|
|
266
|
+
* Called before toAttributeMap (put path).
|
|
267
|
+
*/
|
|
268
|
+
const serializeDateFields = (item) => {
|
|
269
|
+
if (!hasDateFields)
|
|
270
|
+
return;
|
|
271
|
+
for (const [field, encoding] of Object.entries(fieldEncodings)) {
|
|
272
|
+
if (field in item && item[field] != null) {
|
|
273
|
+
item[field] = serializeDateForDynamo(item[field], encoding);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
/**
|
|
278
|
+
* Convert storage primitives back to domain values for DynamoEncoding-annotated fields.
|
|
279
|
+
* Called after fromAttributeMap (get path).
|
|
280
|
+
*/
|
|
281
|
+
const deserializeDateFields = (raw) => {
|
|
282
|
+
if (!hasDateFields)
|
|
283
|
+
return;
|
|
284
|
+
for (const [field, encoding] of Object.entries(fieldEncodings)) {
|
|
285
|
+
if (field in raw && raw[field] != null) {
|
|
286
|
+
raw[field] = deserializeDateFromDynamo(raw[field], encoding);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Helpers
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
const composePrimaryKey = (record) => {
|
|
294
|
+
const primary = config.indexes.primary;
|
|
295
|
+
return {
|
|
296
|
+
[primary.pk.field]: KeyComposer.composePk(schema, entityType, primary, record),
|
|
297
|
+
[primary.sk.field]: KeyComposer.composeSk(schema, entityType, entityVersion, primary, record),
|
|
298
|
+
};
|
|
299
|
+
};
|
|
300
|
+
const composeAllKeys = (record) => KeyComposer.composeAllKeys(schema, entityType, entityVersion, allIndexes, record);
|
|
301
|
+
/** Attach the model class prototype to a decoded plain object (when model is Schema.Class). */
|
|
302
|
+
const attachPrototype = (decoded) => isSchemaClass ? Object.assign(Object.create(rawModel.prototype), decoded) : decoded;
|
|
303
|
+
const decodeRecord = (raw) => {
|
|
304
|
+
renameFromDynamo(raw);
|
|
305
|
+
deserializeDateFields(raw);
|
|
306
|
+
return Schema.decodeUnknownEffect(schemas.recordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
|
|
307
|
+
entityType,
|
|
308
|
+
operation: "decode",
|
|
309
|
+
cause,
|
|
310
|
+
})));
|
|
311
|
+
};
|
|
312
|
+
/** Decode a raw item using the schema selected by mode */
|
|
313
|
+
const decodeAs = (raw, marshalled, mode) => {
|
|
314
|
+
if (mode === "native")
|
|
315
|
+
return Effect.succeed(marshalled);
|
|
316
|
+
// Rename DynamoDB attributes back to domain field names
|
|
317
|
+
renameFromDynamo(raw);
|
|
318
|
+
// Convert date storage primitives to domain values before Schema decode.
|
|
319
|
+
// recordSchema/itemSchema use fromSelf variants for annotated fields,
|
|
320
|
+
// so they accept domain values directly. modelSchema also uses fromSelf
|
|
321
|
+
// for the decode path after deserialization.
|
|
322
|
+
deserializeDateFields(raw);
|
|
323
|
+
const targetSchema = mode === "model"
|
|
324
|
+
? schemas.modelDecodeSchema
|
|
325
|
+
: mode === "record"
|
|
326
|
+
? schemas.recordSchema
|
|
327
|
+
: schemas.itemSchema;
|
|
328
|
+
return Schema.decodeUnknownEffect(targetSchema)(raw).pipe(Effect.map((decoded) => mode === "model" && isSchemaClass && !hasHiddenFields
|
|
329
|
+
? new rawModel(decoded)
|
|
330
|
+
: mode !== "item"
|
|
331
|
+
? attachPrototype(decoded)
|
|
332
|
+
: decoded), Effect.mapError((cause) => new ValidationError({
|
|
333
|
+
entityType,
|
|
334
|
+
operation: "decode",
|
|
335
|
+
cause,
|
|
336
|
+
})));
|
|
337
|
+
};
|
|
338
|
+
const nowIso = () => new Date().toISOString();
|
|
339
|
+
/**
|
|
340
|
+
* Generate a timestamp value appropriate for the field's encoding.
|
|
341
|
+
* Default (no encoding): ISO string. Custom encoding: serialized primitive.
|
|
342
|
+
*/
|
|
343
|
+
const generateTimestamp = (encoding) => {
|
|
344
|
+
if (!encoding)
|
|
345
|
+
return nowIso();
|
|
346
|
+
const now = new Date();
|
|
347
|
+
return serializeDateForDynamo(now, encoding);
|
|
348
|
+
};
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// Lifecycle config helpers
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
const isRetainEnabled = () => typeof config.versioned === "object" &&
|
|
353
|
+
config.versioned !== null &&
|
|
354
|
+
config.versioned.retain === true;
|
|
355
|
+
const isSoftDeleteEnabled = () => config.softDelete === true ||
|
|
356
|
+
(typeof config.softDelete === "object" && config.softDelete !== null);
|
|
357
|
+
const retainTtl = () => {
|
|
358
|
+
if (typeof config.versioned !== "object" || config.versioned === null)
|
|
359
|
+
return undefined;
|
|
360
|
+
return config.versioned.ttl;
|
|
361
|
+
};
|
|
362
|
+
const softDeleteTtl = () => {
|
|
363
|
+
if (typeof config.softDelete !== "object" || config.softDelete === null)
|
|
364
|
+
return undefined;
|
|
365
|
+
return config.softDelete.ttl;
|
|
366
|
+
};
|
|
367
|
+
const preserveUnique = () => {
|
|
368
|
+
if (typeof config.softDelete !== "object" || config.softDelete === null)
|
|
369
|
+
return false;
|
|
370
|
+
return config.softDelete.preserveUnique === true;
|
|
371
|
+
};
|
|
372
|
+
/** Collect all key field names (pk, sk, gsi*pk, gsi*sk) */
|
|
373
|
+
const gsiKeyFields = () => {
|
|
374
|
+
const fields = [];
|
|
375
|
+
for (const [indexName, indexDef] of Object.entries(allIndexes)) {
|
|
376
|
+
if (indexName === "primary")
|
|
377
|
+
continue;
|
|
378
|
+
fields.push(indexDef.pk.field, indexDef.sk.field);
|
|
379
|
+
}
|
|
380
|
+
return fields;
|
|
381
|
+
};
|
|
382
|
+
/**
|
|
383
|
+
* Build a version snapshot item: same PK, version SK, stripped GSI keys,
|
|
384
|
+
* keeps __edd_e__ for entity type filtering.
|
|
385
|
+
*/
|
|
386
|
+
const buildSnapshotItem = (item, version, _tablePkField, tableSkField) => {
|
|
387
|
+
const snapshot = { ...item };
|
|
388
|
+
// Strip GSI key fields — snapshots must not appear in index queries
|
|
389
|
+
for (const field of gsiKeyFields()) {
|
|
390
|
+
delete snapshot[field];
|
|
391
|
+
}
|
|
392
|
+
// Replace SK with version SK
|
|
393
|
+
snapshot[tableSkField] = DynamoSchema.composeVersionKey(schema, entityType, version);
|
|
394
|
+
// Add optional TTL
|
|
395
|
+
const ttl = retainTtl();
|
|
396
|
+
if (ttl) {
|
|
397
|
+
snapshot._ttl = Math.floor(Date.now() / 1000) + Duration.toSeconds(ttl);
|
|
398
|
+
}
|
|
399
|
+
return snapshot;
|
|
400
|
+
};
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
// Ref hydration
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
/**
|
|
405
|
+
* Hydrate ref fields by fetching referenced entities by ID and embedding
|
|
406
|
+
* their core domain data. Returns a new object with ref fields populated.
|
|
407
|
+
*
|
|
408
|
+
* @param decoded - Decoded input containing `${field}Id` values
|
|
409
|
+
* @returns Object with `${field}Id` removed and `${field}` populated with domain data
|
|
410
|
+
*/
|
|
411
|
+
const hydrateRefs = (decoded, refs) => Effect.gen(function* () {
|
|
412
|
+
const refsToProcess = refs ?? resolvedRefs;
|
|
413
|
+
if (refsToProcess.length === 0)
|
|
414
|
+
return decoded;
|
|
415
|
+
// Fetch all refs in parallel
|
|
416
|
+
const hydrated = { ...decoded };
|
|
417
|
+
const fetchEffects = refsToProcess.map((ref) => {
|
|
418
|
+
const id = decoded[ref.idFieldName];
|
|
419
|
+
return Effect.gen(function* () {
|
|
420
|
+
const keyInput = { [ref.identifierField]: id };
|
|
421
|
+
// Use asModel to convert EntityGet to Effect<ModelType>
|
|
422
|
+
const getEffect = asModel(ref.refEntity.get(keyInput));
|
|
423
|
+
const result = yield* getEffect.pipe(Effect.catchTag("ItemNotFound", () => Effect.fail(new RefNotFound({
|
|
424
|
+
entity: entityType,
|
|
425
|
+
field: ref.fieldName,
|
|
426
|
+
refEntity: ref.refEntityType,
|
|
427
|
+
refId: id,
|
|
428
|
+
}))));
|
|
429
|
+
return { fieldName: ref.fieldName, idFieldName: ref.idFieldName, data: result };
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
const results = yield* Effect.all(fetchEffects, { concurrency: "unbounded" });
|
|
433
|
+
for (const r of results) {
|
|
434
|
+
// Spread the Schema.Class instance to extract domain data as a plain object
|
|
435
|
+
hydrated[r.fieldName] = { ...r.data };
|
|
436
|
+
delete hydrated[r.idFieldName];
|
|
437
|
+
}
|
|
438
|
+
return hydrated;
|
|
439
|
+
});
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
// Cascade update execution
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
/**
|
|
444
|
+
* Find a GSI on a target entity whose PK composite includes the source entity's
|
|
445
|
+
* identifier field name (e.g., "playerId"). This GSI is used to query all target
|
|
446
|
+
* items that reference the source entity.
|
|
447
|
+
*
|
|
448
|
+
* Selection priority:
|
|
449
|
+
* 1. A GSI whose PK composite is exactly `[idFieldName]` (single attribute).
|
|
450
|
+
* This is the only shape that can be recomposed from the source identifier
|
|
451
|
+
* alone, so it's always safe for cascade.
|
|
452
|
+
* 2. Otherwise, the first GSI whose PK composite contains `idFieldName`.
|
|
453
|
+
* This is a fallback for backwards compatibility — `executeCascade`
|
|
454
|
+
* validates that all extra composites can be supplied at runtime and
|
|
455
|
+
* raises a clear error if they cannot.
|
|
456
|
+
*/
|
|
457
|
+
const findCascadeIndex = (target, idFieldName) => {
|
|
458
|
+
let fallback;
|
|
459
|
+
for (const [indexName, indexDef] of Object.entries(target.indexes)) {
|
|
460
|
+
if (indexName === "primary")
|
|
461
|
+
continue;
|
|
462
|
+
const composite = indexDef.pk.composite;
|
|
463
|
+
if (composite.length === 1 && composite[0] === idFieldName) {
|
|
464
|
+
// Exact match — preferred. PK is fully determined by the source id.
|
|
465
|
+
return { indexName, indexDef };
|
|
466
|
+
}
|
|
467
|
+
if (fallback === undefined && composite.includes(idFieldName)) {
|
|
468
|
+
fallback = { indexName, indexDef };
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return fallback;
|
|
472
|
+
};
|
|
473
|
+
/** Cached source entity identifier field name (e.g., "playerId") */
|
|
474
|
+
const sourceIdentifierField = (() => {
|
|
475
|
+
const idField = getIdentifierField(config.model);
|
|
476
|
+
return idField?.name;
|
|
477
|
+
})();
|
|
478
|
+
/**
|
|
479
|
+
* Execute cascade updates after a source entity update completes.
|
|
480
|
+
* For each target entity: query its cascade GSI, then update all matching items.
|
|
481
|
+
*/
|
|
482
|
+
const executeCascade = (cascadeConfig, sourceDomainData, sourceIdValue) => Effect.gen(function* () {
|
|
483
|
+
const { targets, filter, mode } = cascadeConfig;
|
|
484
|
+
const sourceEntityType = entityType;
|
|
485
|
+
const client = yield* DynamoClient;
|
|
486
|
+
if (!sourceIdentifierField)
|
|
487
|
+
return undefined;
|
|
488
|
+
const allUpdateOps = [];
|
|
489
|
+
for (const target of targets) {
|
|
490
|
+
// Find the ref on the target that points to our source entity
|
|
491
|
+
const matchingRef = target._resolvedRefs.find((r) => r.refEntityType === sourceEntityType);
|
|
492
|
+
if (!matchingRef)
|
|
493
|
+
continue;
|
|
494
|
+
// Find GSI on target whose PK composite includes the ref's ID field
|
|
495
|
+
const cascadeIdx = findCascadeIndex(target, matchingRef.idFieldName);
|
|
496
|
+
if (!cascadeIdx)
|
|
497
|
+
continue;
|
|
498
|
+
// The cascade can only recompose a GSI PK from the source identifier
|
|
499
|
+
// if every composite attribute is satisfied. The source entity (e.g. a
|
|
500
|
+
// Team) only carries its own identifier (e.g. teamId), so any GSI whose
|
|
501
|
+
// PK includes additional composites (e.g. season, series) is unusable
|
|
502
|
+
// for cascade. findCascadeIndex prefers the safe shape, but the
|
|
503
|
+
// fallback may still hand back an over-composed GSI when no exact
|
|
504
|
+
// match exists. Detect that here and fail with a clear, tagged error
|
|
505
|
+
// rather than throwing deep inside KeyComposer.composePk.
|
|
506
|
+
const cascadePkComposite = cascadeIdx.indexDef.pk.composite;
|
|
507
|
+
if (cascadePkComposite.length !== 1 || cascadePkComposite[0] !== matchingRef.idFieldName) {
|
|
508
|
+
return yield* new CascadePartialFailure({
|
|
509
|
+
sourceEntity: sourceEntityType,
|
|
510
|
+
sourceId: sourceIdValue,
|
|
511
|
+
succeeded: 0,
|
|
512
|
+
failed: 0,
|
|
513
|
+
errors: [
|
|
514
|
+
`Cascade target "${target.entityType}" has no GSI usable for cascade from "${sourceEntityType}". ` +
|
|
515
|
+
`The chosen index "${cascadeIdx.indexName}" (${cascadeIdx.indexDef.index}) has PK composite ` +
|
|
516
|
+
`[${cascadePkComposite.join(", ")}], but cascade requires a GSI whose PK composite is exactly ` +
|
|
517
|
+
`["${matchingRef.idFieldName}"]. Add a single-attribute GSI keyed on "${matchingRef.idFieldName}" ` +
|
|
518
|
+
`(or use the refs.${matchingRef.fieldName}.cascade config to auto-generate one).`,
|
|
519
|
+
],
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
// Resolve target table name
|
|
523
|
+
const { name: targetTableName } = yield* target._tableTag;
|
|
524
|
+
// Compose GSI PK for the source entity's ID
|
|
525
|
+
const targetSchema = target._schema;
|
|
526
|
+
const gsiPkValue = KeyComposer.composePk(targetSchema, target.entityType, cascadeIdx.indexDef, { [matchingRef.idFieldName]: sourceIdValue });
|
|
527
|
+
// Build query parameters
|
|
528
|
+
const gsiPkField = cascadeIdx.indexDef.pk.field;
|
|
529
|
+
const queryNames = {
|
|
530
|
+
"#pk": gsiPkField,
|
|
531
|
+
"#et": "__edd_e__",
|
|
532
|
+
};
|
|
533
|
+
const queryValues = {
|
|
534
|
+
":pk": { S: gsiPkValue },
|
|
535
|
+
":et0": { S: target.entityType },
|
|
536
|
+
};
|
|
537
|
+
let filterExpression = "#et = :et0";
|
|
538
|
+
// Add user-provided filter
|
|
539
|
+
if (filter) {
|
|
540
|
+
let filterIdx = 0;
|
|
541
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
542
|
+
const nameKey = `#cf${filterIdx}`;
|
|
543
|
+
const valKey = `:cf${filterIdx}`;
|
|
544
|
+
queryNames[nameKey] = key;
|
|
545
|
+
queryValues[valKey] = toAttributeValue(value);
|
|
546
|
+
filterExpression += ` AND ${nameKey} = ${valKey}`;
|
|
547
|
+
filterIdx++;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// Paginated query
|
|
551
|
+
let exclusiveStartKey;
|
|
552
|
+
do {
|
|
553
|
+
const queryResult = yield* client.query({
|
|
554
|
+
TableName: targetTableName,
|
|
555
|
+
IndexName: cascadeIdx.indexDef.index,
|
|
556
|
+
KeyConditionExpression: "#pk = :pk",
|
|
557
|
+
FilterExpression: filterExpression,
|
|
558
|
+
ExpressionAttributeNames: queryNames,
|
|
559
|
+
ExpressionAttributeValues: queryValues,
|
|
560
|
+
ExclusiveStartKey: exclusiveStartKey,
|
|
561
|
+
});
|
|
562
|
+
for (const item of queryResult.Items ?? []) {
|
|
563
|
+
allUpdateOps.push({
|
|
564
|
+
tableName: targetTableName,
|
|
565
|
+
key: { pk: item.pk, sk: item.sk },
|
|
566
|
+
refFieldName: matchingRef.fieldName,
|
|
567
|
+
refData: toAttributeValue(sourceDomainData),
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
exclusiveStartKey = queryResult.LastEvaluatedKey;
|
|
571
|
+
} while (exclusiveStartKey !== undefined);
|
|
572
|
+
}
|
|
573
|
+
if (allUpdateOps.length === 0)
|
|
574
|
+
return;
|
|
575
|
+
if (mode === "transactional") {
|
|
576
|
+
// Transactional mode — fail if >100 items
|
|
577
|
+
if (allUpdateOps.length > 100) {
|
|
578
|
+
return yield* new CascadePartialFailure({
|
|
579
|
+
sourceEntity: sourceEntityType,
|
|
580
|
+
sourceId: sourceIdValue,
|
|
581
|
+
succeeded: 0,
|
|
582
|
+
failed: allUpdateOps.length,
|
|
583
|
+
errors: [`Transactional cascade exceeded 100-item limit: ${allUpdateOps.length} items`],
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
yield* client.transactWriteItems({
|
|
587
|
+
TransactItems: allUpdateOps.map((op) => ({
|
|
588
|
+
Update: {
|
|
589
|
+
TableName: op.tableName,
|
|
590
|
+
Key: op.key,
|
|
591
|
+
UpdateExpression: "SET #ref = :refData",
|
|
592
|
+
ExpressionAttributeNames: { "#ref": op.refFieldName },
|
|
593
|
+
ExpressionAttributeValues: { ":refData": op.refData },
|
|
594
|
+
},
|
|
595
|
+
})),
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
else {
|
|
599
|
+
// Eventual mode — concurrent updates with partial failure tracking
|
|
600
|
+
let succeeded = 0;
|
|
601
|
+
const errors = [];
|
|
602
|
+
const updateEffects = allUpdateOps.map((op) => client
|
|
603
|
+
.updateItem({
|
|
604
|
+
TableName: op.tableName,
|
|
605
|
+
Key: op.key,
|
|
606
|
+
UpdateExpression: "SET #ref = :refData",
|
|
607
|
+
ExpressionAttributeNames: { "#ref": op.refFieldName },
|
|
608
|
+
ExpressionAttributeValues: { ":refData": op.refData },
|
|
609
|
+
})
|
|
610
|
+
.pipe(Effect.tap(() => Effect.sync(() => {
|
|
611
|
+
succeeded++;
|
|
612
|
+
})), Effect.catch((err) => Effect.sync(() => {
|
|
613
|
+
errors.push(err);
|
|
614
|
+
}))));
|
|
615
|
+
yield* Effect.all(updateEffects, { concurrency: 10 });
|
|
616
|
+
if (errors.length > 0) {
|
|
617
|
+
return yield* new CascadePartialFailure({
|
|
618
|
+
sourceEntity: sourceEntityType,
|
|
619
|
+
sourceId: sourceIdValue,
|
|
620
|
+
succeeded,
|
|
621
|
+
failed: errors.length,
|
|
622
|
+
errors,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
// ---------------------------------------------------------------------------
|
|
628
|
+
// put operation
|
|
629
|
+
// ---------------------------------------------------------------------------
|
|
630
|
+
// Mutable self — safe because operations are closures that only
|
|
631
|
+
// dereference `self` when called by users, after make() has returned
|
|
632
|
+
let self;
|
|
633
|
+
const put = (input) => new EntityPutImpl((mode, opts) => Effect.gen(function* () {
|
|
634
|
+
const client = yield* DynamoClient;
|
|
635
|
+
const { name: tableName } = yield* tableTag;
|
|
636
|
+
// Decode input
|
|
637
|
+
const decodedInput = yield* Schema.decodeUnknownEffect(schemas.inputSchema)(input).pipe(Effect.mapError((cause) => new ValidationError({
|
|
638
|
+
entityType,
|
|
639
|
+
operation: "put.decode",
|
|
640
|
+
cause,
|
|
641
|
+
})));
|
|
642
|
+
// Compose all keys BEFORE ref hydration (ID fields are still present)
|
|
643
|
+
const keys = composeAllKeys(decodedInput);
|
|
644
|
+
// Hydrate refs: replace ID fields with full entity domain data
|
|
645
|
+
const decoded = hasRefs
|
|
646
|
+
? yield* hydrateRefs(decodedInput)
|
|
647
|
+
: decodedInput;
|
|
648
|
+
// Build the DynamoDB item
|
|
649
|
+
const item = { ...decoded };
|
|
650
|
+
// Add system fields (encoding-aware timestamps)
|
|
651
|
+
if (systemFields.createdAt)
|
|
652
|
+
item[systemFields.createdAt] = generateTimestamp(systemFields.createdAtEncoding);
|
|
653
|
+
if (systemFields.updatedAt)
|
|
654
|
+
item[systemFields.updatedAt] = generateTimestamp(systemFields.updatedAtEncoding);
|
|
655
|
+
if (systemFields.version)
|
|
656
|
+
item[systemFields.version] = 1;
|
|
657
|
+
// Add entity type discriminator
|
|
658
|
+
item.__edd_e__ = entityType;
|
|
659
|
+
// Apply composed keys
|
|
660
|
+
Object.assign(item, keys);
|
|
661
|
+
// Convert date fields from domain values to storage primitives
|
|
662
|
+
serializeDateFields(item);
|
|
663
|
+
// Rename domain fields to DynamoDB attribute names
|
|
664
|
+
renameToDynamo(item);
|
|
665
|
+
const marshalledItem = toAttributeMap(item);
|
|
666
|
+
// Build user condition expression if provided
|
|
667
|
+
const userCondition = opts.condition
|
|
668
|
+
? compileCondition(opts.condition, resolveDbName)
|
|
669
|
+
: undefined;
|
|
670
|
+
const hasUniqueConstraints = config.unique != null && Object.keys(config.unique).length > 0;
|
|
671
|
+
const needsTransaction = hasUniqueConstraints || isRetainEnabled();
|
|
672
|
+
if (needsTransaction) {
|
|
673
|
+
// Use transactWriteItems for atomic put + sentinel creation + snapshot
|
|
674
|
+
const transactItems = [];
|
|
675
|
+
// Main entity item (with optional user condition)
|
|
676
|
+
const mainPut = {
|
|
677
|
+
TableName: tableName,
|
|
678
|
+
Item: marshalledItem,
|
|
679
|
+
};
|
|
680
|
+
if (userCondition) {
|
|
681
|
+
mainPut.ConditionExpression = userCondition.expression;
|
|
682
|
+
mainPut.ExpressionAttributeNames = userCondition.names;
|
|
683
|
+
if (Object.keys(userCondition.values).length > 0) {
|
|
684
|
+
mainPut.ExpressionAttributeValues = userCondition.values;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
transactItems.push({ Put: mainPut });
|
|
688
|
+
// Unique constraint sentinels
|
|
689
|
+
if (hasUniqueConstraints) {
|
|
690
|
+
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
691
|
+
const fields = resolveUniqueFields(constraintDef);
|
|
692
|
+
const fieldValues = fields.map((f) => KeyComposer.serializeValue(decoded[f]));
|
|
693
|
+
const uniqueKey = DynamoSchema.composeUniqueKey(schema, entityType, constraintName, fieldValues);
|
|
694
|
+
transactItems.push({
|
|
695
|
+
Put: {
|
|
696
|
+
TableName: tableName,
|
|
697
|
+
Item: toAttributeMap({
|
|
698
|
+
[config.indexes.primary.pk.field]: uniqueKey.pk,
|
|
699
|
+
[config.indexes.primary.sk.field]: uniqueKey.sk,
|
|
700
|
+
__edd_e__: `${entityType}._unique.${constraintName}`,
|
|
701
|
+
_entity_pk: keys[config.indexes.primary.pk.field],
|
|
702
|
+
_entity_sk: keys[config.indexes.primary.sk.field],
|
|
703
|
+
}),
|
|
704
|
+
ConditionExpression: "attribute_not_exists(pk)",
|
|
705
|
+
},
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
// Version snapshot (v1) when retain is enabled
|
|
710
|
+
if (isRetainEnabled()) {
|
|
711
|
+
const snapshotItem = buildSnapshotItem(item, 1, config.indexes.primary.pk.field, config.indexes.primary.sk.field);
|
|
712
|
+
transactItems.push({
|
|
713
|
+
Put: {
|
|
714
|
+
TableName: tableName,
|
|
715
|
+
Item: toAttributeMap(snapshotItem),
|
|
716
|
+
},
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
yield* checkTransactionLimit(entityType, "put", transactItems);
|
|
720
|
+
yield* client
|
|
721
|
+
.transactWriteItems({
|
|
722
|
+
TransactItems: transactItems.map((t) => ({ Put: t.Put })),
|
|
723
|
+
})
|
|
724
|
+
.pipe(Effect.mapError((err) => {
|
|
725
|
+
// Check if this is a transaction cancellation
|
|
726
|
+
if (isAwsTransactionCancelled(err.cause)) {
|
|
727
|
+
const reasons = err.cause.CancellationReasons;
|
|
728
|
+
if (reasons) {
|
|
729
|
+
// Index 0 is the main item — if it failed with user condition, return ConditionalCheckFailed
|
|
730
|
+
if (opts.condition && reasons[0]?.Code === "ConditionalCheckFailed") {
|
|
731
|
+
return new ConditionalCheckFailed({
|
|
732
|
+
entityType,
|
|
733
|
+
key: decoded,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
// Sentinel items start at index 1
|
|
737
|
+
for (let i = 1; i < reasons.length; i++) {
|
|
738
|
+
if (reasons[i]?.Code === "ConditionalCheckFailed") {
|
|
739
|
+
const constraintNames = Object.keys(config.unique);
|
|
740
|
+
const constraintName = constraintNames[i - 1] ?? "unknown";
|
|
741
|
+
const constraintDef = config.unique[constraintName];
|
|
742
|
+
const uniqueFields = constraintDef
|
|
743
|
+
? resolveUniqueFields(constraintDef)
|
|
744
|
+
: [];
|
|
745
|
+
const fieldsRecord = {};
|
|
746
|
+
for (const f of uniqueFields) {
|
|
747
|
+
fieldsRecord[f] = KeyComposer.serializeValue(decoded[f]);
|
|
748
|
+
}
|
|
749
|
+
return new UniqueConstraintViolation({
|
|
750
|
+
entityType,
|
|
751
|
+
constraint: constraintName,
|
|
752
|
+
fields: fieldsRecord,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return err;
|
|
759
|
+
}));
|
|
760
|
+
}
|
|
761
|
+
else {
|
|
762
|
+
// Simple put without unique constraints or retain
|
|
763
|
+
const putInput = {
|
|
764
|
+
TableName: tableName,
|
|
765
|
+
Item: marshalledItem,
|
|
766
|
+
};
|
|
767
|
+
if (userCondition) {
|
|
768
|
+
putInput.ConditionExpression = userCondition.expression;
|
|
769
|
+
putInput.ExpressionAttributeNames = userCondition.names;
|
|
770
|
+
if (Object.keys(userCondition.values).length > 0) {
|
|
771
|
+
putInput.ExpressionAttributeValues = userCondition.values;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
yield* client.putItem(putInput).pipe(Effect.mapError((err) => {
|
|
775
|
+
if (opts.condition && isAwsConditionalCheckFailed(err.cause)) {
|
|
776
|
+
return new ConditionalCheckFailed({ entityType, key: decoded });
|
|
777
|
+
}
|
|
778
|
+
return err;
|
|
779
|
+
}));
|
|
780
|
+
}
|
|
781
|
+
// Decode and return using selected mode
|
|
782
|
+
return yield* decodeAs(item, marshalledItem, mode);
|
|
783
|
+
}), self, input);
|
|
784
|
+
// ---------------------------------------------------------------------------
|
|
785
|
+
// create operation — put + attribute_not_exists condition
|
|
786
|
+
// ---------------------------------------------------------------------------
|
|
787
|
+
const create = (input) => {
|
|
788
|
+
const pkField = config.indexes.primary.pk.field;
|
|
789
|
+
const skField = config.indexes.primary.sk.field;
|
|
790
|
+
const op = put(input);
|
|
791
|
+
return new EntityPutImpl(op._builder, op._entity, op._input, {
|
|
792
|
+
attributeNotExists: [pkField, skField],
|
|
793
|
+
});
|
|
794
|
+
};
|
|
795
|
+
// ---------------------------------------------------------------------------
|
|
796
|
+
// patch operation — update + attribute_exists condition
|
|
797
|
+
// ---------------------------------------------------------------------------
|
|
798
|
+
const patch = (key) => {
|
|
799
|
+
const pkField = config.indexes.primary.pk.field;
|
|
800
|
+
const op = update(key);
|
|
801
|
+
return new EntityUpdateImpl(op._builder, { ...op._updateState, condition: { attributeExists: [pkField] } }, op._entity, op._key);
|
|
802
|
+
};
|
|
803
|
+
// ---------------------------------------------------------------------------
|
|
804
|
+
// get operation
|
|
805
|
+
// ---------------------------------------------------------------------------
|
|
806
|
+
const get = (key) => new EntityGetImpl((mode, opts) => Effect.gen(function* () {
|
|
807
|
+
const client = yield* DynamoClient;
|
|
808
|
+
const { name: tableName } = yield* tableTag;
|
|
809
|
+
// Decode key
|
|
810
|
+
const decodedKey = yield* Schema.decodeUnknownEffect(schemas.keySchema)(key).pipe(Effect.mapError((cause) => new ValidationError({
|
|
811
|
+
entityType,
|
|
812
|
+
operation: "get.decode",
|
|
813
|
+
cause,
|
|
814
|
+
})));
|
|
815
|
+
// Compose primary key
|
|
816
|
+
const primaryKey = composePrimaryKey(decodedKey);
|
|
817
|
+
const marshalledKey = toAttributeMap(primaryKey);
|
|
818
|
+
// Build ProjectionExpression if projection attributes provided
|
|
819
|
+
let projectionExpression;
|
|
820
|
+
let projectionNames;
|
|
821
|
+
if (opts.projection && opts.projection.length > 0) {
|
|
822
|
+
const proj = Projection.projection(opts.projection);
|
|
823
|
+
projectionExpression = proj.expression;
|
|
824
|
+
projectionNames = proj.names;
|
|
825
|
+
}
|
|
826
|
+
const result = yield* client.getItem({
|
|
827
|
+
TableName: tableName,
|
|
828
|
+
Key: marshalledKey,
|
|
829
|
+
ConsistentRead: opts.consistentRead || undefined,
|
|
830
|
+
ProjectionExpression: projectionExpression,
|
|
831
|
+
ExpressionAttributeNames: projectionNames && Object.keys(projectionNames).length > 0
|
|
832
|
+
? projectionNames
|
|
833
|
+
: undefined,
|
|
834
|
+
});
|
|
835
|
+
if (!result.Item) {
|
|
836
|
+
return yield* new ItemNotFound({ entityType, key: decodedKey });
|
|
837
|
+
}
|
|
838
|
+
// Raw mode: return unmarshalled record without schema decode
|
|
839
|
+
if (mode === "raw") {
|
|
840
|
+
return fromAttributeMap(result.Item);
|
|
841
|
+
}
|
|
842
|
+
// Unmarshall and decode using selected mode
|
|
843
|
+
const raw = fromAttributeMap(result.Item);
|
|
844
|
+
return yield* decodeAs(raw, result.Item, mode);
|
|
845
|
+
}), self, key);
|
|
846
|
+
// ---------------------------------------------------------------------------
|
|
847
|
+
// update operation
|
|
848
|
+
// ---------------------------------------------------------------------------
|
|
849
|
+
const update = (key) => new EntityUpdateImpl((mode, uState) => Effect.gen(function* () {
|
|
850
|
+
const updates = uState.updates;
|
|
851
|
+
const evExpected = uState.expectedVersion;
|
|
852
|
+
const userCond = uState.condition;
|
|
853
|
+
const client = yield* DynamoClient;
|
|
854
|
+
const { name: tableName } = yield* tableTag;
|
|
855
|
+
// Decode key
|
|
856
|
+
const decodedKey = yield* Schema.decodeUnknownEffect(schemas.keySchema)(key).pipe(Effect.mapError((cause) => new ValidationError({
|
|
857
|
+
entityType,
|
|
858
|
+
operation: "update.decodeKey",
|
|
859
|
+
cause,
|
|
860
|
+
})));
|
|
861
|
+
// Decode updates
|
|
862
|
+
const decodedUpdates = yield* Schema.decodeUnknownEffect(schemas.updateSchema)(updates ?? {}).pipe(Effect.mapError((cause) => new ValidationError({
|
|
863
|
+
entityType,
|
|
864
|
+
operation: "update.decodeUpdates",
|
|
865
|
+
cause,
|
|
866
|
+
})));
|
|
867
|
+
// Hydrate refs in updates: for any ${field}Id present, fetch and embed
|
|
868
|
+
let hydratedUpdates = decodedUpdates;
|
|
869
|
+
if (hasRefs) {
|
|
870
|
+
const refsToHydrate = resolvedRefs.filter((ref) => ref.idFieldName in decodedUpdates &&
|
|
871
|
+
decodedUpdates[ref.idFieldName] !==
|
|
872
|
+
undefined);
|
|
873
|
+
if (refsToHydrate.length > 0) {
|
|
874
|
+
hydratedUpdates = yield* hydrateRefs(hydratedUpdates, refsToHydrate);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
// Compose primary key
|
|
878
|
+
const primaryKey = composePrimaryKey(decodedKey);
|
|
879
|
+
const marshalledKey = toAttributeMap(primaryKey);
|
|
880
|
+
// Detect whether the update touches any unique constraint fields
|
|
881
|
+
const hasUniqueConstraints = config.unique != null && Object.keys(config.unique).length > 0;
|
|
882
|
+
let touchesUniqueFields = false;
|
|
883
|
+
if (hasUniqueConstraints) {
|
|
884
|
+
const uniqueFieldSet = new Set(Object.values(config.unique).flatMap((def) => [...resolveUniqueFields(def)]));
|
|
885
|
+
const allUpdatedFields = new Set([
|
|
886
|
+
...Object.keys(hydratedUpdates).filter((k) => hydratedUpdates[k] !== undefined),
|
|
887
|
+
...(uState.remove ?? []),
|
|
888
|
+
...Object.keys(uState.add ?? {}),
|
|
889
|
+
...Object.keys(uState.subtract ?? {}),
|
|
890
|
+
...Object.keys(uState.append ?? {}),
|
|
891
|
+
...Object.keys(uState.deleteFromSet ?? {}),
|
|
892
|
+
]);
|
|
893
|
+
touchesUniqueFields = [...uniqueFieldSet].some((f) => allUpdatedFields.has(f));
|
|
894
|
+
}
|
|
895
|
+
if (isRetainEnabled() || touchesUniqueFields) {
|
|
896
|
+
// --- Retain path: read-then-transact ---
|
|
897
|
+
// Read current item (needed to create snapshot of pre-update state)
|
|
898
|
+
const currentResult = yield* client.getItem({
|
|
899
|
+
TableName: tableName,
|
|
900
|
+
Key: marshalledKey,
|
|
901
|
+
});
|
|
902
|
+
if (!currentResult.Item) {
|
|
903
|
+
return yield* new ItemNotFound({ entityType, key: decodedKey });
|
|
904
|
+
}
|
|
905
|
+
const currentRaw = fromAttributeMap(currentResult.Item);
|
|
906
|
+
const currentVersion = systemFields.version
|
|
907
|
+
? currentRaw[systemFields.version]
|
|
908
|
+
: 0;
|
|
909
|
+
// Validate optimistic lock if requested
|
|
910
|
+
if (evExpected !== undefined && currentVersion !== evExpected) {
|
|
911
|
+
return yield* new OptimisticLockError({
|
|
912
|
+
entityType,
|
|
913
|
+
key: decodedKey,
|
|
914
|
+
expectedVersion: evExpected,
|
|
915
|
+
actualVersion: currentVersion,
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
// Build new item: merge current + updates + rich ops + recompose keys
|
|
919
|
+
const newItem = {
|
|
920
|
+
...currentRaw,
|
|
921
|
+
};
|
|
922
|
+
for (const [attr, val] of Object.entries(hydratedUpdates)) {
|
|
923
|
+
if (val !== undefined)
|
|
924
|
+
newItem[attr] = val;
|
|
925
|
+
}
|
|
926
|
+
// Apply rich operations to in-memory item
|
|
927
|
+
if (uState.remove) {
|
|
928
|
+
for (const attr of uState.remove) {
|
|
929
|
+
delete newItem[attr];
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
if (uState.add) {
|
|
933
|
+
for (const [attr, val] of Object.entries(uState.add)) {
|
|
934
|
+
newItem[attr] = (newItem[attr] ?? 0) + val;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (uState.subtract) {
|
|
938
|
+
for (const [attr, val] of Object.entries(uState.subtract)) {
|
|
939
|
+
newItem[attr] = (newItem[attr] ?? 0) - val;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
if (uState.append) {
|
|
943
|
+
for (const [attr, val] of Object.entries(uState.append)) {
|
|
944
|
+
const existing = newItem[attr] ?? [];
|
|
945
|
+
newItem[attr] = [...existing, ...val];
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if (uState.deleteFromSet) {
|
|
949
|
+
for (const [attr, val] of Object.entries(uState.deleteFromSet)) {
|
|
950
|
+
if (newItem[attr] instanceof Set && val instanceof Set) {
|
|
951
|
+
const current = newItem[attr];
|
|
952
|
+
for (const elem of val) {
|
|
953
|
+
current.delete(elem);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
// Increment version and update timestamp
|
|
959
|
+
const newVersion = currentVersion + 1;
|
|
960
|
+
if (systemFields.version)
|
|
961
|
+
newItem[systemFields.version] = newVersion;
|
|
962
|
+
if (systemFields.updatedAt)
|
|
963
|
+
newItem[systemFields.updatedAt] = generateTimestamp(systemFields.updatedAtEncoding);
|
|
964
|
+
// Convert DynamoDB attribute names to domain names for key composition
|
|
965
|
+
// and sentinel value comparison (newItem was built from currentRaw which
|
|
966
|
+
// uses DynamoDB names; composites and unique fields use domain names)
|
|
967
|
+
renameFromDynamo(newItem);
|
|
968
|
+
// Recompose all keys with updated attributes
|
|
969
|
+
const newKeys = composeAllKeys(newItem);
|
|
970
|
+
Object.assign(newItem, newKeys);
|
|
971
|
+
const sentinelRotations = [];
|
|
972
|
+
if (touchesUniqueFields) {
|
|
973
|
+
const currentRawRecord = currentRaw;
|
|
974
|
+
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
975
|
+
const fields = resolveUniqueFields(constraintDef);
|
|
976
|
+
const oldFieldValues = fields.map((f) => KeyComposer.serializeValue(currentRawRecord[f] ?? currentRawRecord[resolveDbName(f)]));
|
|
977
|
+
const newFieldValues = fields.map((f) => KeyComposer.serializeValue(newItem[f]));
|
|
978
|
+
const changed = oldFieldValues.some((v, i) => v !== newFieldValues[i]);
|
|
979
|
+
if (!changed)
|
|
980
|
+
continue;
|
|
981
|
+
const newFieldsRecord = {};
|
|
982
|
+
for (const f of fields) {
|
|
983
|
+
newFieldsRecord[f] = KeyComposer.serializeValue(newItem[f]);
|
|
984
|
+
}
|
|
985
|
+
sentinelRotations.push({
|
|
986
|
+
constraintName,
|
|
987
|
+
oldUniqueKey: DynamoSchema.composeUniqueKey(schema, entityType, constraintName, oldFieldValues),
|
|
988
|
+
newUniqueKey: DynamoSchema.composeUniqueKey(schema, entityType, constraintName, newFieldValues),
|
|
989
|
+
newFieldsRecord,
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
// Convert back to DynamoDB attribute names for storage
|
|
994
|
+
renameToDynamo(newItem);
|
|
995
|
+
const marshalledNewItem = toAttributeMap(newItem);
|
|
996
|
+
// Build snapshot of the pre-update state (only when retain is enabled)
|
|
997
|
+
const snapshotItem = isRetainEnabled()
|
|
998
|
+
? buildSnapshotItem(currentRaw, currentVersion, config.indexes.primary.pk.field, config.indexes.primary.sk.field)
|
|
999
|
+
: undefined;
|
|
1000
|
+
const transactItems = [];
|
|
1001
|
+
// Put new item with version condition + optional user condition
|
|
1002
|
+
{
|
|
1003
|
+
const retainCondParts = [];
|
|
1004
|
+
const retainNames = {};
|
|
1005
|
+
const retainValues = {};
|
|
1006
|
+
if (systemFields.version) {
|
|
1007
|
+
retainNames["#ver"] = systemFields.version;
|
|
1008
|
+
retainValues[":expectedVer"] = toAttributeValue(currentVersion);
|
|
1009
|
+
retainCondParts.push("#ver = :expectedVer");
|
|
1010
|
+
}
|
|
1011
|
+
if (userCond) {
|
|
1012
|
+
const uc = compileCondition(userCond, resolveDbName);
|
|
1013
|
+
retainCondParts.push(`(${uc.expression})`);
|
|
1014
|
+
Object.assign(retainNames, uc.names);
|
|
1015
|
+
Object.assign(retainValues, uc.values);
|
|
1016
|
+
}
|
|
1017
|
+
const mainPut = {
|
|
1018
|
+
TableName: tableName,
|
|
1019
|
+
Item: marshalledNewItem,
|
|
1020
|
+
};
|
|
1021
|
+
if (retainCondParts.length > 0) {
|
|
1022
|
+
mainPut.ConditionExpression = retainCondParts.join(" AND ");
|
|
1023
|
+
mainPut.ExpressionAttributeNames = retainNames;
|
|
1024
|
+
mainPut.ExpressionAttributeValues = retainValues;
|
|
1025
|
+
}
|
|
1026
|
+
transactItems.push({ Put: mainPut });
|
|
1027
|
+
}
|
|
1028
|
+
// Snapshot of pre-update state (only when retain is enabled)
|
|
1029
|
+
if (snapshotItem) {
|
|
1030
|
+
transactItems.push({
|
|
1031
|
+
Put: {
|
|
1032
|
+
TableName: tableName,
|
|
1033
|
+
Item: toAttributeMap(snapshotItem),
|
|
1034
|
+
},
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
// Apply sentinel rotations computed earlier
|
|
1038
|
+
const sentinelPutIndices = [];
|
|
1039
|
+
for (const rotation of sentinelRotations) {
|
|
1040
|
+
// Delete old sentinel
|
|
1041
|
+
transactItems.push({
|
|
1042
|
+
Delete: {
|
|
1043
|
+
TableName: tableName,
|
|
1044
|
+
Key: toAttributeMap({
|
|
1045
|
+
[config.indexes.primary.pk.field]: rotation.oldUniqueKey.pk,
|
|
1046
|
+
[config.indexes.primary.sk.field]: rotation.oldUniqueKey.sk,
|
|
1047
|
+
}),
|
|
1048
|
+
},
|
|
1049
|
+
});
|
|
1050
|
+
// Put new sentinel with uniqueness check
|
|
1051
|
+
sentinelPutIndices.push({
|
|
1052
|
+
index: transactItems.length,
|
|
1053
|
+
constraintName: rotation.constraintName,
|
|
1054
|
+
newFieldsRecord: rotation.newFieldsRecord,
|
|
1055
|
+
});
|
|
1056
|
+
transactItems.push({
|
|
1057
|
+
Put: {
|
|
1058
|
+
TableName: tableName,
|
|
1059
|
+
Item: toAttributeMap({
|
|
1060
|
+
[config.indexes.primary.pk.field]: rotation.newUniqueKey.pk,
|
|
1061
|
+
[config.indexes.primary.sk.field]: rotation.newUniqueKey.sk,
|
|
1062
|
+
__edd_e__: `${entityType}._unique.${rotation.constraintName}`,
|
|
1063
|
+
_entity_pk: primaryKey[config.indexes.primary.pk.field],
|
|
1064
|
+
_entity_sk: primaryKey[config.indexes.primary.sk.field],
|
|
1065
|
+
}),
|
|
1066
|
+
ConditionExpression: "attribute_not_exists(pk)",
|
|
1067
|
+
},
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
yield* checkTransactionLimit(entityType, "update", transactItems);
|
|
1071
|
+
yield* client
|
|
1072
|
+
.transactWriteItems({
|
|
1073
|
+
TransactItems: transactItems.map((t) => "Put" in t ? { Put: t.Put } : { Delete: t.Delete }),
|
|
1074
|
+
})
|
|
1075
|
+
.pipe(Effect.mapError((err) => {
|
|
1076
|
+
if (isAwsTransactionCancelled(err.cause)) {
|
|
1077
|
+
const reasons = err.cause.CancellationReasons;
|
|
1078
|
+
if (reasons) {
|
|
1079
|
+
// Check sentinel Puts for unique constraint violations
|
|
1080
|
+
for (const { index, constraintName, newFieldsRecord, } of sentinelPutIndices) {
|
|
1081
|
+
if (reasons[index]?.Code === "ConditionalCheckFailed") {
|
|
1082
|
+
return new UniqueConstraintViolation({
|
|
1083
|
+
entityType,
|
|
1084
|
+
constraint: constraintName,
|
|
1085
|
+
fields: newFieldsRecord,
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
// Index 0 is the main item — version conflict or user condition
|
|
1090
|
+
if (reasons[0]?.Code === "ConditionalCheckFailed") {
|
|
1091
|
+
return new OptimisticLockError({
|
|
1092
|
+
entityType,
|
|
1093
|
+
key: decodedKey,
|
|
1094
|
+
expectedVersion: currentVersion,
|
|
1095
|
+
actualVersion: -1,
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
if (isAwsConditionalCheckFailed(err.cause)) {
|
|
1101
|
+
return new OptimisticLockError({
|
|
1102
|
+
entityType,
|
|
1103
|
+
key: decodedKey,
|
|
1104
|
+
expectedVersion: currentVersion,
|
|
1105
|
+
actualVersion: -1,
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
return err;
|
|
1109
|
+
}));
|
|
1110
|
+
const retainDecoded = yield* decodeAs(newItem, marshalledNewItem, mode);
|
|
1111
|
+
// Execute cascade if configured (retain path)
|
|
1112
|
+
if (uState.cascade) {
|
|
1113
|
+
const domainData = { ...retainDecoded };
|
|
1114
|
+
const sourceId = sourceIdentifierField != null
|
|
1115
|
+
? decodedKey[sourceIdentifierField]
|
|
1116
|
+
: undefined;
|
|
1117
|
+
if (sourceId != null) {
|
|
1118
|
+
yield* executeCascade(uState.cascade, domainData, String(sourceId));
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return retainDecoded;
|
|
1122
|
+
}
|
|
1123
|
+
// --- Standard path: updateItem ---
|
|
1124
|
+
// Build UpdateExpression
|
|
1125
|
+
const setClauses = [];
|
|
1126
|
+
const names = {};
|
|
1127
|
+
const values = {};
|
|
1128
|
+
let counter = 0;
|
|
1129
|
+
// Add user-provided updates
|
|
1130
|
+
for (const [attr, val] of Object.entries(hydratedUpdates)) {
|
|
1131
|
+
if (val === undefined)
|
|
1132
|
+
continue;
|
|
1133
|
+
const nameKey = `#u${counter}`;
|
|
1134
|
+
const valKey = `:u${counter}`;
|
|
1135
|
+
names[nameKey] = resolveDbName(attr);
|
|
1136
|
+
values[valKey] = toAttributeValue(val);
|
|
1137
|
+
setClauses.push(`${nameKey} = ${valKey}`);
|
|
1138
|
+
counter++;
|
|
1139
|
+
}
|
|
1140
|
+
// Shared sets for GSI removal detection (used by both compose and REMOVE)
|
|
1141
|
+
const removedSet = uState.remove ? new Set(uState.remove) : undefined;
|
|
1142
|
+
const pkCompositeSet = new Set(primaryKeyComposites(config.indexes));
|
|
1143
|
+
// Compose GSI keys for indexes whose composites are in the update,
|
|
1144
|
+
// filtering out any GSI whose composites are targeted by REMOVE
|
|
1145
|
+
const indexesForGsiUpdate = removedSet
|
|
1146
|
+
? (() => {
|
|
1147
|
+
const filtered = {};
|
|
1148
|
+
for (const [indexName, indexDef] of Object.entries(allIndexes)) {
|
|
1149
|
+
if (!("index" in indexDef)) {
|
|
1150
|
+
filtered[indexName] = indexDef;
|
|
1151
|
+
continue;
|
|
1152
|
+
}
|
|
1153
|
+
const gsiComposites = [...indexDef.pk.composite, ...indexDef.sk.composite].filter((attr) => !pkCompositeSet.has(attr));
|
|
1154
|
+
if (!gsiComposites.some((attr) => removedSet.has(attr))) {
|
|
1155
|
+
filtered[indexName] = indexDef;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
return filtered;
|
|
1159
|
+
})()
|
|
1160
|
+
: allIndexes;
|
|
1161
|
+
let gsiKeys;
|
|
1162
|
+
try {
|
|
1163
|
+
gsiKeys = KeyComposer.composeGsiKeysForUpdate(schema, entityType, entityVersion, indexesForGsiUpdate, hydratedUpdates, decodedKey);
|
|
1164
|
+
}
|
|
1165
|
+
catch (e) {
|
|
1166
|
+
return yield* new ValidationError({
|
|
1167
|
+
entityType,
|
|
1168
|
+
operation: "update.gsiComposites",
|
|
1169
|
+
cause: e instanceof Error ? e.message : e,
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
for (const [field, value] of Object.entries(gsiKeys)) {
|
|
1173
|
+
const nameKey = `#u${counter}`;
|
|
1174
|
+
const valKey = `:u${counter}`;
|
|
1175
|
+
names[nameKey] = field;
|
|
1176
|
+
values[valKey] = toAttributeValue(value);
|
|
1177
|
+
setClauses.push(`${nameKey} = ${valKey}`);
|
|
1178
|
+
counter++;
|
|
1179
|
+
}
|
|
1180
|
+
// Add updatedAt timestamp (encoding-aware)
|
|
1181
|
+
if (systemFields.updatedAt) {
|
|
1182
|
+
const nameKey = `#u${counter}`;
|
|
1183
|
+
const valKey = `:u${counter}`;
|
|
1184
|
+
names[nameKey] = systemFields.updatedAt;
|
|
1185
|
+
values[valKey] = toAttributeValue(generateTimestamp(systemFields.updatedAtEncoding));
|
|
1186
|
+
setClauses.push(`${nameKey} = ${valKey}`);
|
|
1187
|
+
counter++;
|
|
1188
|
+
}
|
|
1189
|
+
// Add version increment
|
|
1190
|
+
if (systemFields.version) {
|
|
1191
|
+
const nameKey = `#u${counter}`;
|
|
1192
|
+
names[nameKey] = systemFields.version;
|
|
1193
|
+
setClauses.push(`${nameKey} = ${nameKey} + :vinc`);
|
|
1194
|
+
values[":vinc"] = toAttributeValue(1);
|
|
1195
|
+
counter++;
|
|
1196
|
+
}
|
|
1197
|
+
// Rich update operations from state
|
|
1198
|
+
const removeClauses = [];
|
|
1199
|
+
const addClauses = [];
|
|
1200
|
+
const deleteClauses = [];
|
|
1201
|
+
// SUBTRACT → synthesize SET #field = #field - :val
|
|
1202
|
+
if (uState.subtract) {
|
|
1203
|
+
for (const [attr, val] of Object.entries(uState.subtract)) {
|
|
1204
|
+
const nameKey = `#u${counter}`;
|
|
1205
|
+
const valKey = `:u${counter}`;
|
|
1206
|
+
names[nameKey] = resolveDbName(attr);
|
|
1207
|
+
values[valKey] = toAttributeValue(val);
|
|
1208
|
+
setClauses.push(`${nameKey} = ${nameKey} - ${valKey}`);
|
|
1209
|
+
counter++;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
// APPEND → synthesize SET #field = list_append(#field, :val)
|
|
1213
|
+
if (uState.append) {
|
|
1214
|
+
for (const [attr, val] of Object.entries(uState.append)) {
|
|
1215
|
+
const nameKey = `#u${counter}`;
|
|
1216
|
+
const valKey = `:u${counter}`;
|
|
1217
|
+
names[nameKey] = resolveDbName(attr);
|
|
1218
|
+
values[valKey] = toAttributeValue(val);
|
|
1219
|
+
setClauses.push(`${nameKey} = list_append(${nameKey}, ${valKey})`);
|
|
1220
|
+
counter++;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
// REMOVE
|
|
1224
|
+
if (uState.remove) {
|
|
1225
|
+
for (const attr of uState.remove) {
|
|
1226
|
+
const nameKey = `#r${removeClauses.length}`;
|
|
1227
|
+
names[nameKey] = resolveDbName(attr);
|
|
1228
|
+
removeClauses.push(nameKey);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
// Cascade REMOVE to GSI key fields: when a removed attribute is a GSI
|
|
1232
|
+
// composite, the GSI pk/sk fields must also be removed so the item drops
|
|
1233
|
+
// out of the sparse index.
|
|
1234
|
+
if (removedSet) {
|
|
1235
|
+
for (const [, indexDef] of Object.entries(allIndexes)) {
|
|
1236
|
+
if (!("index" in indexDef))
|
|
1237
|
+
continue;
|
|
1238
|
+
const gsiComposites = [...indexDef.pk.composite, ...indexDef.sk.composite].filter((attr) => !pkCompositeSet.has(attr));
|
|
1239
|
+
if (gsiComposites.some((attr) => removedSet.has(attr))) {
|
|
1240
|
+
for (const keyField of [indexDef.pk.field, indexDef.sk.field]) {
|
|
1241
|
+
const nameKey = `#r${removeClauses.length}`;
|
|
1242
|
+
names[nameKey] = keyField;
|
|
1243
|
+
removeClauses.push(nameKey);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
// ADD (atomic numeric increment / set addition)
|
|
1249
|
+
if (uState.add) {
|
|
1250
|
+
for (const [attr, val] of Object.entries(uState.add)) {
|
|
1251
|
+
const nameKey = `#a${addClauses.length}`;
|
|
1252
|
+
const valKey = `:a${addClauses.length}`;
|
|
1253
|
+
names[nameKey] = resolveDbName(attr);
|
|
1254
|
+
values[valKey] = toAttributeValue(val);
|
|
1255
|
+
addClauses.push(`${nameKey} ${valKey}`);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
// DELETE (remove elements from set)
|
|
1259
|
+
if (uState.deleteFromSet) {
|
|
1260
|
+
for (const [attr, val] of Object.entries(uState.deleteFromSet)) {
|
|
1261
|
+
const nameKey = `#d${deleteClauses.length}`;
|
|
1262
|
+
const valKey = `:d${deleteClauses.length}`;
|
|
1263
|
+
names[nameKey] = resolveDbName(attr);
|
|
1264
|
+
values[valKey] = toAttributeValue(val);
|
|
1265
|
+
deleteClauses.push(`${nameKey} ${valKey}`);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
// Path-based operations (from typed callback API)
|
|
1269
|
+
const pathCounter = { value: 0 };
|
|
1270
|
+
// Path SET operations
|
|
1271
|
+
if (uState.pathSets) {
|
|
1272
|
+
for (const op of uState.pathSets) {
|
|
1273
|
+
const pathExpr = compilePath(op.segments, names, "ps", pathCounter, resolveDbName);
|
|
1274
|
+
if (op.isPath && op.valueSegments) {
|
|
1275
|
+
const srcExpr = compilePath(op.valueSegments, names, "ps", pathCounter, resolveDbName);
|
|
1276
|
+
setClauses.push(`${pathExpr} = ${srcExpr}`);
|
|
1277
|
+
}
|
|
1278
|
+
else {
|
|
1279
|
+
const valKey = `:ps${pathCounter.value++}`;
|
|
1280
|
+
values[valKey] = toAttributeValue(op.value);
|
|
1281
|
+
setClauses.push(`${pathExpr} = ${valKey}`);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
// Path SUBTRACT operations
|
|
1286
|
+
if (uState.pathSubtracts) {
|
|
1287
|
+
for (const op of uState.pathSubtracts) {
|
|
1288
|
+
const pathExpr = compilePath(op.segments, names, "psb", pathCounter, resolveDbName);
|
|
1289
|
+
if (op.isPath && op.valueSegments) {
|
|
1290
|
+
const srcExpr = compilePath(op.valueSegments, names, "psb", pathCounter, resolveDbName);
|
|
1291
|
+
setClauses.push(`${pathExpr} = ${pathExpr} - ${srcExpr}`);
|
|
1292
|
+
}
|
|
1293
|
+
else {
|
|
1294
|
+
const valKey = `:psb${pathCounter.value++}`;
|
|
1295
|
+
values[valKey] = toAttributeValue(op.value);
|
|
1296
|
+
setClauses.push(`${pathExpr} = ${pathExpr} - ${valKey}`);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
// Path APPEND operations
|
|
1301
|
+
if (uState.pathAppends) {
|
|
1302
|
+
for (const op of uState.pathAppends) {
|
|
1303
|
+
const pathExpr = compilePath(op.segments, names, "pa", pathCounter, resolveDbName);
|
|
1304
|
+
const valKey = `:pa${pathCounter.value++}`;
|
|
1305
|
+
values[valKey] = toAttributeValue(op.value);
|
|
1306
|
+
setClauses.push(`${pathExpr} = list_append(${pathExpr}, ${valKey})`);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
// Path PREPEND operations
|
|
1310
|
+
if (uState.pathPrepends) {
|
|
1311
|
+
for (const op of uState.pathPrepends) {
|
|
1312
|
+
const pathExpr = compilePath(op.segments, names, "pp", pathCounter, resolveDbName);
|
|
1313
|
+
const valKey = `:pp${pathCounter.value++}`;
|
|
1314
|
+
values[valKey] = toAttributeValue(op.value);
|
|
1315
|
+
setClauses.push(`${pathExpr} = list_append(${valKey}, ${pathExpr})`);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
// Path if_not_exists operations
|
|
1319
|
+
if (uState.pathIfNotExists) {
|
|
1320
|
+
for (const op of uState.pathIfNotExists) {
|
|
1321
|
+
const pathExpr = compilePath(op.segments, names, "pi", pathCounter, resolveDbName);
|
|
1322
|
+
const valKey = `:pi${pathCounter.value++}`;
|
|
1323
|
+
values[valKey] = toAttributeValue(op.value);
|
|
1324
|
+
setClauses.push(`${pathExpr} = if_not_exists(${pathExpr}, ${valKey})`);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
// Path REMOVE operations
|
|
1328
|
+
if (uState.pathRemoves) {
|
|
1329
|
+
for (const segments of uState.pathRemoves) {
|
|
1330
|
+
const pathExpr = compilePath(segments, names, "pr", pathCounter, resolveDbName);
|
|
1331
|
+
removeClauses.push(pathExpr);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
// Path ADD operations
|
|
1335
|
+
if (uState.pathAdds) {
|
|
1336
|
+
for (const op of uState.pathAdds) {
|
|
1337
|
+
const pathExpr = compilePath(op.segments, names, "pad", pathCounter, resolveDbName);
|
|
1338
|
+
const valKey = `:pad${pathCounter.value++}`;
|
|
1339
|
+
values[valKey] = toAttributeValue(op.value);
|
|
1340
|
+
addClauses.push(`${pathExpr} ${valKey}`);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
// Path DELETE operations
|
|
1344
|
+
if (uState.pathDeletes) {
|
|
1345
|
+
for (const op of uState.pathDeletes) {
|
|
1346
|
+
const pathExpr = compilePath(op.segments, names, "pd", pathCounter, resolveDbName);
|
|
1347
|
+
const valKey = `:pd${pathCounter.value++}`;
|
|
1348
|
+
values[valKey] = toAttributeValue(op.value);
|
|
1349
|
+
deleteClauses.push(`${pathExpr} ${valKey}`);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
const hasAnyUpdate = setClauses.length > 0 ||
|
|
1353
|
+
removeClauses.length > 0 ||
|
|
1354
|
+
addClauses.length > 0 ||
|
|
1355
|
+
deleteClauses.length > 0;
|
|
1356
|
+
if (!hasAnyUpdate) {
|
|
1357
|
+
// Nothing to update — just get the current item
|
|
1358
|
+
return yield* get(key)._run(mode);
|
|
1359
|
+
}
|
|
1360
|
+
// Compose UpdateExpression from all clause types
|
|
1361
|
+
const expressionParts = [];
|
|
1362
|
+
if (setClauses.length > 0)
|
|
1363
|
+
expressionParts.push(`SET ${setClauses.join(", ")}`);
|
|
1364
|
+
if (removeClauses.length > 0)
|
|
1365
|
+
expressionParts.push(`REMOVE ${removeClauses.join(", ")}`);
|
|
1366
|
+
if (addClauses.length > 0)
|
|
1367
|
+
expressionParts.push(`ADD ${addClauses.join(", ")}`);
|
|
1368
|
+
if (deleteClauses.length > 0)
|
|
1369
|
+
expressionParts.push(`DELETE ${deleteClauses.join(", ")}`);
|
|
1370
|
+
const updateExpression = expressionParts.join(" ");
|
|
1371
|
+
// Build condition expression — combine optimistic lock + user condition
|
|
1372
|
+
const condParts = [];
|
|
1373
|
+
if (evExpected !== undefined && systemFields.version) {
|
|
1374
|
+
names["#condVer"] = systemFields.version;
|
|
1375
|
+
values[":expectedVer"] = toAttributeValue(evExpected);
|
|
1376
|
+
condParts.push("#condVer = :expectedVer");
|
|
1377
|
+
}
|
|
1378
|
+
if (userCond) {
|
|
1379
|
+
const uc = compileCondition(userCond, resolveDbName);
|
|
1380
|
+
condParts.push(`(${uc.expression})`);
|
|
1381
|
+
Object.assign(names, uc.names);
|
|
1382
|
+
Object.assign(values, uc.values);
|
|
1383
|
+
}
|
|
1384
|
+
const conditionExpression = condParts.length > 0 ? condParts.join(" AND ") : undefined;
|
|
1385
|
+
const result = yield* client
|
|
1386
|
+
.updateItem({
|
|
1387
|
+
TableName: tableName,
|
|
1388
|
+
Key: marshalledKey,
|
|
1389
|
+
UpdateExpression: updateExpression,
|
|
1390
|
+
ExpressionAttributeNames: names,
|
|
1391
|
+
ExpressionAttributeValues: values,
|
|
1392
|
+
ConditionExpression: conditionExpression,
|
|
1393
|
+
ReturnValues: returnValuesMap[uState.returnValues ?? "allNew"],
|
|
1394
|
+
})
|
|
1395
|
+
.pipe(Effect.mapError((err) => {
|
|
1396
|
+
if (isAwsConditionalCheckFailed(err.cause)) {
|
|
1397
|
+
if (evExpected !== undefined) {
|
|
1398
|
+
return new OptimisticLockError({
|
|
1399
|
+
entityType,
|
|
1400
|
+
key: decodedKey,
|
|
1401
|
+
expectedVersion: evExpected,
|
|
1402
|
+
actualVersion: -1,
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
if (userCond) {
|
|
1406
|
+
return new ConditionalCheckFailed({
|
|
1407
|
+
entityType,
|
|
1408
|
+
key: decodedKey,
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
return err;
|
|
1413
|
+
}));
|
|
1414
|
+
if (!result.Attributes) {
|
|
1415
|
+
return yield* new ItemNotFound({ entityType, key: decodedKey });
|
|
1416
|
+
}
|
|
1417
|
+
const raw = fromAttributeMap(result.Attributes);
|
|
1418
|
+
const decoded = yield* decodeAs(raw, result.Attributes, mode);
|
|
1419
|
+
// Execute cascade if configured
|
|
1420
|
+
if (uState.cascade) {
|
|
1421
|
+
const domainData = { ...decoded };
|
|
1422
|
+
const sourceId = sourceIdentifierField != null
|
|
1423
|
+
? decodedKey[sourceIdentifierField]
|
|
1424
|
+
: undefined;
|
|
1425
|
+
if (sourceId != null) {
|
|
1426
|
+
yield* executeCascade(uState.cascade, domainData, String(sourceId));
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
return decoded;
|
|
1430
|
+
}), emptyUpdateState, self, key);
|
|
1431
|
+
// ---------------------------------------------------------------------------
|
|
1432
|
+
// delete operation
|
|
1433
|
+
// ---------------------------------------------------------------------------
|
|
1434
|
+
const del = (key) => new EntityDeleteImpl((opts) => Effect.gen(function* () {
|
|
1435
|
+
const client = yield* DynamoClient;
|
|
1436
|
+
const { name: tableName } = yield* tableTag;
|
|
1437
|
+
// Decode key
|
|
1438
|
+
const decodedKey = yield* Schema.decodeUnknownEffect(schemas.keySchema)(key).pipe(Effect.mapError((cause) => new ValidationError({
|
|
1439
|
+
entityType,
|
|
1440
|
+
operation: "delete.decode",
|
|
1441
|
+
cause,
|
|
1442
|
+
})));
|
|
1443
|
+
// Compose primary key
|
|
1444
|
+
const primaryKey = composePrimaryKey(decodedKey);
|
|
1445
|
+
const marshalledKey = toAttributeMap(primaryKey);
|
|
1446
|
+
const primary = config.indexes.primary;
|
|
1447
|
+
const hasUniqueConstraints = config.unique != null && Object.keys(config.unique).length > 0;
|
|
1448
|
+
// Build user condition expression if provided
|
|
1449
|
+
const userCondition = opts.condition
|
|
1450
|
+
? compileCondition(opts.condition, resolveDbName)
|
|
1451
|
+
: undefined;
|
|
1452
|
+
if (isSoftDeleteEnabled()) {
|
|
1453
|
+
// --- Soft delete path ---
|
|
1454
|
+
// Read current item
|
|
1455
|
+
const result = yield* client.getItem({
|
|
1456
|
+
TableName: tableName,
|
|
1457
|
+
Key: marshalledKey,
|
|
1458
|
+
});
|
|
1459
|
+
if (!result.Item) {
|
|
1460
|
+
return yield* Effect.fail(new ItemNotFound({ entityType, key: decodedKey }));
|
|
1461
|
+
}
|
|
1462
|
+
const raw = fromAttributeMap(result.Item);
|
|
1463
|
+
const now = nowIso();
|
|
1464
|
+
// Build soft-deleted item: same PK, replace SK with deleted key, strip GSI keys
|
|
1465
|
+
const deletedItem = { ...raw };
|
|
1466
|
+
// Strip GSI key fields — soft-deleted items must not appear in index queries
|
|
1467
|
+
for (const field of gsiKeyFields()) {
|
|
1468
|
+
delete deletedItem[field];
|
|
1469
|
+
}
|
|
1470
|
+
// Replace SK with deleted sort key
|
|
1471
|
+
deletedItem[primary.sk.field] = DynamoSchema.composeDeletedKey(schema, entityType, now);
|
|
1472
|
+
// Add deletedAt
|
|
1473
|
+
deletedItem.deletedAt = now;
|
|
1474
|
+
// Add optional TTL
|
|
1475
|
+
const sdTtl = softDeleteTtl();
|
|
1476
|
+
if (sdTtl) {
|
|
1477
|
+
deletedItem._ttl = Math.floor(Date.now() / 1000) + Duration.toSeconds(sdTtl);
|
|
1478
|
+
}
|
|
1479
|
+
const transactItems = [];
|
|
1480
|
+
// Delete current entity item
|
|
1481
|
+
transactItems.push({
|
|
1482
|
+
Delete: { TableName: tableName, Key: marshalledKey },
|
|
1483
|
+
});
|
|
1484
|
+
// Put soft-deleted item
|
|
1485
|
+
transactItems.push({
|
|
1486
|
+
Put: {
|
|
1487
|
+
TableName: tableName,
|
|
1488
|
+
Item: toAttributeMap(deletedItem),
|
|
1489
|
+
},
|
|
1490
|
+
});
|
|
1491
|
+
// Version snapshot if retain is enabled
|
|
1492
|
+
if (isRetainEnabled()) {
|
|
1493
|
+
const currentVersion = systemFields.version
|
|
1494
|
+
? raw[systemFields.version]
|
|
1495
|
+
: 0;
|
|
1496
|
+
const snapshotItem = buildSnapshotItem(raw, currentVersion, primary.pk.field, primary.sk.field);
|
|
1497
|
+
transactItems.push({
|
|
1498
|
+
Put: {
|
|
1499
|
+
TableName: tableName,
|
|
1500
|
+
Item: toAttributeMap(snapshotItem),
|
|
1501
|
+
},
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
// Delete sentinels if not preserving unique
|
|
1505
|
+
if (hasUniqueConstraints && !preserveUnique()) {
|
|
1506
|
+
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
1507
|
+
const fields = resolveUniqueFields(constraintDef);
|
|
1508
|
+
const fieldValues = fields.map((f) => KeyComposer.serializeValue(raw[f]));
|
|
1509
|
+
const uniqueKey = DynamoSchema.composeUniqueKey(schema, entityType, constraintName, fieldValues);
|
|
1510
|
+
transactItems.push({
|
|
1511
|
+
Delete: {
|
|
1512
|
+
TableName: tableName,
|
|
1513
|
+
Key: toAttributeMap({
|
|
1514
|
+
[primary.pk.field]: uniqueKey.pk,
|
|
1515
|
+
[primary.sk.field]: uniqueKey.sk,
|
|
1516
|
+
}),
|
|
1517
|
+
},
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
yield* checkTransactionLimit(entityType, "delete", transactItems);
|
|
1522
|
+
yield* client.transactWriteItems({
|
|
1523
|
+
TransactItems: transactItems,
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
else if (hasUniqueConstraints) {
|
|
1527
|
+
// --- Hard delete with unique constraints ---
|
|
1528
|
+
// First get the item to find sentinel key values
|
|
1529
|
+
const result = yield* client.getItem({
|
|
1530
|
+
TableName: tableName,
|
|
1531
|
+
Key: marshalledKey,
|
|
1532
|
+
});
|
|
1533
|
+
if (!result.Item) {
|
|
1534
|
+
return yield* Effect.fail(new ItemNotFound({ entityType, key: decodedKey }));
|
|
1535
|
+
}
|
|
1536
|
+
const raw = fromAttributeMap(result.Item);
|
|
1537
|
+
const transactItems = [];
|
|
1538
|
+
// Delete entity item
|
|
1539
|
+
transactItems.push({
|
|
1540
|
+
Delete: { TableName: tableName, Key: marshalledKey },
|
|
1541
|
+
});
|
|
1542
|
+
// Delete sentinels
|
|
1543
|
+
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
1544
|
+
const fields = resolveUniqueFields(constraintDef);
|
|
1545
|
+
const fieldValues = fields.map((f) => KeyComposer.serializeValue(raw[f]));
|
|
1546
|
+
const uniqueKey = DynamoSchema.composeUniqueKey(schema, entityType, constraintName, fieldValues);
|
|
1547
|
+
transactItems.push({
|
|
1548
|
+
Delete: {
|
|
1549
|
+
TableName: tableName,
|
|
1550
|
+
Key: toAttributeMap({
|
|
1551
|
+
[primary.pk.field]: uniqueKey.pk,
|
|
1552
|
+
[primary.sk.field]: uniqueKey.sk,
|
|
1553
|
+
}),
|
|
1554
|
+
},
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
yield* checkTransactionLimit(entityType, "delete", transactItems);
|
|
1558
|
+
yield* client.transactWriteItems({
|
|
1559
|
+
TransactItems: transactItems.map((t) => ({ Delete: t.Delete })),
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
else {
|
|
1563
|
+
// Simple delete
|
|
1564
|
+
const deleteInput = {
|
|
1565
|
+
TableName: tableName,
|
|
1566
|
+
Key: marshalledKey,
|
|
1567
|
+
};
|
|
1568
|
+
if (userCondition) {
|
|
1569
|
+
deleteInput.ConditionExpression = userCondition.expression;
|
|
1570
|
+
deleteInput.ExpressionAttributeNames = userCondition.names;
|
|
1571
|
+
if (Object.keys(userCondition.values).length > 0) {
|
|
1572
|
+
deleteInput.ExpressionAttributeValues = userCondition.values;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
if (opts.returnValues) {
|
|
1576
|
+
deleteInput.ReturnValues = returnValuesMap[opts.returnValues];
|
|
1577
|
+
}
|
|
1578
|
+
yield* client.deleteItem(deleteInput).pipe(Effect.mapError((err) => {
|
|
1579
|
+
if (opts.condition && isAwsConditionalCheckFailed(err.cause)) {
|
|
1580
|
+
return new ConditionalCheckFailed({
|
|
1581
|
+
entityType,
|
|
1582
|
+
key: decodedKey,
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
return err;
|
|
1586
|
+
}));
|
|
1587
|
+
}
|
|
1588
|
+
}), self, key);
|
|
1589
|
+
// ---------------------------------------------------------------------------
|
|
1590
|
+
// deleteIfExists operation — delete + attribute_exists condition
|
|
1591
|
+
// ---------------------------------------------------------------------------
|
|
1592
|
+
const deleteIfExists = (key) => {
|
|
1593
|
+
const pkField = config.indexes.primary.pk.field;
|
|
1594
|
+
const op = del(key);
|
|
1595
|
+
return new EntityDeleteImpl(op._builder, op._entity, op._key, { attributeExists: [pkField] }, op._returnValues);
|
|
1596
|
+
};
|
|
1597
|
+
// ---------------------------------------------------------------------------
|
|
1598
|
+
// upsert operation — create-or-update via UpdateItem with if_not_exists()
|
|
1599
|
+
// ---------------------------------------------------------------------------
|
|
1600
|
+
const upsert = (input) => new EntityPutImpl((mode, opts) => Effect.gen(function* () {
|
|
1601
|
+
const client = yield* DynamoClient;
|
|
1602
|
+
const { name: tableName } = yield* tableTag;
|
|
1603
|
+
// Decode input
|
|
1604
|
+
const decodedInput = yield* Schema.decodeUnknownEffect(schemas.inputSchema)(input).pipe(Effect.mapError((cause) => new ValidationError({
|
|
1605
|
+
entityType,
|
|
1606
|
+
operation: "upsert.decode",
|
|
1607
|
+
cause,
|
|
1608
|
+
})));
|
|
1609
|
+
// Hydrate refs: replace ID fields with full entity domain data
|
|
1610
|
+
const decoded = hasRefs
|
|
1611
|
+
? yield* hydrateRefs(decodedInput)
|
|
1612
|
+
: decodedInput;
|
|
1613
|
+
const item = decoded;
|
|
1614
|
+
// Compose primary key
|
|
1615
|
+
const primaryKey = composePrimaryKey(item);
|
|
1616
|
+
const marshalledKey = toAttributeMap(primaryKey);
|
|
1617
|
+
// Build UpdateExpression with if_not_exists for immutable fields + createdAt
|
|
1618
|
+
const setClauses = [];
|
|
1619
|
+
const names = {};
|
|
1620
|
+
const values = {};
|
|
1621
|
+
let counter = 0;
|
|
1622
|
+
// Determine primary key composite attribute names
|
|
1623
|
+
const pkComposites = new Set(primaryKeyComposites(config.indexes));
|
|
1624
|
+
// Serialize date fields on the decoded item
|
|
1625
|
+
if (hasDateFields) {
|
|
1626
|
+
serializeDateFields(item);
|
|
1627
|
+
}
|
|
1628
|
+
// All model fields (excluding PK composites, which are in the Key)
|
|
1629
|
+
for (const [attr, val] of Object.entries(item)) {
|
|
1630
|
+
if (pkComposites.has(attr))
|
|
1631
|
+
continue;
|
|
1632
|
+
if (val === undefined)
|
|
1633
|
+
continue;
|
|
1634
|
+
const nameKey = `#u${counter}`;
|
|
1635
|
+
const valKey = `:u${counter}`;
|
|
1636
|
+
names[nameKey] = resolveDbName(attr);
|
|
1637
|
+
values[valKey] = toAttributeValue(val);
|
|
1638
|
+
// Immutable fields use if_not_exists — only set on first create
|
|
1639
|
+
if (immutableFields.has(attr)) {
|
|
1640
|
+
setClauses.push(`${nameKey} = if_not_exists(${nameKey}, ${valKey})`);
|
|
1641
|
+
}
|
|
1642
|
+
else {
|
|
1643
|
+
setClauses.push(`${nameKey} = ${valKey}`);
|
|
1644
|
+
}
|
|
1645
|
+
counter++;
|
|
1646
|
+
}
|
|
1647
|
+
// Add all index keys (including GSIs)
|
|
1648
|
+
const allKeys = composeAllKeys(item);
|
|
1649
|
+
for (const [field, value] of Object.entries(allKeys)) {
|
|
1650
|
+
// Skip primary key fields (they're in Key, not UpdateExpression)
|
|
1651
|
+
if (field === config.indexes.primary.pk.field ||
|
|
1652
|
+
field === config.indexes.primary.sk.field)
|
|
1653
|
+
continue;
|
|
1654
|
+
const nameKey = `#u${counter}`;
|
|
1655
|
+
const valKey = `:u${counter}`;
|
|
1656
|
+
names[nameKey] = field;
|
|
1657
|
+
values[valKey] = toAttributeValue(value);
|
|
1658
|
+
setClauses.push(`${nameKey} = ${valKey}`);
|
|
1659
|
+
counter++;
|
|
1660
|
+
}
|
|
1661
|
+
// Add entity type discriminator
|
|
1662
|
+
{
|
|
1663
|
+
const nameKey = `#u${counter}`;
|
|
1664
|
+
const valKey = `:u${counter}`;
|
|
1665
|
+
names[nameKey] = "__edd_e__";
|
|
1666
|
+
values[valKey] = toAttributeValue(entityType);
|
|
1667
|
+
setClauses.push(`${nameKey} = ${valKey}`);
|
|
1668
|
+
counter++;
|
|
1669
|
+
}
|
|
1670
|
+
// Add createdAt with if_not_exists — only set on first create
|
|
1671
|
+
if (systemFields.createdAt) {
|
|
1672
|
+
const nameKey = `#u${counter}`;
|
|
1673
|
+
const valKey = `:u${counter}`;
|
|
1674
|
+
names[nameKey] = systemFields.createdAt;
|
|
1675
|
+
values[valKey] = toAttributeValue(generateTimestamp(systemFields.createdAtEncoding));
|
|
1676
|
+
setClauses.push(`${nameKey} = if_not_exists(${nameKey}, ${valKey})`);
|
|
1677
|
+
counter++;
|
|
1678
|
+
}
|
|
1679
|
+
// Add updatedAt — always set to current time
|
|
1680
|
+
if (systemFields.updatedAt) {
|
|
1681
|
+
const nameKey = `#u${counter}`;
|
|
1682
|
+
const valKey = `:u${counter}`;
|
|
1683
|
+
names[nameKey] = systemFields.updatedAt;
|
|
1684
|
+
values[valKey] = toAttributeValue(generateTimestamp(systemFields.updatedAtEncoding));
|
|
1685
|
+
setClauses.push(`${nameKey} = ${valKey}`);
|
|
1686
|
+
counter++;
|
|
1687
|
+
}
|
|
1688
|
+
// Add version: if_not_exists(version, 0) + 1
|
|
1689
|
+
if (systemFields.version) {
|
|
1690
|
+
const nameKey = `#u${counter}`;
|
|
1691
|
+
const zeroKey = `:u${counter}z`;
|
|
1692
|
+
names[nameKey] = systemFields.version;
|
|
1693
|
+
values[zeroKey] = toAttributeValue(0);
|
|
1694
|
+
values[":vinc"] = toAttributeValue(1);
|
|
1695
|
+
setClauses.push(`${nameKey} = if_not_exists(${nameKey}, ${zeroKey}) + :vinc`);
|
|
1696
|
+
counter++;
|
|
1697
|
+
}
|
|
1698
|
+
const updateExpression = `SET ${setClauses.join(", ")}`;
|
|
1699
|
+
// Optional user condition
|
|
1700
|
+
const condParts = [];
|
|
1701
|
+
if (opts.condition) {
|
|
1702
|
+
const uc = compileCondition(opts.condition, resolveDbName);
|
|
1703
|
+
condParts.push(`(${uc.expression})`);
|
|
1704
|
+
Object.assign(names, uc.names);
|
|
1705
|
+
Object.assign(values, uc.values);
|
|
1706
|
+
}
|
|
1707
|
+
const result = yield* client
|
|
1708
|
+
.updateItem({
|
|
1709
|
+
TableName: tableName,
|
|
1710
|
+
Key: marshalledKey,
|
|
1711
|
+
UpdateExpression: updateExpression,
|
|
1712
|
+
ExpressionAttributeNames: names,
|
|
1713
|
+
ExpressionAttributeValues: values,
|
|
1714
|
+
ConditionExpression: condParts.length > 0 ? condParts.join(" AND ") : undefined,
|
|
1715
|
+
ReturnValues: "ALL_NEW",
|
|
1716
|
+
})
|
|
1717
|
+
.pipe(Effect.mapError((err) => {
|
|
1718
|
+
if (opts.condition && isAwsConditionalCheckFailed(err.cause)) {
|
|
1719
|
+
return new ConditionalCheckFailed({ entityType, key: item });
|
|
1720
|
+
}
|
|
1721
|
+
return err;
|
|
1722
|
+
}));
|
|
1723
|
+
if (!result.Attributes) {
|
|
1724
|
+
return yield* new ItemNotFound({ entityType, key: item });
|
|
1725
|
+
}
|
|
1726
|
+
const raw = fromAttributeMap(result.Attributes);
|
|
1727
|
+
return yield* decodeAs(raw, result.Attributes, mode);
|
|
1728
|
+
}), self, input);
|
|
1729
|
+
// ---------------------------------------------------------------------------
|
|
1730
|
+
// query namespace
|
|
1731
|
+
// ---------------------------------------------------------------------------
|
|
1732
|
+
const queryNamespace = {};
|
|
1733
|
+
for (const indexName of Object.keys(config.indexes)) {
|
|
1734
|
+
if (indexName === "primary")
|
|
1735
|
+
continue;
|
|
1736
|
+
const indexDef = config.indexes[indexName];
|
|
1737
|
+
queryNamespace[indexName] = (pk) => {
|
|
1738
|
+
const pkValue = KeyComposer.composePk(schema, entityType, indexDef, pk);
|
|
1739
|
+
const hasSkComposites = indexDef.sk.composite.some((attr) => pk[attr] !== undefined);
|
|
1740
|
+
const query = Query.make({
|
|
1741
|
+
tableName: "",
|
|
1742
|
+
indexName: indexDef.index,
|
|
1743
|
+
pkField: indexDef.pk.field,
|
|
1744
|
+
pkValue,
|
|
1745
|
+
skField: indexDef.sk.field,
|
|
1746
|
+
entityTypes: [entityType],
|
|
1747
|
+
decoder: (raw) => decodeRecord(raw),
|
|
1748
|
+
resolveTableName: tableTag.useSync((tc) => tc.name),
|
|
1749
|
+
});
|
|
1750
|
+
if (hasSkComposites) {
|
|
1751
|
+
const skPrefix = KeyComposer.composeSortKeyPrefix(schema, entityType, entityVersion, indexDef, pk);
|
|
1752
|
+
return Query.where(query, { beginsWith: skPrefix });
|
|
1753
|
+
}
|
|
1754
|
+
return query;
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
// ---------------------------------------------------------------------------
|
|
1758
|
+
// scan operation
|
|
1759
|
+
// ---------------------------------------------------------------------------
|
|
1760
|
+
const scan = () => Query.makeScan({
|
|
1761
|
+
tableName: "",
|
|
1762
|
+
indexName: undefined,
|
|
1763
|
+
entityTypes: [entityType],
|
|
1764
|
+
decoder: (raw) => decodeRecord(raw),
|
|
1765
|
+
resolveTableName: tableTag.useSync((tc) => tc.name),
|
|
1766
|
+
});
|
|
1767
|
+
// ---------------------------------------------------------------------------
|
|
1768
|
+
// getVersion operation
|
|
1769
|
+
// ---------------------------------------------------------------------------
|
|
1770
|
+
const getVersion = (key, versionNumber) => new EntityGetImpl((mode, opts) => Effect.gen(function* () {
|
|
1771
|
+
const client = yield* DynamoClient;
|
|
1772
|
+
const { name: tableName } = yield* tableTag;
|
|
1773
|
+
// Decode key
|
|
1774
|
+
const decodedKey = yield* Schema.decodeUnknownEffect(schemas.keySchema)(key).pipe(Effect.mapError((cause) => new ValidationError({
|
|
1775
|
+
entityType,
|
|
1776
|
+
operation: "getVersion.decode",
|
|
1777
|
+
cause,
|
|
1778
|
+
})));
|
|
1779
|
+
// Compose PK + version SK
|
|
1780
|
+
const primary = config.indexes.primary;
|
|
1781
|
+
const pkValue = KeyComposer.composePk(schema, entityType, primary, decodedKey);
|
|
1782
|
+
const versionSk = DynamoSchema.composeVersionKey(schema, entityType, versionNumber);
|
|
1783
|
+
const marshalledKey = toAttributeMap({
|
|
1784
|
+
[primary.pk.field]: pkValue,
|
|
1785
|
+
[primary.sk.field]: versionSk,
|
|
1786
|
+
});
|
|
1787
|
+
const result = yield* client.getItem({
|
|
1788
|
+
TableName: tableName,
|
|
1789
|
+
Key: marshalledKey,
|
|
1790
|
+
ConsistentRead: opts.consistentRead || undefined,
|
|
1791
|
+
});
|
|
1792
|
+
if (!result.Item) {
|
|
1793
|
+
return yield* new ItemNotFound({ entityType, key: decodedKey });
|
|
1794
|
+
}
|
|
1795
|
+
const raw = fromAttributeMap(result.Item);
|
|
1796
|
+
return yield* decodeAs(raw, result.Item, mode);
|
|
1797
|
+
}), self, key);
|
|
1798
|
+
// ---------------------------------------------------------------------------
|
|
1799
|
+
// versions operation
|
|
1800
|
+
// ---------------------------------------------------------------------------
|
|
1801
|
+
const versions = (key) => {
|
|
1802
|
+
const decodedKey = Schema.decodeUnknownSync(schemas.keySchema)(key);
|
|
1803
|
+
const primary = config.indexes.primary;
|
|
1804
|
+
const pkValue = KeyComposer.composePk(schema, entityType, primary, decodedKey);
|
|
1805
|
+
const versionPrefix = DynamoSchema.composeVersionKeyPrefix(schema, entityType);
|
|
1806
|
+
return Query.make({
|
|
1807
|
+
tableName: "",
|
|
1808
|
+
indexName: undefined,
|
|
1809
|
+
pkField: primary.pk.field,
|
|
1810
|
+
pkValue,
|
|
1811
|
+
skField: primary.sk.field,
|
|
1812
|
+
entityTypes: [entityType],
|
|
1813
|
+
decoder: (raw) => decodeRecord(raw),
|
|
1814
|
+
resolveTableName: tableTag.useSync((tc) => tc.name),
|
|
1815
|
+
}).pipe(Query.where({ beginsWith: versionPrefix }));
|
|
1816
|
+
};
|
|
1817
|
+
// ---------------------------------------------------------------------------
|
|
1818
|
+
// deleted namespace
|
|
1819
|
+
// ---------------------------------------------------------------------------
|
|
1820
|
+
const deletedGet = (key) => new EntityGetImpl((mode, _opts) => Effect.gen(function* () {
|
|
1821
|
+
const client = yield* DynamoClient;
|
|
1822
|
+
const { name: tableName } = yield* tableTag;
|
|
1823
|
+
// Decode key
|
|
1824
|
+
const decodedKey = yield* Schema.decodeUnknownEffect(schemas.keySchema)(key).pipe(Effect.mapError((cause) => new ValidationError({
|
|
1825
|
+
entityType,
|
|
1826
|
+
operation: "deleted.get.decode",
|
|
1827
|
+
cause,
|
|
1828
|
+
})));
|
|
1829
|
+
// Query for soft-deleted item using begins_with on deleted prefix
|
|
1830
|
+
const primary = config.indexes.primary;
|
|
1831
|
+
const pkValue = KeyComposer.composePk(schema, entityType, primary, decodedKey);
|
|
1832
|
+
const deletedPrefix = DynamoSchema.composeDeletedKeyPrefix(schema, entityType);
|
|
1833
|
+
const result = yield* client.query({
|
|
1834
|
+
TableName: tableName,
|
|
1835
|
+
KeyConditionExpression: "#pk = :pk AND begins_with(#sk, :skPrefix)",
|
|
1836
|
+
ExpressionAttributeNames: {
|
|
1837
|
+
"#pk": primary.pk.field,
|
|
1838
|
+
"#sk": primary.sk.field,
|
|
1839
|
+
},
|
|
1840
|
+
ExpressionAttributeValues: {
|
|
1841
|
+
":pk": toAttributeValue(pkValue),
|
|
1842
|
+
":skPrefix": toAttributeValue(deletedPrefix),
|
|
1843
|
+
},
|
|
1844
|
+
Limit: 1,
|
|
1845
|
+
ScanIndexForward: false,
|
|
1846
|
+
});
|
|
1847
|
+
if (!result.Items || result.Items.length === 0) {
|
|
1848
|
+
return yield* new ItemNotFound({ entityType, key: decodedKey });
|
|
1849
|
+
}
|
|
1850
|
+
const raw = fromAttributeMap(result.Items[0]);
|
|
1851
|
+
// Soft-deleted items have GSI keys stripped — always use deletedRecordSchema
|
|
1852
|
+
// (itemSchema would fail because it expects GSI key fields that aren't present)
|
|
1853
|
+
if (mode === "native")
|
|
1854
|
+
return result.Items[0];
|
|
1855
|
+
const targetSchema = schemas.deletedRecordSchema;
|
|
1856
|
+
return yield* Schema.decodeUnknownEffect(targetSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
|
|
1857
|
+
entityType,
|
|
1858
|
+
operation: "deleted.get.decode",
|
|
1859
|
+
cause,
|
|
1860
|
+
})));
|
|
1861
|
+
}), self, key);
|
|
1862
|
+
const deletedList = (key) => {
|
|
1863
|
+
const decodedKey = Schema.decodeUnknownSync(schemas.keySchema)(key);
|
|
1864
|
+
const primary = config.indexes.primary;
|
|
1865
|
+
const pkValue = KeyComposer.composePk(schema, entityType, primary, decodedKey);
|
|
1866
|
+
const deletedPrefix = DynamoSchema.composeDeletedKeyPrefix(schema, entityType);
|
|
1867
|
+
const decodeDeleted = (raw) => Schema.decodeUnknownEffect(schemas.deletedRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
|
|
1868
|
+
entityType,
|
|
1869
|
+
operation: "deleted.list.decode",
|
|
1870
|
+
cause,
|
|
1871
|
+
})));
|
|
1872
|
+
return Query.make({
|
|
1873
|
+
tableName: "",
|
|
1874
|
+
indexName: undefined,
|
|
1875
|
+
pkField: primary.pk.field,
|
|
1876
|
+
pkValue,
|
|
1877
|
+
skField: primary.sk.field,
|
|
1878
|
+
entityTypes: [],
|
|
1879
|
+
decoder: (raw) => decodeDeleted(raw),
|
|
1880
|
+
resolveTableName: tableTag.useSync((tc) => tc.name),
|
|
1881
|
+
}).pipe(Query.where({ beginsWith: deletedPrefix }));
|
|
1882
|
+
};
|
|
1883
|
+
// ---------------------------------------------------------------------------
|
|
1884
|
+
// restore operation
|
|
1885
|
+
// ---------------------------------------------------------------------------
|
|
1886
|
+
const restore = (key) => new EntityGetImpl((mode, _opts) => Effect.gen(function* () {
|
|
1887
|
+
const client = yield* DynamoClient;
|
|
1888
|
+
const { name: tableName } = yield* tableTag;
|
|
1889
|
+
// Decode key
|
|
1890
|
+
const decodedKey = yield* Schema.decodeUnknownEffect(schemas.keySchema)(key).pipe(Effect.mapError((cause) => new ValidationError({
|
|
1891
|
+
entityType,
|
|
1892
|
+
operation: "restore.decode",
|
|
1893
|
+
cause,
|
|
1894
|
+
})));
|
|
1895
|
+
// Query for the soft-deleted item
|
|
1896
|
+
const primary = config.indexes.primary;
|
|
1897
|
+
const pkValue = KeyComposer.composePk(schema, entityType, primary, decodedKey);
|
|
1898
|
+
const deletedPrefix = DynamoSchema.composeDeletedKeyPrefix(schema, entityType);
|
|
1899
|
+
const queryResult = yield* client.query({
|
|
1900
|
+
TableName: tableName,
|
|
1901
|
+
KeyConditionExpression: "#pk = :pk AND begins_with(#sk, :skPrefix)",
|
|
1902
|
+
ExpressionAttributeNames: {
|
|
1903
|
+
"#pk": primary.pk.field,
|
|
1904
|
+
"#sk": primary.sk.field,
|
|
1905
|
+
},
|
|
1906
|
+
ExpressionAttributeValues: {
|
|
1907
|
+
":pk": toAttributeValue(pkValue),
|
|
1908
|
+
":skPrefix": toAttributeValue(deletedPrefix),
|
|
1909
|
+
},
|
|
1910
|
+
Limit: 1,
|
|
1911
|
+
ScanIndexForward: false,
|
|
1912
|
+
});
|
|
1913
|
+
if (!queryResult.Items || queryResult.Items.length === 0) {
|
|
1914
|
+
return yield* new ItemNotFound({ entityType, key: decodedKey });
|
|
1915
|
+
}
|
|
1916
|
+
const deletedRaw = fromAttributeMap(queryResult.Items[0]);
|
|
1917
|
+
const deletedMarshalledKey = toAttributeMap({
|
|
1918
|
+
[primary.pk.field]: deletedRaw[primary.pk.field],
|
|
1919
|
+
[primary.sk.field]: deletedRaw[primary.sk.field],
|
|
1920
|
+
});
|
|
1921
|
+
// Build restored item: original SK, recompose all GSI keys, remove deletedAt + _ttl
|
|
1922
|
+
const restoredItem = {
|
|
1923
|
+
...deletedRaw,
|
|
1924
|
+
};
|
|
1925
|
+
delete restoredItem.deletedAt;
|
|
1926
|
+
delete restoredItem._ttl;
|
|
1927
|
+
// Increment version
|
|
1928
|
+
const currentVersion = systemFields.version
|
|
1929
|
+
? restoredItem[systemFields.version]
|
|
1930
|
+
: 0;
|
|
1931
|
+
const newVersion = currentVersion + 1;
|
|
1932
|
+
if (systemFields.version)
|
|
1933
|
+
restoredItem[systemFields.version] = newVersion;
|
|
1934
|
+
if (systemFields.updatedAt)
|
|
1935
|
+
restoredItem[systemFields.updatedAt] = generateTimestamp(systemFields.updatedAtEncoding);
|
|
1936
|
+
// Recompose all keys (original SK, GSI keys)
|
|
1937
|
+
const restoredKeys = composeAllKeys(restoredItem);
|
|
1938
|
+
Object.assign(restoredItem, restoredKeys);
|
|
1939
|
+
const marshalledRestoredItem = toAttributeMap(restoredItem);
|
|
1940
|
+
const transactItems = [];
|
|
1941
|
+
// Delete the soft-deleted item
|
|
1942
|
+
transactItems.push({
|
|
1943
|
+
Delete: { TableName: tableName, Key: deletedMarshalledKey },
|
|
1944
|
+
});
|
|
1945
|
+
// Put restored item
|
|
1946
|
+
transactItems.push({
|
|
1947
|
+
Put: {
|
|
1948
|
+
TableName: tableName,
|
|
1949
|
+
Item: marshalledRestoredItem,
|
|
1950
|
+
},
|
|
1951
|
+
});
|
|
1952
|
+
// Version snapshot if retain enabled
|
|
1953
|
+
if (isRetainEnabled()) {
|
|
1954
|
+
const snapshotItem = buildSnapshotItem(deletedRaw, currentVersion, primary.pk.field, primary.sk.field);
|
|
1955
|
+
transactItems.push({
|
|
1956
|
+
Put: {
|
|
1957
|
+
TableName: tableName,
|
|
1958
|
+
Item: toAttributeMap(snapshotItem),
|
|
1959
|
+
},
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
// Re-establish unique constraint sentinels
|
|
1963
|
+
if (config.unique && Object.keys(config.unique).length > 0) {
|
|
1964
|
+
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
1965
|
+
const fields = resolveUniqueFields(constraintDef);
|
|
1966
|
+
const fieldValues = fields.map((f) => KeyComposer.serializeValue(restoredItem[f]));
|
|
1967
|
+
const uniqueKey = DynamoSchema.composeUniqueKey(schema, entityType, constraintName, fieldValues);
|
|
1968
|
+
transactItems.push({
|
|
1969
|
+
Put: {
|
|
1970
|
+
TableName: tableName,
|
|
1971
|
+
Item: toAttributeMap({
|
|
1972
|
+
[primary.pk.field]: uniqueKey.pk,
|
|
1973
|
+
[primary.sk.field]: uniqueKey.sk,
|
|
1974
|
+
__edd_e__: `${entityType}._unique.${constraintName}`,
|
|
1975
|
+
_entity_pk: restoredKeys[primary.pk.field],
|
|
1976
|
+
_entity_sk: restoredKeys[primary.sk.field],
|
|
1977
|
+
}),
|
|
1978
|
+
ConditionExpression: "attribute_not_exists(#pk)",
|
|
1979
|
+
ExpressionAttributeNames: { "#pk": primary.pk.field },
|
|
1980
|
+
},
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
yield* checkTransactionLimit(entityType, "restore", transactItems);
|
|
1985
|
+
yield* client
|
|
1986
|
+
.transactWriteItems({
|
|
1987
|
+
TransactItems: transactItems,
|
|
1988
|
+
})
|
|
1989
|
+
.pipe(Effect.mapError((err) => {
|
|
1990
|
+
if (isAwsTransactionCancelled(err.cause)) {
|
|
1991
|
+
const reasons = err.cause.CancellationReasons;
|
|
1992
|
+
if (reasons && config.unique) {
|
|
1993
|
+
// Sentinel items are after Delete + Put + optional snapshot
|
|
1994
|
+
const sentinelStart = isRetainEnabled() ? 3 : 2;
|
|
1995
|
+
const constraintNames = Object.keys(config.unique);
|
|
1996
|
+
for (let i = sentinelStart; i < reasons.length; i++) {
|
|
1997
|
+
if (reasons[i]?.Code === "ConditionalCheckFailed") {
|
|
1998
|
+
const constraintName = constraintNames[i - sentinelStart] ?? "unknown";
|
|
1999
|
+
const constraintDef = config.unique[constraintName];
|
|
2000
|
+
const uniqueFields = constraintDef ? resolveUniqueFields(constraintDef) : [];
|
|
2001
|
+
const fieldsRecord = {};
|
|
2002
|
+
for (const f of uniqueFields) {
|
|
2003
|
+
fieldsRecord[f] = KeyComposer.serializeValue(restoredItem[f]);
|
|
2004
|
+
}
|
|
2005
|
+
return new UniqueConstraintViolation({
|
|
2006
|
+
entityType,
|
|
2007
|
+
constraint: constraintName,
|
|
2008
|
+
fields: fieldsRecord,
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
return err;
|
|
2015
|
+
}));
|
|
2016
|
+
return yield* decodeAs(restoredItem, marshalledRestoredItem, mode);
|
|
2017
|
+
}), self, key);
|
|
2018
|
+
// ---------------------------------------------------------------------------
|
|
2019
|
+
// purge operation
|
|
2020
|
+
// ---------------------------------------------------------------------------
|
|
2021
|
+
const purge = (key) => new EntityDeleteImpl((_opts) => Effect.gen(function* () {
|
|
2022
|
+
const client = yield* DynamoClient;
|
|
2023
|
+
const { name: tableName } = yield* tableTag;
|
|
2024
|
+
// Decode key
|
|
2025
|
+
const decodedKey = yield* Schema.decodeUnknownEffect(schemas.keySchema)(key).pipe(Effect.mapError((cause) => new ValidationError({
|
|
2026
|
+
entityType,
|
|
2027
|
+
operation: "purge.decode",
|
|
2028
|
+
cause,
|
|
2029
|
+
})));
|
|
2030
|
+
const primary = config.indexes.primary;
|
|
2031
|
+
const pkValue = KeyComposer.composePk(schema, entityType, primary, decodedKey);
|
|
2032
|
+
// Query ALL items in this partition (current + versions + deleted)
|
|
2033
|
+
// Use ProjectionExpression to only get keys
|
|
2034
|
+
const allItems = [];
|
|
2035
|
+
let exclusiveStartKey;
|
|
2036
|
+
do {
|
|
2037
|
+
const result = yield* client.query({
|
|
2038
|
+
TableName: tableName,
|
|
2039
|
+
KeyConditionExpression: "#pk = :pk",
|
|
2040
|
+
ExpressionAttributeNames: { "#pk": primary.pk.field },
|
|
2041
|
+
ExpressionAttributeValues: { ":pk": toAttributeValue(pkValue) },
|
|
2042
|
+
ProjectionExpression: `${primary.pk.field}, ${primary.sk.field}`,
|
|
2043
|
+
ExclusiveStartKey: exclusiveStartKey,
|
|
2044
|
+
});
|
|
2045
|
+
if (result.Items) {
|
|
2046
|
+
allItems.push(...result.Items);
|
|
2047
|
+
}
|
|
2048
|
+
exclusiveStartKey = result.LastEvaluatedKey;
|
|
2049
|
+
} while (exclusiveStartKey);
|
|
2050
|
+
// Also get the main/deleted item to find unique sentinel keys
|
|
2051
|
+
if (config.unique && Object.keys(config.unique).length > 0) {
|
|
2052
|
+
// Get current or deleted item to extract field values for sentinel cleanup
|
|
2053
|
+
const marshalledKey = toAttributeMap({
|
|
2054
|
+
[primary.pk.field]: pkValue,
|
|
2055
|
+
[primary.sk.field]: KeyComposer.composeSk(schema, entityType, entityVersion, primary, decodedKey),
|
|
2056
|
+
});
|
|
2057
|
+
const mainResult = yield* client.getItem({
|
|
2058
|
+
TableName: tableName,
|
|
2059
|
+
Key: marshalledKey,
|
|
2060
|
+
});
|
|
2061
|
+
// Try deleted items if main not found
|
|
2062
|
+
let entityItem;
|
|
2063
|
+
if (mainResult.Item) {
|
|
2064
|
+
entityItem = fromAttributeMap(mainResult.Item);
|
|
2065
|
+
}
|
|
2066
|
+
else {
|
|
2067
|
+
// Check for a soft-deleted item
|
|
2068
|
+
const deletedPrefix = DynamoSchema.composeDeletedKeyPrefix(schema, entityType);
|
|
2069
|
+
const deletedResult = yield* client.query({
|
|
2070
|
+
TableName: tableName,
|
|
2071
|
+
KeyConditionExpression: "#pk = :pk AND begins_with(#sk, :skPrefix)",
|
|
2072
|
+
ExpressionAttributeNames: {
|
|
2073
|
+
"#pk": primary.pk.field,
|
|
2074
|
+
"#sk": primary.sk.field,
|
|
2075
|
+
},
|
|
2076
|
+
ExpressionAttributeValues: {
|
|
2077
|
+
":pk": toAttributeValue(pkValue),
|
|
2078
|
+
":skPrefix": toAttributeValue(deletedPrefix),
|
|
2079
|
+
},
|
|
2080
|
+
Limit: 1,
|
|
2081
|
+
});
|
|
2082
|
+
if (deletedResult.Items && deletedResult.Items.length > 0) {
|
|
2083
|
+
entityItem = fromAttributeMap(deletedResult.Items[0]);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
if (entityItem) {
|
|
2087
|
+
// Add sentinel keys to delete list
|
|
2088
|
+
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
2089
|
+
const fields = resolveUniqueFields(constraintDef);
|
|
2090
|
+
const fieldValues = fields.map((f) => KeyComposer.serializeValue(entityItem[f]));
|
|
2091
|
+
const uniqueKey = DynamoSchema.composeUniqueKey(schema, entityType, constraintName, fieldValues);
|
|
2092
|
+
allItems.push(toAttributeMap({
|
|
2093
|
+
[primary.pk.field]: uniqueKey.pk,
|
|
2094
|
+
[primary.sk.field]: uniqueKey.sk,
|
|
2095
|
+
}));
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
// Batch delete in chunks of 25
|
|
2100
|
+
for (let i = 0; i < allItems.length; i += 25) {
|
|
2101
|
+
const chunk = allItems.slice(i, i + 25);
|
|
2102
|
+
yield* client.batchWriteItem({
|
|
2103
|
+
RequestItems: {
|
|
2104
|
+
[tableName]: chunk.map((item) => ({
|
|
2105
|
+
DeleteRequest: {
|
|
2106
|
+
Key: {
|
|
2107
|
+
[primary.pk.field]: item[primary.pk.field],
|
|
2108
|
+
[primary.sk.field]: item[primary.sk.field],
|
|
2109
|
+
},
|
|
2110
|
+
},
|
|
2111
|
+
})),
|
|
2112
|
+
},
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
}), self, key);
|
|
2116
|
+
// ---------------------------------------------------------------------------
|
|
2117
|
+
// Expression combinators: condition, filter, select
|
|
2118
|
+
// ---------------------------------------------------------------------------
|
|
2119
|
+
const entityPathBuilder = createPathBuilder([], resolveDbName);
|
|
2120
|
+
const entityConditionOps = createConditionOps();
|
|
2121
|
+
/**
|
|
2122
|
+
* Build a condition expression combinator from callback or shorthand.
|
|
2123
|
+
* Returns a function that applies the condition to an EntityPut/Update/Delete.
|
|
2124
|
+
*/
|
|
2125
|
+
const entityCondition = (cbOrShorthand) => {
|
|
2126
|
+
const expr = typeof cbOrShorthand === "function"
|
|
2127
|
+
? cbOrShorthand(entityPathBuilder, entityConditionOps)
|
|
2128
|
+
: parseSimpleShorthand(cbOrShorthand);
|
|
2129
|
+
return (self) => conditionCombinator(self, expr);
|
|
2130
|
+
};
|
|
2131
|
+
/**
|
|
2132
|
+
* Build a filter expression combinator from callback or shorthand.
|
|
2133
|
+
* Returns a function that applies the filter to a Query.
|
|
2134
|
+
*/
|
|
2135
|
+
const entityFilter = (cbOrShorthand) => {
|
|
2136
|
+
const expr = typeof cbOrShorthand === "function"
|
|
2137
|
+
? cbOrShorthand(entityPathBuilder, entityConditionOps)
|
|
2138
|
+
: parseSimpleShorthand(cbOrShorthand);
|
|
2139
|
+
return (q) => filterExpr(q, expr);
|
|
2140
|
+
};
|
|
2141
|
+
/**
|
|
2142
|
+
* Build a select (projection) combinator from callback or string array.
|
|
2143
|
+
*/
|
|
2144
|
+
const entitySelect = (cbOrAttrs) => {
|
|
2145
|
+
if (typeof cbOrAttrs === "function") {
|
|
2146
|
+
const paths = cbOrAttrs(entityPathBuilder);
|
|
2147
|
+
const segments = paths.map((p) => p.segments);
|
|
2148
|
+
return (q) => selectPaths(q, segments);
|
|
2149
|
+
}
|
|
2150
|
+
return (q) => Query.select(q, cbOrAttrs);
|
|
2151
|
+
};
|
|
2152
|
+
// ---------------------------------------------------------------------------
|
|
2153
|
+
// Flattened query accessors (same as query namespace, but on entity directly)
|
|
2154
|
+
// ---------------------------------------------------------------------------
|
|
2155
|
+
const flattenedAccessors = {};
|
|
2156
|
+
for (const indexName of Object.keys(config.indexes)) {
|
|
2157
|
+
if (indexName === "primary")
|
|
2158
|
+
continue;
|
|
2159
|
+
flattenedAccessors[indexName] = queryNamespace[indexName];
|
|
2160
|
+
}
|
|
2161
|
+
const entity = {
|
|
2162
|
+
_tag: "Entity",
|
|
2163
|
+
model: config.model,
|
|
2164
|
+
entityType: config.entityType,
|
|
2165
|
+
get indexes() {
|
|
2166
|
+
return allIndexes;
|
|
2167
|
+
},
|
|
2168
|
+
timestamps: config.timestamps,
|
|
2169
|
+
versioned: config.versioned,
|
|
2170
|
+
softDelete: config.softDelete,
|
|
2171
|
+
unique: config.unique,
|
|
2172
|
+
identifier: resolvedIdentifier,
|
|
2173
|
+
_resolvedRefs: resolvedRefs,
|
|
2174
|
+
/** @internal Full decode pipeline: rename + date deser + schema decode. Used by Batch/Aggregate. */
|
|
2175
|
+
_decodeRecord: decodeRecord,
|
|
2176
|
+
_attachPrototype: attachPrototype,
|
|
2177
|
+
_configure: (injectedSchema, injectedTableTag) => {
|
|
2178
|
+
schema = injectedSchema;
|
|
2179
|
+
tableTag = injectedTableTag;
|
|
2180
|
+
},
|
|
2181
|
+
_injectIndex: (name, def) => {
|
|
2182
|
+
allIndexes = { ...allIndexes, [name]: def };
|
|
2183
|
+
},
|
|
2184
|
+
get _schema() {
|
|
2185
|
+
return schema;
|
|
2186
|
+
},
|
|
2187
|
+
get _tableTag() {
|
|
2188
|
+
return tableTag;
|
|
2189
|
+
},
|
|
2190
|
+
systemFields,
|
|
2191
|
+
schemas,
|
|
2192
|
+
inputSchema: schemas.inputSchema,
|
|
2193
|
+
createSchema: schemas.createSchema,
|
|
2194
|
+
updateSchema: schemas.updateSchema,
|
|
2195
|
+
get,
|
|
2196
|
+
put,
|
|
2197
|
+
create,
|
|
2198
|
+
patch,
|
|
2199
|
+
update,
|
|
2200
|
+
delete: del,
|
|
2201
|
+
deleteIfExists,
|
|
2202
|
+
upsert,
|
|
2203
|
+
scan,
|
|
2204
|
+
getVersion,
|
|
2205
|
+
versions,
|
|
2206
|
+
restore,
|
|
2207
|
+
purge,
|
|
2208
|
+
deleted: { get: deletedGet, list: deletedList },
|
|
2209
|
+
set,
|
|
2210
|
+
expectedVersion,
|
|
2211
|
+
query: queryNamespace,
|
|
2212
|
+
condition: entityCondition,
|
|
2213
|
+
filter: entityFilter,
|
|
2214
|
+
select: entitySelect,
|
|
2215
|
+
...flattenedAccessors,
|
|
2216
|
+
// Cast rationale: Entity.make() builds the entity object incrementally from
|
|
2217
|
+
// closures that capture the config. The object literal's inferred type is a
|
|
2218
|
+
// union of all closure return types, which doesn't satisfy the fully-generic
|
|
2219
|
+
// Entity<TModel, ...> interface. The cast is safe because each method
|
|
2220
|
+
// is constructed with the correct types from the generic config parameters.
|
|
2221
|
+
};
|
|
2222
|
+
// Assign self so operation closures can reference the entity
|
|
2223
|
+
// Cast needed: Entity<TModel,...> set property is contravariant in updates type,
|
|
2224
|
+
// which makes it incompatible with the default Entity (Schema.Top).
|
|
2225
|
+
// This is safe — self is only used internally for extractTransactable.
|
|
2226
|
+
self = entity;
|
|
2227
|
+
return entity;
|
|
2228
|
+
};
|
|
2229
|
+
// ---------------------------------------------------------------------------
|
|
2230
|
+
// Entity binding — resolve services, return BoundEntity with R = never
|
|
2231
|
+
// ---------------------------------------------------------------------------
|
|
2232
|
+
/**
|
|
2233
|
+
* Bind an Entity to resolved `DynamoClient` and `TableConfig` services.
|
|
2234
|
+
* Returns a {@link BoundEntity} where all operations have `R = never`.
|
|
2235
|
+
*
|
|
2236
|
+
* @internal Used by `DynamoClient.make()` to bind entities.
|
|
2237
|
+
*/
|
|
2238
|
+
export const bind = (entity) => Effect.gen(function* () {
|
|
2239
|
+
const ctx = yield* Effect.context();
|
|
2240
|
+
const provide = (effect) => Effect.provide(effect, ctx);
|
|
2241
|
+
// Helper: apply combinators to an EntityUpdate, then run in record mode + provide
|
|
2242
|
+
const applyUpdate = (op, combinators) => {
|
|
2243
|
+
let result = op;
|
|
2244
|
+
for (const fn of combinators)
|
|
2245
|
+
result = fn(result);
|
|
2246
|
+
return provide(result._run("record"));
|
|
2247
|
+
};
|
|
2248
|
+
// Helper: apply combinators to an EntityPut, then run in record mode + provide
|
|
2249
|
+
const applyPut = (op, combinators) => {
|
|
2250
|
+
let result = op;
|
|
2251
|
+
for (const fn of combinators)
|
|
2252
|
+
result = fn(result);
|
|
2253
|
+
return provide(result._run("record"));
|
|
2254
|
+
};
|
|
2255
|
+
// Helper: apply combinators to an EntityDelete, then asEffect + provide
|
|
2256
|
+
const applyDelete = (op, combinators) => {
|
|
2257
|
+
let result = op;
|
|
2258
|
+
for (const fn of combinators)
|
|
2259
|
+
result = fn(result);
|
|
2260
|
+
return provide(result.asEffect());
|
|
2261
|
+
};
|
|
2262
|
+
// Helper: apply query combinators then execute
|
|
2263
|
+
const applyQuery = (q, combinators) => {
|
|
2264
|
+
let result = q;
|
|
2265
|
+
for (const fn of combinators)
|
|
2266
|
+
result = fn(result);
|
|
2267
|
+
return result;
|
|
2268
|
+
};
|
|
2269
|
+
// Helper: wrap a raw entity-level Query<A> in a BoundQuery with this binding's
|
|
2270
|
+
// pre-resolved provide. Used by `versions` and `deleted.list` accessors so consumers
|
|
2271
|
+
// get the same fluent .collect() / .fetch() / .paginate() / .reverse() / .limit() /
|
|
2272
|
+
// .filter() ergonomics they get from index accessors and `.scan()`.
|
|
2273
|
+
const wrapAsBoundQuery = (q) => {
|
|
2274
|
+
const bqConfig = {
|
|
2275
|
+
pathBuilder: createPathBuilder(),
|
|
2276
|
+
conditionOps: createConditionOps(),
|
|
2277
|
+
provide,
|
|
2278
|
+
};
|
|
2279
|
+
return new BoundQueryImpl(q, bqConfig);
|
|
2280
|
+
};
|
|
2281
|
+
return {
|
|
2282
|
+
// CRUD — all ops return Effect (auto-wrapped)
|
|
2283
|
+
get: (key) => provide(entity.get(key)._run("record")),
|
|
2284
|
+
put: (input, ...combinators) => applyPut(entity.put(input), combinators),
|
|
2285
|
+
create: (input, ...combinators) => applyPut(entity.create(input), combinators),
|
|
2286
|
+
update: (key, ...combinators) => applyUpdate(entity.update(key), combinators),
|
|
2287
|
+
delete: (key, ...combinators) => applyDelete(entity.delete(key), combinators),
|
|
2288
|
+
upsert: (input, ...combinators) => applyPut(entity.upsert(input), combinators),
|
|
2289
|
+
patch: (key, ...combinators) => applyUpdate(entity.patch(key), combinators),
|
|
2290
|
+
deleteIfExists: (key, ...combinators) => applyDelete(entity.deleteIfExists(key), combinators),
|
|
2291
|
+
// Lifecycle
|
|
2292
|
+
getVersion: (key, version) => provide(entity.getVersion(key, version)._run("record")),
|
|
2293
|
+
versions: (key) => wrapAsBoundQuery(entity.versions(key)),
|
|
2294
|
+
restore: (key) => provide(entity.restore(key)._run("record")),
|
|
2295
|
+
purge: (key) => provide(entity.purge(key).asEffect()),
|
|
2296
|
+
deleted: {
|
|
2297
|
+
get: (key) => provide(entity.deleted.get(key)._run("record")),
|
|
2298
|
+
list: (key) => wrapAsBoundQuery(entity.deleted.list(key)),
|
|
2299
|
+
},
|
|
2300
|
+
// Query execution
|
|
2301
|
+
paginate: (q, ...combinators) => {
|
|
2302
|
+
const final = applyQuery(q, combinators);
|
|
2303
|
+
return Stream.unwrap(provide(Query.paginate(final))).pipe(Stream.flatMap((page) => Stream.fromIterable(page)));
|
|
2304
|
+
},
|
|
2305
|
+
collect: (q, ...combinators) => provide(Query.collect(applyQuery(q, combinators))),
|
|
2306
|
+
fetch: (q, ...combinators) => provide(Query.execute(applyQuery(q, combinators))),
|
|
2307
|
+
scanFetch: (...combinators) => provide(Query.execute(applyQuery(entity.scan(), combinators))),
|
|
2308
|
+
};
|
|
2309
|
+
});
|
|
2310
|
+
const isEntityOp = (op) => EntityOpTypeId in op;
|
|
2311
|
+
const isEntityDelete = (op) => EntityDeleteTypeId in op;
|
|
2312
|
+
/**
|
|
2313
|
+
* Extract transactable metadata from an Entity operation intermediate.
|
|
2314
|
+
* Returns undefined if the value is not a recognized entity operation.
|
|
2315
|
+
*/
|
|
2316
|
+
export const extractTransactable = (op) => {
|
|
2317
|
+
if (op == null || typeof op !== "object")
|
|
2318
|
+
return undefined;
|
|
2319
|
+
// Check for EntityOp intermediates (get, put, update)
|
|
2320
|
+
if (isEntityOp(op)) {
|
|
2321
|
+
if (op._opType === "get") {
|
|
2322
|
+
return { opType: "get", entity: op._entity, key: op._key };
|
|
2323
|
+
}
|
|
2324
|
+
if (op._opType === "put") {
|
|
2325
|
+
return { opType: "put", entity: op._entity, input: op._input };
|
|
2326
|
+
}
|
|
2327
|
+
if (op._opType === "update") {
|
|
2328
|
+
return { opType: "update", entity: op._entity, key: op._key };
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
// Check for EntityDelete intermediate
|
|
2332
|
+
if (isEntityDelete(op)) {
|
|
2333
|
+
return { opType: "delete", entity: op._entity, key: op._key };
|
|
2334
|
+
}
|
|
2335
|
+
return undefined;
|
|
2336
|
+
};
|
|
2337
|
+
/**
|
|
2338
|
+
* Get the names of all primary key composite attributes for an entity.
|
|
2339
|
+
*/
|
|
2340
|
+
export const keyAttributes = (entity) => primaryKeyComposites(entity.indexes);
|
|
2341
|
+
/**
|
|
2342
|
+
* Get the names of all key field attributes (pk, sk, gsi1pk, etc.) for an entity.
|
|
2343
|
+
*/
|
|
2344
|
+
export const keyFieldNames = (entity) => allKeyFieldNames(entity.indexes);
|
|
2345
|
+
/**
|
|
2346
|
+
* Get the names of all composite attributes across all indexes.
|
|
2347
|
+
*/
|
|
2348
|
+
export const compositeAttributes = (entity) => allCompositeAttributes(entity.indexes);
|
|
2349
|
+
/**
|
|
2350
|
+
* Get the item schema for an entity.
|
|
2351
|
+
*
|
|
2352
|
+
* Returns a Schema that decodes unmarshalled DynamoDB items (plain JS objects
|
|
2353
|
+
* from `Marshaller.fromAttributeMap`) into Entity.Record instances.
|
|
2354
|
+
*
|
|
2355
|
+
* Useful for consuming DynamoDB Streams or working with raw query results.
|
|
2356
|
+
*/
|
|
2357
|
+
export const itemSchema = (entity) => entity.schemas.itemSchema;
|
|
2358
|
+
/**
|
|
2359
|
+
* Decode a marshalled DynamoDB item (AttributeValue format) into a typed Entity.Record.
|
|
2360
|
+
*
|
|
2361
|
+
* Unmarshalls the AttributeValue map to plain JS, then decodes via the entity's item schema.
|
|
2362
|
+
* Useful for consuming DynamoDB Streams events or raw SDK responses.
|
|
2363
|
+
*
|
|
2364
|
+
* Returns an Effect that fails with `ValidationError` if the item doesn't match the schema.
|
|
2365
|
+
*/
|
|
2366
|
+
export const decodeMarshalledItem = (entity, marshalledItem) => Schema.decodeUnknownEffect(entity.schemas.itemSchema)(fromAttributeMap(marshalledItem)).pipe(Effect.map((decoded) => (entity._attachPrototype ? entity._attachPrototype(decoded) : decoded)), Effect.mapError((cause) => new ValidationError({
|
|
2367
|
+
entityType: entity.entityType,
|
|
2368
|
+
operation: "decodeMarshalledItem",
|
|
2369
|
+
cause,
|
|
2370
|
+
})));
|
|
2371
|
+
//# sourceMappingURL=Entity.js.map
|