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.
Files changed (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +69 -0
  3. package/dist/Aggregate.d.ts +210 -0
  4. package/dist/Aggregate.d.ts.map +1 -0
  5. package/dist/Aggregate.js +1169 -0
  6. package/dist/Aggregate.js.map +1 -0
  7. package/dist/Batch.d.ts +62 -0
  8. package/dist/Batch.d.ts.map +1 -0
  9. package/dist/Batch.js +218 -0
  10. package/dist/Batch.js.map +1 -0
  11. package/dist/Collection.d.ts +58 -0
  12. package/dist/Collection.d.ts.map +1 -0
  13. package/dist/Collection.js +195 -0
  14. package/dist/Collection.js.map +1 -0
  15. package/dist/Collections.d.ts +121 -0
  16. package/dist/Collections.d.ts.map +1 -0
  17. package/dist/Collections.js +121 -0
  18. package/dist/Collections.js.map +1 -0
  19. package/dist/DynamoClient.d.ts +317 -0
  20. package/dist/DynamoClient.d.ts.map +1 -0
  21. package/dist/DynamoClient.js +653 -0
  22. package/dist/DynamoClient.js.map +1 -0
  23. package/dist/DynamoModel.d.ts +278 -0
  24. package/dist/DynamoModel.d.ts.map +1 -0
  25. package/dist/DynamoModel.js +453 -0
  26. package/dist/DynamoModel.js.map +1 -0
  27. package/dist/DynamoSchema.d.ts +137 -0
  28. package/dist/DynamoSchema.d.ts.map +1 -0
  29. package/dist/DynamoSchema.js +188 -0
  30. package/dist/DynamoSchema.js.map +1 -0
  31. package/dist/Entity.d.ts +628 -0
  32. package/dist/Entity.d.ts.map +1 -0
  33. package/dist/Entity.js +2371 -0
  34. package/dist/Entity.js.map +1 -0
  35. package/dist/Errors.d.ts +227 -0
  36. package/dist/Errors.d.ts.map +1 -0
  37. package/dist/Errors.js +93 -0
  38. package/dist/Errors.js.map +1 -0
  39. package/dist/EventStore.d.ts +208 -0
  40. package/dist/EventStore.d.ts.map +1 -0
  41. package/dist/EventStore.js +333 -0
  42. package/dist/EventStore.js.map +1 -0
  43. package/dist/Expression.d.ts +50 -0
  44. package/dist/Expression.d.ts.map +1 -0
  45. package/dist/Expression.js +170 -0
  46. package/dist/Expression.js.map +1 -0
  47. package/dist/KeyComposer.d.ts +125 -0
  48. package/dist/KeyComposer.d.ts.map +1 -0
  49. package/dist/KeyComposer.js +257 -0
  50. package/dist/KeyComposer.js.map +1 -0
  51. package/dist/Marshaller.d.ts +30 -0
  52. package/dist/Marshaller.d.ts.map +1 -0
  53. package/dist/Marshaller.js +115 -0
  54. package/dist/Marshaller.js.map +1 -0
  55. package/dist/Projection.d.ts +28 -0
  56. package/dist/Projection.d.ts.map +1 -0
  57. package/dist/Projection.js +39 -0
  58. package/dist/Projection.js.map +1 -0
  59. package/dist/Query.d.ts +214 -0
  60. package/dist/Query.d.ts.map +1 -0
  61. package/dist/Query.js +397 -0
  62. package/dist/Query.js.map +1 -0
  63. package/dist/Table.d.ts +178 -0
  64. package/dist/Table.d.ts.map +1 -0
  65. package/dist/Table.js +211 -0
  66. package/dist/Table.js.map +1 -0
  67. package/dist/Transaction.d.ts +79 -0
  68. package/dist/Transaction.d.ts.map +1 -0
  69. package/dist/Transaction.js +215 -0
  70. package/dist/Transaction.js.map +1 -0
  71. package/dist/_AggregateCursor.d.ts +44 -0
  72. package/dist/_AggregateCursor.d.ts.map +1 -0
  73. package/dist/_AggregateCursor.js +18 -0
  74. package/dist/_AggregateCursor.js.map +1 -0
  75. package/dist/_AggregateEdges.d.ts +77 -0
  76. package/dist/_AggregateEdges.d.ts.map +1 -0
  77. package/dist/_AggregateEdges.js +41 -0
  78. package/dist/_AggregateEdges.js.map +1 -0
  79. package/dist/_AggregateSchemas.d.ts +69 -0
  80. package/dist/_AggregateSchemas.d.ts.map +1 -0
  81. package/dist/_AggregateSchemas.js +194 -0
  82. package/dist/_AggregateSchemas.js.map +1 -0
  83. package/dist/_AggregateTypes.d.ts +94 -0
  84. package/dist/_AggregateTypes.d.ts.map +1 -0
  85. package/dist/_AggregateTypes.js +7 -0
  86. package/dist/_AggregateTypes.js.map +1 -0
  87. package/dist/_EntityCombinators.d.ts +155 -0
  88. package/dist/_EntityCombinators.d.ts.map +1 -0
  89. package/dist/_EntityCombinators.js +158 -0
  90. package/dist/_EntityCombinators.js.map +1 -0
  91. package/dist/_EntityConfig.d.ts +100 -0
  92. package/dist/_EntityConfig.d.ts.map +1 -0
  93. package/dist/_EntityConfig.js +7 -0
  94. package/dist/_EntityConfig.js.map +1 -0
  95. package/dist/_EntityOps.d.ts +205 -0
  96. package/dist/_EntityOps.d.ts.map +1 -0
  97. package/dist/_EntityOps.js +137 -0
  98. package/dist/_EntityOps.js.map +1 -0
  99. package/dist/_EntitySchemas.d.ts +57 -0
  100. package/dist/_EntitySchemas.d.ts.map +1 -0
  101. package/dist/_EntitySchemas.js +298 -0
  102. package/dist/_EntitySchemas.js.map +1 -0
  103. package/dist/_EntityTypes.d.ts +81 -0
  104. package/dist/_EntityTypes.d.ts.map +1 -0
  105. package/dist/_EntityTypes.js +7 -0
  106. package/dist/_EntityTypes.js.map +1 -0
  107. package/dist/_sparse_check.d.ts +2 -0
  108. package/dist/_sparse_check.d.ts.map +1 -0
  109. package/dist/_sparse_check.js +193 -0
  110. package/dist/_sparse_check.js.map +1 -0
  111. package/dist/index.d.ts +35 -0
  112. package/dist/index.d.ts.map +1 -0
  113. package/dist/index.js +20 -0
  114. package/dist/index.js.map +1 -0
  115. package/dist/internal/AggregateCursor.d.ts +44 -0
  116. package/dist/internal/AggregateCursor.d.ts.map +1 -0
  117. package/dist/internal/AggregateCursor.js +18 -0
  118. package/dist/internal/AggregateCursor.js.map +1 -0
  119. package/dist/internal/AggregateEdges.d.ts +77 -0
  120. package/dist/internal/AggregateEdges.d.ts.map +1 -0
  121. package/dist/internal/AggregateEdges.js +41 -0
  122. package/dist/internal/AggregateEdges.js.map +1 -0
  123. package/dist/internal/AggregateSchemas.d.ts +69 -0
  124. package/dist/internal/AggregateSchemas.d.ts.map +1 -0
  125. package/dist/internal/AggregateSchemas.js +193 -0
  126. package/dist/internal/AggregateSchemas.js.map +1 -0
  127. package/dist/internal/AggregateTypes.d.ts +94 -0
  128. package/dist/internal/AggregateTypes.d.ts.map +1 -0
  129. package/dist/internal/AggregateTypes.js +7 -0
  130. package/dist/internal/AggregateTypes.js.map +1 -0
  131. package/dist/internal/BoundQuery.d.ts +107 -0
  132. package/dist/internal/BoundQuery.d.ts.map +1 -0
  133. package/dist/internal/BoundQuery.js +111 -0
  134. package/dist/internal/BoundQuery.js.map +1 -0
  135. package/dist/internal/EntityCombinators.d.ts +173 -0
  136. package/dist/internal/EntityCombinators.d.ts.map +1 -0
  137. package/dist/internal/EntityCombinators.js +183 -0
  138. package/dist/internal/EntityCombinators.js.map +1 -0
  139. package/dist/internal/EntityConfig.d.ts +100 -0
  140. package/dist/internal/EntityConfig.d.ts.map +1 -0
  141. package/dist/internal/EntityConfig.js +7 -0
  142. package/dist/internal/EntityConfig.js.map +1 -0
  143. package/dist/internal/EntityOps.d.ts +268 -0
  144. package/dist/internal/EntityOps.d.ts.map +1 -0
  145. package/dist/internal/EntityOps.js +153 -0
  146. package/dist/internal/EntityOps.js.map +1 -0
  147. package/dist/internal/EntitySchemas.d.ts +57 -0
  148. package/dist/internal/EntitySchemas.d.ts.map +1 -0
  149. package/dist/internal/EntitySchemas.js +299 -0
  150. package/dist/internal/EntitySchemas.js.map +1 -0
  151. package/dist/internal/EntityTypes.d.ts +128 -0
  152. package/dist/internal/EntityTypes.d.ts.map +1 -0
  153. package/dist/internal/EntityTypes.js +7 -0
  154. package/dist/internal/EntityTypes.js.map +1 -0
  155. package/dist/internal/Expr.d.ts +141 -0
  156. package/dist/internal/Expr.d.ts.map +1 -0
  157. package/dist/internal/Expr.js +332 -0
  158. package/dist/internal/Expr.js.map +1 -0
  159. package/dist/internal/PathBuilder.d.ts +88 -0
  160. package/dist/internal/PathBuilder.d.ts.map +1 -0
  161. package/dist/internal/PathBuilder.js +100 -0
  162. package/dist/internal/PathBuilder.js.map +1 -0
  163. package/dist/internal/TransactableOps.d.ts +25 -0
  164. package/dist/internal/TransactableOps.d.ts.map +1 -0
  165. package/dist/internal/TransactableOps.js +58 -0
  166. package/dist/internal/TransactableOps.js.map +1 -0
  167. package/dist/internal/sparse_check.d.ts +2 -0
  168. package/dist/internal/sparse_check.d.ts.map +1 -0
  169. package/dist/internal/sparse_check.js +196 -0
  170. package/dist/internal/sparse_check.js.map +1 -0
  171. 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