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
@@ -0,0 +1,1169 @@
1
+ /**
2
+ * Aggregate — Graph-based composite domain model for DynamoDB.
3
+ *
4
+ * An Aggregate binds a Schema.Class hierarchy to a graph of underlying entities
5
+ * sharing a partition key. The read path queries the collection, discriminates
6
+ * items by entity type + discriminators, and assembles them leaves-to-root into
7
+ * a Schema.Class instance.
8
+ *
9
+ * Created via {@link make}. Sub-aggregates compose recursively via `.with()`.
10
+ */
11
+ import { Effect, Schema, SchemaAST } from "effect";
12
+ import * as Batch from "./Batch.js";
13
+ import { DynamoClient } from "./DynamoClient.js";
14
+ import * as DynamoModel from "./DynamoModel.js";
15
+ import { composeCollectionKey, composeKey } from "./DynamoSchema.js";
16
+ import { AggregateAssemblyError, AggregateTransactionOverflow, RefNotFound, TransactionCancelled, ValidationError, } from "./Errors.js";
17
+ import * as KeyComposer from "./KeyComposer.js";
18
+ import { deserializeDateFromDynamo, fromAttributeMap, serializeDateForDynamo, toAttributeMap, toAttributeValue, } from "./Marshaller.js";
19
+ // Internal modules (decomposed from Aggregate.ts)
20
+ export { isManyEdge, isOneEdge, isRefEdge, many, one, ref, } from "./internal/AggregateEdges.js";
21
+ export { deriveAggregateSchemas, deriveElementInputSchema, deriveEntityFieldName, extractArrayElement, getSchemaFields, isFieldOptional, isSchemaMatchingEntity, unwrapModel, } from "./internal/AggregateSchemas.js";
22
+ import { makeCursor, } from "./internal/AggregateCursor.js";
23
+ import { deriveAggregateSchemas, deriveEntityFieldName } from "./internal/AggregateSchemas.js";
24
+ // ---------------------------------------------------------------------------
25
+ // TypeId
26
+ // ---------------------------------------------------------------------------
27
+ export const TypeId = Symbol.for("effect-dynamodb/Aggregate");
28
+ export function make(schema, config) {
29
+ if (!("table" in config) || config.table === undefined) {
30
+ // Sub-aggregate form
31
+ return makeSubAggregate(schema, config);
32
+ }
33
+ // Top-level form
34
+ return makeAggregate(schema, config);
35
+ }
36
+ // ---------------------------------------------------------------------------
37
+ // Sub-aggregate construction
38
+ // ---------------------------------------------------------------------------
39
+ const makeSubAggregate = (schema, config) => {
40
+ const sub = {
41
+ _tag: "SubAggregate",
42
+ schema,
43
+ root: config.root,
44
+ edges: config.edges,
45
+ with: (discConfig) => ({
46
+ _tag: "BoundSubAggregate",
47
+ aggregate: sub,
48
+ discriminator: discConfig.discriminator,
49
+ }),
50
+ };
51
+ return sub;
52
+ };
53
+ /**
54
+ * Try to match a date encoding from a SchemaAST.resolve() result.
55
+ * SchemaAST.resolve flattens annotations to the top level (typeConstructor, meta).
56
+ */
57
+ const matchResolvedDateAST = (resolved) => {
58
+ const tc = resolved.typeConstructor;
59
+ if (tc?._tag === "effect/DateTime.Utc")
60
+ return { storage: "string", domain: "DateTime.Utc" };
61
+ if (tc?._tag === "effect/DateTime.Zoned")
62
+ return { storage: "string", domain: "DateTime.Zoned" };
63
+ if (tc?._tag === "Date")
64
+ return { storage: "string", domain: "Date" };
65
+ const meta = resolved.meta;
66
+ if (meta?._tag === "isDateValid")
67
+ return { storage: "string", domain: "Date" };
68
+ return undefined;
69
+ };
70
+ /**
71
+ * Try to match a date encoding from a raw AST member (Union type member).
72
+ * Raw AST nodes have annotations nested under .annotations (not flattened).
73
+ */
74
+ const matchRawDateAST = (node) => {
75
+ const ann = node.annotations;
76
+ if (!ann)
77
+ return undefined;
78
+ const tc = ann.typeConstructor;
79
+ if (tc?._tag === "effect/DateTime.Utc")
80
+ return { storage: "string", domain: "DateTime.Utc" };
81
+ if (tc?._tag === "effect/DateTime.Zoned")
82
+ return { storage: "string", domain: "DateTime.Zoned" };
83
+ if (tc?._tag === "Date")
84
+ return { storage: "string", domain: "Date" };
85
+ const meta = ann.meta;
86
+ if (meta?._tag === "isDateValid")
87
+ return { storage: "string", domain: "Date" };
88
+ return undefined;
89
+ };
90
+ /**
91
+ * Infer date encoding from a schema AST, handling Schema.optional() wrappers.
92
+ * Optional fields have Union AST with [InnerType, Undefined] — unwrap to find dates.
93
+ */
94
+ const inferDateEncoding = (ast) => {
95
+ // Try SchemaAST.resolve first (works for non-optional date fields)
96
+ const resolved = SchemaAST.resolve(ast);
97
+ if (resolved) {
98
+ const direct = matchResolvedDateAST(resolved);
99
+ if (direct)
100
+ return direct;
101
+ }
102
+ // Handle optional wrapper: Union with Undefined + inner type.
103
+ // SchemaAST.resolve returns undefined for Union ASTs, so check the raw AST.
104
+ const rawAst = ast;
105
+ if (rawAst._tag === "Union" && rawAst.types) {
106
+ for (const member of rawAst.types) {
107
+ if (member._tag === "Undefined")
108
+ continue;
109
+ // Try SchemaAST.resolve on the inner member (e.g., Declaration for Schema.Date)
110
+ const memberResolved = SchemaAST.resolve(member);
111
+ if (memberResolved) {
112
+ const enc = matchResolvedDateAST(memberResolved);
113
+ if (enc)
114
+ return enc;
115
+ }
116
+ // Fallback: check raw member annotations
117
+ const enc = matchRawDateAST(member);
118
+ if (enc)
119
+ return enc;
120
+ }
121
+ }
122
+ return undefined;
123
+ };
124
+ // ---------------------------------------------------------------------------
125
+ // Top-level aggregate construction
126
+ // ---------------------------------------------------------------------------
127
+ const makeAggregate = (schema, config) => {
128
+ // Build resolved graph by walking edges recursively
129
+ const rootNode = resolveNode(null, config.root.entityType, "root", config.edges);
130
+ const aggregateName = config.root.entityType;
131
+ const contextFields = config.context ?? [];
132
+ // Detect date encodings for all root schema fields (once, at make() time).
133
+ // Fields with Date/DateTime types need serialization before DynamoDB
134
+ // marshalling and deserialization before Schema decode, matching Entity's
135
+ // serializeDateFields/deserializeDateFields pattern.
136
+ const dateEncodings = {};
137
+ const schemaFields = schema.fields;
138
+ if (schemaFields) {
139
+ for (const field of Object.keys(schemaFields)) {
140
+ const fieldSchema = schemaFields[field];
141
+ const explicit = DynamoModel.getEncoding(fieldSchema);
142
+ if (explicit) {
143
+ dateEncodings[field] = explicit;
144
+ }
145
+ else {
146
+ // Infer for standard Effect date schemas (Schema.Date, DateTime, etc.)
147
+ // For optional fields (Union with Undefined), unwrap to find the inner type.
148
+ const inferred = inferDateEncoding(fieldSchema.ast);
149
+ if (inferred)
150
+ dateEncodings[field] = inferred;
151
+ }
152
+ }
153
+ }
154
+ // Build optics at construction time (once per aggregate definition)
155
+ const classToPlain = Schema.toIso(schema);
156
+ const opticRoot = Schema.toIsoFocus(schema);
157
+ /** Shared: compose PK and query all items */
158
+ const fetchPartition = (key) => Effect.gen(function* () {
159
+ const client = yield* DynamoClient;
160
+ const tableConfig = yield* config.table.Tag;
161
+ const composites = KeyComposer.extractComposites(config.pk.composite, key);
162
+ const pkValue = composeCollectionKey(config.schema, config.collection.name, composites);
163
+ const allItems = yield* queryAllItems(client, tableConfig.name, config.collection.index, config.pk.field, pkValue);
164
+ return { client, tableConfig, pkValue, composites, allItems };
165
+ });
166
+ const derivedSchemas = deriveAggregateSchemas(schema, config.edges, config.pk.composite);
167
+ return {
168
+ [TypeId]: TypeId,
169
+ _tag: "Aggregate",
170
+ _tableTag: config.table.Tag,
171
+ schema,
172
+ pkField: config.pk.field,
173
+ collection: {
174
+ index: config.collection.index,
175
+ sk: { field: config.collection.sk.field },
176
+ },
177
+ listIndex: config.list
178
+ ? {
179
+ index: config.list.index,
180
+ pk: { field: config.list.pk.field },
181
+ sk: { field: config.list.sk.field },
182
+ }
183
+ : undefined,
184
+ inputSchema: derivedSchemas.inputSchema,
185
+ createSchema: derivedSchemas.inputSchema,
186
+ updateSchema: derivedSchemas.updateSchema,
187
+ get: (key) => Effect.gen(function* () {
188
+ const { allItems } = yield* fetchPartition(key);
189
+ if (allItems.length === 0) {
190
+ return yield* new AggregateAssemblyError({
191
+ aggregate: aggregateName,
192
+ reason: "No items found for aggregate key",
193
+ key,
194
+ });
195
+ }
196
+ const result = yield* assembleAggregate(schema, rootNode, allItems, contextFields, dateEncodings, key, aggregateName);
197
+ return result;
198
+ }),
199
+ create: (input) => Effect.gen(function* () {
200
+ const client = yield* DynamoClient;
201
+ const tableConfig = yield* config.table.Tag;
202
+ // 1. Hydrate refs from edges
203
+ const hydrated = yield* hydrateAggregateRefs(input, config.edges, aggregateName);
204
+ // 2. Validate via schema decode
205
+ const decoded = yield* Schema.decodeUnknownEffect(schema)(hydrated).pipe(Effect.mapError((cause) => new ValidationError({
206
+ entityType: aggregateName,
207
+ operation: "aggregate.create",
208
+ cause,
209
+ })));
210
+ const assembled = { ...decoded };
211
+ // 3. Compose PK
212
+ const composites = KeyComposer.extractComposites(config.pk.composite, assembled);
213
+ const pkValue = composeCollectionKey(config.schema, config.collection.name, composites);
214
+ // 4. Decompose into items grouped by sub-aggregate transaction boundaries
215
+ const groups = yield* decomposeAggregate(assembled, rootNode, contextFields, dateEncodings, aggregateName);
216
+ // 5. Compose collection SK composites for root item
217
+ const collectionSkComposites = KeyComposer.extractComposites(config.collection.sk.composite, assembled);
218
+ // 6. Build DynamoDB items with composed keys
219
+ const dynamoGroups = buildDynamoItems(groups, config, pkValue, collectionSkComposites);
220
+ // 7. Write via sub-aggregate transactions
221
+ yield* writeTransactionGroups(client, tableConfig.name, dynamoGroups, aggregateName);
222
+ return decoded;
223
+ }),
224
+ update: (key, mutationFn) => Effect.gen(function* () {
225
+ const { client, tableConfig, pkValue, allItems } = yield* fetchPartition(key);
226
+ if (allItems.length === 0) {
227
+ return yield* new AggregateAssemblyError({
228
+ aggregate: aggregateName,
229
+ reason: "No items found for aggregate key",
230
+ key,
231
+ });
232
+ }
233
+ // 1. Assemble current state
234
+ const current = yield* assembleAggregate(schema, rootNode, allItems, contextFields, dateEncodings, key, aggregateName);
235
+ // 2. Apply mutation — provide optics context for composable updates
236
+ const state = classToPlain.get(current);
237
+ const updated = mutationFn({
238
+ state,
239
+ cursor: makeCursor(state, opticRoot),
240
+ optic: opticRoot,
241
+ current: current,
242
+ });
243
+ // 3. Validate updated state via schema decode
244
+ const decoded = yield* Schema.decodeUnknownEffect(schema)(updated).pipe(Effect.mapError((cause) => new ValidationError({
245
+ entityType: aggregateName,
246
+ operation: "aggregate.update",
247
+ cause,
248
+ })));
249
+ const assembledOld = { ...current };
250
+ const assembledNew = { ...decoded };
251
+ // 4. Decompose both old and new
252
+ const oldGroups = yield* decomposeAggregate(assembledOld, rootNode, contextFields, dateEncodings, aggregateName);
253
+ const newGroups = yield* decomposeAggregate(assembledNew, rootNode, contextFields, dateEncodings, aggregateName);
254
+ // 5. Diff: determine which transaction groups changed
255
+ const oldGroupMap = new Map(oldGroups.map((g) => [g.name, g]));
256
+ const newGroupMap = new Map(newGroups.map((g) => [g.name, g]));
257
+ // Check if context fields changed — if so, ALL groups must be rewritten
258
+ let contextChanged = false;
259
+ for (const field of contextFields) {
260
+ if (!deepEqual(assembledOld[field], assembledNew[field])) {
261
+ contextChanged = true;
262
+ break;
263
+ }
264
+ }
265
+ const groupsToWrite = [];
266
+ for (const [name, newGroup] of newGroupMap) {
267
+ if (contextChanged) {
268
+ groupsToWrite.push(newGroup);
269
+ continue;
270
+ }
271
+ const oldGroup = oldGroupMap.get(name);
272
+ if (!oldGroup || !deepEqualGroups(oldGroup, newGroup)) {
273
+ groupsToWrite.push(newGroup);
274
+ }
275
+ }
276
+ if (groupsToWrite.length > 0) {
277
+ // 6. Compose collection SK composites
278
+ const collectionSkComposites = KeyComposer.extractComposites(config.collection.sk.composite, assembledNew);
279
+ // 7. Build and write changed groups
280
+ const dynamoGroups = buildDynamoItems(groupsToWrite, config, pkValue, collectionSkComposites);
281
+ yield* writeTransactionGroups(client, tableConfig.name, dynamoGroups, aggregateName);
282
+ }
283
+ return decoded;
284
+ }),
285
+ delete: (key) => Effect.gen(function* () {
286
+ const { client, tableConfig, allItems } = yield* fetchPartition(key);
287
+ if (allItems.length === 0) {
288
+ return yield* new AggregateAssemblyError({
289
+ aggregate: aggregateName,
290
+ reason: "No items found for aggregate key",
291
+ key,
292
+ });
293
+ }
294
+ yield* deleteAllItems(client, tableConfig.name, allItems, config.pk.field);
295
+ }),
296
+ list: (filter, options) => Effect.gen(function* () {
297
+ const client = yield* DynamoClient;
298
+ const tableConfig = yield* config.table.Tag;
299
+ if (!config.list) {
300
+ return yield* new ValidationError({
301
+ entityType: aggregateName,
302
+ operation: "list",
303
+ cause: "Aggregate.list() requires a `list` collection config. " +
304
+ "Define `list: { index, name, pk, sk }` in your aggregate config.",
305
+ });
306
+ }
307
+ const listConfig = config.list;
308
+ const limit = options?.limit;
309
+ const startKey = options?.cursor
310
+ ? JSON.parse(atob(options.cursor))
311
+ : undefined;
312
+ // Compose PK from filter values matching PK composites
313
+ const listPkComposites = KeyComposer.extractComposites(listConfig.pk.composite, filter ?? {});
314
+ // Build SK prefix from contiguous filter values matching SK composites
315
+ const skValues = [];
316
+ for (const attr of listConfig.sk.composite) {
317
+ if (filter?.[attr] !== undefined)
318
+ skValues.push(String(filter[attr]));
319
+ else
320
+ break; // Stop at first gap (prefix matching)
321
+ }
322
+ let rootItems;
323
+ let nextKey;
324
+ if (listConfig.cardinality) {
325
+ // Fan out: query each shard in parallel, merge results
326
+ // Pagination not supported for sharded lists — returns all results
327
+ const shardQueries = Array.from({ length: listConfig.cardinality }, (_, shard) => {
328
+ const shardPkValue = composeCollectionKey(config.schema, listConfig.name, [
329
+ ...listPkComposites,
330
+ String(shard),
331
+ ]);
332
+ return queryListPartition(client, tableConfig, listConfig, config.schema, shardPkValue, skValues);
333
+ });
334
+ const shardResults = yield* Effect.all(shardQueries);
335
+ rootItems = shardResults.flatMap((r) => r.items);
336
+ nextKey = undefined;
337
+ }
338
+ else {
339
+ const listPkValue = composeCollectionKey(config.schema, listConfig.name, listPkComposites);
340
+ const result = yield* queryListPartition(client, tableConfig, listConfig, config.schema, listPkValue, skValues, limit, startKey);
341
+ rootItems = result.items;
342
+ nextKey = result.lastKey;
343
+ }
344
+ // Extract PK composite values from each root item and assemble
345
+ const aggregates = [];
346
+ for (const rootItem of rootItems) {
347
+ const key = {};
348
+ for (const composite of config.pk.composite) {
349
+ key[composite] = rootItem[composite];
350
+ }
351
+ const assembled = yield* Effect.gen(function* () {
352
+ const { allItems } = yield* fetchPartition(key);
353
+ if (allItems.length === 0)
354
+ return undefined;
355
+ const result = yield* assembleAggregate(schema, rootNode, allItems, contextFields, dateEncodings, key, aggregateName);
356
+ return result;
357
+ });
358
+ if (assembled !== undefined)
359
+ aggregates.push(assembled);
360
+ }
361
+ const cursor = nextKey ? btoa(JSON.stringify(nextKey)) : null;
362
+ return { data: aggregates, cursor };
363
+ }),
364
+ };
365
+ };
366
+ // ---------------------------------------------------------------------------
367
+ // Aggregate binding — resolve services, return BoundAggregate with R = never
368
+ // ---------------------------------------------------------------------------
369
+ /**
370
+ * Bind an Aggregate to resolved `DynamoClient` and `TableConfig` services.
371
+ * Returns a {@link BoundAggregate} where all operations have `R = never`.
372
+ *
373
+ * @internal Used by `DynamoClient.make()` to bind aggregates.
374
+ */
375
+ export const bind = (aggregate) => Effect.gen(function* () {
376
+ const ctx = yield* Effect.context();
377
+ const provide = (effect) => Effect.provide(effect, ctx);
378
+ return {
379
+ schema: aggregate.schema,
380
+ get: (key) => provide(aggregate.get(key)),
381
+ create: (input) => provide(aggregate.create(input)),
382
+ update: (key, mutationFn) => provide(aggregate.update(key, mutationFn)),
383
+ delete: (key) => provide(aggregate.delete(key)),
384
+ list: (filter, options) => provide(aggregate.list(filter, options)),
385
+ inputSchema: aggregate.inputSchema,
386
+ createSchema: aggregate.createSchema,
387
+ updateSchema: aggregate.updateSchema,
388
+ provide,
389
+ };
390
+ });
391
+ // ---------------------------------------------------------------------------
392
+ // Internal: Deep equality helpers for diff-based update
393
+ // ---------------------------------------------------------------------------
394
+ const deepEqual = (a, b) => {
395
+ if (a === b)
396
+ return true;
397
+ if (a == null || b == null)
398
+ return false;
399
+ if (typeof a !== typeof b)
400
+ return false;
401
+ if (typeof a !== "object")
402
+ return false;
403
+ if (Array.isArray(a) && Array.isArray(b)) {
404
+ if (a.length !== b.length)
405
+ return false;
406
+ return a.every((item, i) => deepEqual(item, b[i]));
407
+ }
408
+ if (Array.isArray(a) || Array.isArray(b))
409
+ return false;
410
+ const aObj = a;
411
+ const bObj = b;
412
+ const aKeys = Object.keys(aObj);
413
+ const bKeys = Object.keys(bObj);
414
+ if (aKeys.length !== bKeys.length)
415
+ return false;
416
+ return aKeys.every((k) => deepEqual(aObj[k], bObj[k]));
417
+ };
418
+ const deepEqualGroups = (a, b) => {
419
+ if (a.items.length !== b.items.length)
420
+ return false;
421
+ return a.items.every((item, i) => {
422
+ const other = b.items[i];
423
+ if (item.entityType !== other.entityType)
424
+ return false;
425
+ return deepEqual(item.attributes, other.attributes);
426
+ });
427
+ };
428
+ // ---------------------------------------------------------------------------
429
+ // Internal: Resolve the graph
430
+ // ---------------------------------------------------------------------------
431
+ const resolveNode = (fieldName, entityType, cardinality, edges, discriminator, assemble, decompose, ownDiscriminator) => {
432
+ const children = [];
433
+ for (const [field, edge] of Object.entries(edges)) {
434
+ if ("_tag" in edge) {
435
+ if (edge._tag === "OneEdge") {
436
+ // Merge parent discriminator with edge's own discriminator
437
+ const mergedDisc = edge.discriminator
438
+ ? { ...(discriminator ?? {}), ...edge.discriminator }
439
+ : discriminator;
440
+ children.push(resolveNode(field, edge.entityType, "one", {}, mergedDisc, undefined, undefined, edge.discriminator));
441
+ }
442
+ else if (edge._tag === "ManyEdge") {
443
+ children.push(resolveNode(field, edge.entityType, "many", {}, discriminator, edge.assemble, edge.decompose));
444
+ }
445
+ else if (edge._tag === "BoundSubAggregate") {
446
+ const bound = edge;
447
+ const subChildren = resolveNode(field, bound.aggregate.root.entityType, "one", bound.aggregate.edges, bound.discriminator, undefined, undefined, bound.discriminator);
448
+ children.push(subChildren);
449
+ }
450
+ }
451
+ }
452
+ return {
453
+ fieldName,
454
+ entityType,
455
+ cardinality,
456
+ discriminator,
457
+ ownDiscriminator,
458
+ children,
459
+ assemble,
460
+ decompose,
461
+ };
462
+ };
463
+ // ---------------------------------------------------------------------------
464
+ // Internal: Query all items in the aggregate partition
465
+ // ---------------------------------------------------------------------------
466
+ const queryAllItems = (client, tableName, indexName, pkField, pkValue) => Effect.gen(function* () {
467
+ const allItems = [];
468
+ let exclusiveStartKey;
469
+ // Paginate through all results
470
+ do {
471
+ const result = yield* client.query({
472
+ TableName: tableName,
473
+ IndexName: indexName,
474
+ KeyConditionExpression: "#pk = :pk",
475
+ ExpressionAttributeNames: { "#pk": pkField },
476
+ ExpressionAttributeValues: { ":pk": toAttributeValue(pkValue) },
477
+ ExclusiveStartKey: exclusiveStartKey,
478
+ });
479
+ if (result.Items) {
480
+ for (const item of result.Items) {
481
+ allItems.push(fromAttributeMap(item));
482
+ }
483
+ }
484
+ exclusiveStartKey = result.LastEvaluatedKey;
485
+ } while (exclusiveStartKey !== undefined);
486
+ return allItems;
487
+ });
488
+ // ---------------------------------------------------------------------------
489
+ // Internal: Assembly algorithm
490
+ // ---------------------------------------------------------------------------
491
+ const assembleAggregate = (schema, rootNode, allItems, _contextFields, dateEncodings, key, aggregateName) => Effect.gen(function* () {
492
+ // Find the root item
493
+ const rootItems = allItems.filter((item) => item.__edd_e__ === rootNode.entityType);
494
+ if (rootItems.length === 0) {
495
+ return yield* new AggregateAssemblyError({
496
+ aggregate: aggregateName,
497
+ reason: "Missing root item",
498
+ key,
499
+ });
500
+ }
501
+ if (rootItems.length > 1) {
502
+ return yield* new AggregateAssemblyError({
503
+ aggregate: aggregateName,
504
+ reason: "Multiple root items found",
505
+ key,
506
+ });
507
+ }
508
+ const rootItem = rootItems[0];
509
+ // Assemble edge values from the item collection
510
+ const edgeValues = {};
511
+ for (const child of rootNode.children) {
512
+ const assembled = yield* assembleNode(child, allItems, key, aggregateName);
513
+ if (child.fieldName !== null && assembled !== undefined) {
514
+ edgeValues[child.fieldName] = assembled;
515
+ }
516
+ }
517
+ // Combine root item fields (minus DynamoDB metadata + context) with assembled edges
518
+ const rootFields = {};
519
+ const edgeFieldNames = new Set(rootNode.children.map((c) => c.fieldName).filter(Boolean));
520
+ const metaFields = new Set([
521
+ "__edd_e__",
522
+ "pk",
523
+ "sk",
524
+ "gsi1pk",
525
+ "gsi1sk",
526
+ "gsi2pk",
527
+ "gsi2sk",
528
+ "gsi3pk",
529
+ "gsi3sk",
530
+ "gsi4pk",
531
+ "gsi4sk",
532
+ "gsi5pk",
533
+ "gsi5sk",
534
+ "lsi1sk",
535
+ "lsi2sk",
536
+ ]);
537
+ for (const [fieldKey, value] of Object.entries(rootItem)) {
538
+ if (metaFields.has(fieldKey))
539
+ continue;
540
+ if (edgeFieldNames.has(fieldKey))
541
+ continue;
542
+ rootFields[fieldKey] = value;
543
+ }
544
+ // Deserialize date context fields from DynamoDB storage primitives to domain values.
545
+ // Schema.decodeUnknownEffect expects domain types (Date, DateTime), not encoded strings.
546
+ for (const [field, encoding] of Object.entries(dateEncodings)) {
547
+ if (field in rootFields && rootFields[field] != null) {
548
+ rootFields[field] = deserializeDateFromDynamo(rootFields[field], encoding);
549
+ }
550
+ }
551
+ // Merge root fields with assembled edge values
552
+ const assembled = { ...rootFields, ...edgeValues };
553
+ // Decode through the aggregate's Schema.Class
554
+ const decoded = yield* Schema.decodeUnknownEffect(schema)(assembled).pipe(Effect.mapError((cause) => new ValidationError({
555
+ entityType: aggregateName,
556
+ operation: "aggregate.assemble",
557
+ cause,
558
+ })));
559
+ return decoded;
560
+ });
561
+ const assembleNode = (node, allItems, key, aggregateName) => Effect.gen(function* () {
562
+ // Find items matching this node's entity type and discriminator
563
+ const matchingItems = allItems.filter((item) => {
564
+ if (item.__edd_e__ !== node.entityType)
565
+ return false;
566
+ // If this node has a discriminator, check that item attributes match
567
+ if (node.discriminator) {
568
+ for (const [attr, value] of Object.entries(node.discriminator)) {
569
+ // Skip computed discriminators (functions) — only match on static values
570
+ if (typeof value === "function")
571
+ continue;
572
+ if (item[attr] !== value)
573
+ return false;
574
+ }
575
+ }
576
+ return true;
577
+ });
578
+ if (node.cardinality === "one") {
579
+ // One-to-one: sub-aggregate root or simple one edge
580
+ if (node.children.length > 0) {
581
+ // Sub-aggregate — assemble recursively
582
+ // If no matching items found, return undefined (supports optional sub-aggregates)
583
+ if (matchingItems.length === 0)
584
+ return undefined;
585
+ if (matchingItems.length > 1) {
586
+ return yield* new AggregateAssemblyError({
587
+ aggregate: aggregateName,
588
+ reason: `Multiple sub-aggregate root items for "${node.fieldName}" (entityType: ${node.entityType})`,
589
+ key,
590
+ });
591
+ }
592
+ const subRootItem = matchingItems[0];
593
+ const edgeValues = {};
594
+ for (const child of node.children) {
595
+ // For children within a sub-aggregate, propagate the parent's discriminator
596
+ const childNode = node.discriminator
597
+ ? { ...child, discriminator: { ...node.discriminator, ...child.discriminator } }
598
+ : child;
599
+ const assembled = yield* assembleNode(childNode, allItems, key, aggregateName);
600
+ if (child.fieldName !== null && assembled !== undefined) {
601
+ edgeValues[child.fieldName] = assembled;
602
+ }
603
+ }
604
+ // Build sub-aggregate result: sub-root fields + assembled edges
605
+ const subFields = {};
606
+ const edgeFieldNames = new Set(node.children.map((c) => c.fieldName).filter(Boolean));
607
+ for (const [fieldKey, value] of Object.entries(subRootItem)) {
608
+ // Skip DynamoDB metadata, discriminator attributes, and edge fields
609
+ if (fieldKey === "__edd_e__")
610
+ continue;
611
+ if (fieldKey.startsWith("pk") || fieldKey.startsWith("sk"))
612
+ continue;
613
+ if (fieldKey.startsWith("gsi") || fieldKey.startsWith("lsi"))
614
+ continue;
615
+ if (edgeFieldNames.has(fieldKey))
616
+ continue;
617
+ // Skip discriminator attributes
618
+ if (node.discriminator && fieldKey in node.discriminator)
619
+ continue;
620
+ subFields[fieldKey] = value;
621
+ }
622
+ return { ...subFields, ...edgeValues };
623
+ }
624
+ // Simple one-to-one edge — if no matching items, return undefined
625
+ // (the Schema decode will catch truly required missing fields)
626
+ if (matchingItems.length === 0)
627
+ return undefined;
628
+ // Return the item (stripped of DynamoDB metadata)
629
+ const item = matchingItems[0];
630
+ return stripMetadata(item);
631
+ }
632
+ // Many edge: collect all matching items
633
+ const stripped = matchingItems.map(stripMetadata);
634
+ if (node.assemble) {
635
+ return node.assemble(stripped);
636
+ }
637
+ return stripped;
638
+ });
639
+ // ---------------------------------------------------------------------------
640
+ // Internal: Strip DynamoDB metadata from items
641
+ // ---------------------------------------------------------------------------
642
+ const stripMetadata = (item) => {
643
+ const result = {};
644
+ for (const [key, value] of Object.entries(item)) {
645
+ if (key === "__edd_e__")
646
+ continue;
647
+ if (key === "pk" || key === "sk")
648
+ continue;
649
+ if (key.startsWith("gsi") || key.startsWith("lsi"))
650
+ continue;
651
+ result[key] = value;
652
+ }
653
+ return result;
654
+ };
655
+ /**
656
+ * Walk the input recursively following graph edges, collect ref IDs,
657
+ * fetch referenced entities, and replace IDs with hydrated domain data.
658
+ */
659
+ const hydrateAggregateRefs = (input, edges, aggregateName) => Effect.gen(function* () {
660
+ // Collect all ref IDs from the entire input hierarchy
661
+ const refRequests = [];
662
+ collectRefIdsFromEdges(refRequests, input, edges, []);
663
+ if (refRequests.length === 0)
664
+ return input;
665
+ // Group by entity type for efficient fetching
666
+ const byEntityType = new Map();
667
+ for (const req of refRequests) {
668
+ const key = req.entity.entityType;
669
+ const existing = byEntityType.get(key);
670
+ if (existing) {
671
+ existing.reqs.push(req);
672
+ }
673
+ else {
674
+ byEntityType.set(key, { entity: req.entity, reqs: [req] });
675
+ }
676
+ }
677
+ // Batch-fetch all refs — one Batch.get call per entity type
678
+ const hydratedLookup = new Map();
679
+ for (const [entityType, { entity, reqs }] of byEntityType) {
680
+ const idField = DynamoModel.getIdentifierField(entity.model);
681
+ const identifierName = idField?.name ?? "id";
682
+ // Deduplicate IDs for this entity type
683
+ const uniqueIds = [...new Set(reqs.map((r) => r.id))];
684
+ // Build EntityGet intermediates for batch fetching
685
+ const getOps = uniqueIds.map((id) => entity.get({ [identifierName]: id }));
686
+ const results = yield* Batch.get(getOps);
687
+ // Map results back to lookup, failing on missing refs
688
+ for (let i = 0; i < uniqueIds.length; i++) {
689
+ const id = uniqueIds[i];
690
+ const result = results[i];
691
+ if (result == null) {
692
+ return yield* new RefNotFound({
693
+ entity: aggregateName,
694
+ field: reqs.find((r) => r.id === id)?.fieldName ?? "",
695
+ refEntity: entityType,
696
+ refId: id,
697
+ });
698
+ }
699
+ hydratedLookup.set(`${entityType}:${id}`, { ...result });
700
+ }
701
+ }
702
+ // Walk the input again and replace IDs with hydrated data
703
+ const result = replaceRefIds(input, refRequests, hydratedLookup);
704
+ renameInputFields(result, edges);
705
+ return result;
706
+ });
707
+ /**
708
+ * Rename ManyEdge inputField keys back to model field names so the domain
709
+ * schema can decode correctly. Walks edges recursively through sub-aggregates.
710
+ */
711
+ const renameInputFields = (data, edges) => {
712
+ for (const [edgeName, edge] of Object.entries(edges)) {
713
+ if (!("_tag" in edge))
714
+ continue;
715
+ if (edge._tag === "ManyEdge" && edge.inputField && edge.inputField !== edgeName) {
716
+ if (edge.inputField in data) {
717
+ data[edgeName] = data[edge.inputField];
718
+ delete data[edge.inputField];
719
+ }
720
+ }
721
+ else if (edge._tag === "BoundSubAggregate") {
722
+ const sub = data[edgeName];
723
+ if (sub != null && typeof sub === "object") {
724
+ renameInputFields(sub, edge.aggregate.edges);
725
+ }
726
+ }
727
+ }
728
+ };
729
+ /**
730
+ * Recursively walk input following graph edges to collect ref ID references.
731
+ * Edge-driven: entity lookup comes from the edge directly, not from a refs bag.
732
+ */
733
+ const collectRefIdsFromEdges = (result, input, edges, path) => {
734
+ for (const [edgeName, edge] of Object.entries(edges)) {
735
+ if (!("_tag" in edge))
736
+ continue;
737
+ if (edge._tag === "RefEdge") {
738
+ // RefEdge → ${field}Id in input
739
+ const idFieldName = `${edgeName}Id`;
740
+ const idValue = input[idFieldName];
741
+ if (typeof idValue === "string") {
742
+ result.push({
743
+ entity: edge.entity,
744
+ id: idValue,
745
+ path: [...path],
746
+ fieldName: edgeName,
747
+ idFieldName,
748
+ });
749
+ }
750
+ }
751
+ else if (edge._tag === "OneEdge" && edge.entity) {
752
+ // OneEdge with entity → ${field}Id in input
753
+ const idFieldName = `${edgeName}Id`;
754
+ const idValue = input[idFieldName];
755
+ if (typeof idValue === "string") {
756
+ result.push({
757
+ entity: edge.entity,
758
+ id: idValue,
759
+ path: [...path],
760
+ fieldName: edgeName,
761
+ idFieldName,
762
+ });
763
+ }
764
+ }
765
+ else if (edge._tag === "BoundSubAggregate") {
766
+ const bound = edge;
767
+ const subInput = input[edgeName];
768
+ if (subInput != null && typeof subInput === "object") {
769
+ collectRefIdsFromEdges(result, subInput, bound.aggregate.edges, [
770
+ ...path,
771
+ edgeName,
772
+ ]);
773
+ }
774
+ }
775
+ else if (edge._tag === "ManyEdge" && edge.entity) {
776
+ // ManyEdge with entity → walk array elements for ref IDs
777
+ const inputKey = edge.inputField ?? edgeName;
778
+ const arr = input[inputKey];
779
+ if (Array.isArray(arr)) {
780
+ const entityFieldName = deriveEntityFieldName(edge.entity);
781
+ for (let i = 0; i < arr.length; i++) {
782
+ const elem = arr[i];
783
+ if (typeof elem === "string") {
784
+ // Element IS the ref ID (e.g., Array<string> for Array<Umpire>)
785
+ result.push({
786
+ entity: edge.entity,
787
+ id: elem,
788
+ path: [...path, inputKey, String(i)],
789
+ fieldName: edgeName,
790
+ idFieldName: String(i),
791
+ });
792
+ }
793
+ else if (elem != null && typeof elem === "object") {
794
+ // Element wraps entity + attributes — find the entityFieldName + "Id"
795
+ const elemObj = elem;
796
+ const idFieldName = `${entityFieldName}Id`;
797
+ const idValue = elemObj[idFieldName];
798
+ if (typeof idValue === "string") {
799
+ result.push({
800
+ entity: edge.entity,
801
+ id: idValue,
802
+ path: [...path, inputKey, String(i)],
803
+ fieldName: entityFieldName,
804
+ idFieldName,
805
+ });
806
+ }
807
+ }
808
+ }
809
+ }
810
+ }
811
+ }
812
+ };
813
+ /**
814
+ * Replace ref ID fields in the input with hydrated domain data.
815
+ */
816
+ const replaceRefIds = (input, refRequests, lookup) => {
817
+ // Deep clone the input for mutation
818
+ const result = structuredClone(input);
819
+ for (const req of refRequests) {
820
+ const lookupKey = `${req.entity.entityType}:${req.id}`;
821
+ const data = lookup.get(lookupKey);
822
+ if (!data)
823
+ continue;
824
+ // Navigate to the target location in the result
825
+ let target = result;
826
+ for (const segment of req.path) {
827
+ const next = target[segment];
828
+ if (next == null || typeof next !== "object")
829
+ break;
830
+ target = next;
831
+ }
832
+ // For ManyEdge elements that are direct IDs (element IS the ref),
833
+ // the target is the array itself — replace the element at the index
834
+ if (Array.isArray(target) && /^\d+$/.test(req.idFieldName)) {
835
+ target[Number(req.idFieldName)] = data;
836
+ }
837
+ else {
838
+ // Replace: remove ${field}Id, add ${field}: data
839
+ delete target[req.idFieldName];
840
+ target[req.fieldName] = data;
841
+ }
842
+ }
843
+ return result;
844
+ };
845
+ /**
846
+ * Decompose an assembled domain object into DynamoDB items grouped by
847
+ * sub-aggregate transaction boundaries.
848
+ */
849
+ const decomposeAggregate = (assembled, rootNode, contextFields, dateEncodings, aggregateName) => Effect.gen(function* () {
850
+ const items = [];
851
+ // Extract context values from the root, serializing date fields for DynamoDB storage.
852
+ // Domain Date/DateTime objects must be encoded before toAttributeMap() marshalling,
853
+ // otherwise Date objects become { M: {} } (no enumerable properties).
854
+ const contextValues = {};
855
+ for (const field of contextFields) {
856
+ const value = assembled[field];
857
+ const encoding = dateEncodings[field];
858
+ contextValues[field] =
859
+ encoding && value != null ? serializeDateForDynamo(value, encoding) : value;
860
+ }
861
+ // Root item: fields not claimed by edges
862
+ const edgeFieldNames = new Set(rootNode.children.map((c) => c.fieldName).filter(Boolean));
863
+ const rootAttrs = {};
864
+ for (const [key, value] of Object.entries(assembled)) {
865
+ if (edgeFieldNames.has(key))
866
+ continue;
867
+ // Serialize date fields in root attributes (same as context values)
868
+ const encoding = dateEncodings[key];
869
+ rootAttrs[key] = encoding && value != null ? serializeDateForDynamo(value, encoding) : value;
870
+ }
871
+ items.push({
872
+ entityType: rootNode.entityType,
873
+ attributes: rootAttrs,
874
+ transactionGroup: "root",
875
+ skComposites: [],
876
+ });
877
+ // Decompose each edge
878
+ for (const child of rootNode.children) {
879
+ const fieldValue = assembled[child.fieldName];
880
+ yield* decomposeNode(items, child, fieldValue, contextValues, "root", [], aggregateName);
881
+ }
882
+ // Group by transaction
883
+ const groups = new Map();
884
+ for (const item of items) {
885
+ const list = groups.get(item.transactionGroup) ?? [];
886
+ list.push(item);
887
+ groups.set(item.transactionGroup, list);
888
+ }
889
+ return [...groups.entries()].map(([name, groupItems]) => ({ name, items: groupItems }));
890
+ });
891
+ /**
892
+ * Merge context values into decomposed item attributes.
893
+ * Context values always override entity-specific fields — the aggregate root
894
+ * defines the authoritative values for context fields like "name" and "gender".
895
+ */
896
+ const mergeContextValues = (attrs, contextValues) => {
897
+ for (const [key, value] of Object.entries(contextValues)) {
898
+ attrs[key] = value;
899
+ }
900
+ };
901
+ /**
902
+ * Recursively decompose a node's value into DynamoDB items.
903
+ */
904
+ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDiscriminatorValues, aggregateName) => Effect.gen(function* () {
905
+ // Optional edges may have null/undefined values — skip decomposition
906
+ if (value == null)
907
+ return;
908
+ if (node.cardinality === "one" && node.children.length > 0) {
909
+ // Sub-aggregate — becomes its own transaction group
910
+ const subValue = value;
911
+ const txGroup = node.fieldName ?? node.entityType;
912
+ // Discriminator values for SK composition (name#value pairs from own discriminator)
913
+ const discValues = node.ownDiscriminator
914
+ ? Object.entries(node.ownDiscriminator)
915
+ .filter(([, v]) => typeof v !== "function")
916
+ .flatMap(([k, v]) => [k, KeyComposer.serializeValue(v)])
917
+ : [];
918
+ // Sub-aggregate root item: fields not claimed by child edges
919
+ const childEdgeNames = new Set(node.children.map((c) => c.fieldName).filter(Boolean));
920
+ const subRootAttrs = {};
921
+ for (const [key, val] of Object.entries(subValue)) {
922
+ if (childEdgeNames.has(key))
923
+ continue;
924
+ subRootAttrs[key] = val;
925
+ }
926
+ // Inject context and discriminator
927
+ mergeContextValues(subRootAttrs, contextValues);
928
+ if (node.discriminator) {
929
+ for (const [k, v] of Object.entries(node.discriminator)) {
930
+ if (typeof v !== "function")
931
+ subRootAttrs[k] = v;
932
+ }
933
+ }
934
+ items.push({
935
+ entityType: node.entityType,
936
+ attributes: subRootAttrs,
937
+ transactionGroup: txGroup,
938
+ skComposites: discValues,
939
+ });
940
+ // Decompose children within this sub-aggregate
941
+ for (const child of node.children) {
942
+ const childValue = subValue[child.fieldName];
943
+ yield* decomposeNode(items, child, childValue, contextValues, txGroup, discValues, aggregateName);
944
+ }
945
+ }
946
+ else if (node.cardinality === "one") {
947
+ // Simple one-to-one edge (no children)
948
+ const attrs = { ...value };
949
+ mergeContextValues(attrs, contextValues);
950
+ if (node.discriminator) {
951
+ for (const [k, v] of Object.entries(node.discriminator)) {
952
+ if (typeof v !== "function")
953
+ attrs[k] = v;
954
+ }
955
+ }
956
+ // Own discriminator values as name#value pairs for SK composition
957
+ const ownDiscValues = node.ownDiscriminator
958
+ ? Object.entries(node.ownDiscriminator)
959
+ .filter(([, v]) => typeof v !== "function")
960
+ .flatMap(([k, v]) => [k, KeyComposer.serializeValue(v)])
961
+ : [];
962
+ items.push({
963
+ entityType: node.entityType,
964
+ attributes: attrs,
965
+ transactionGroup: parentGroup,
966
+ skComposites: [...parentDiscriminatorValues, ...ownDiscValues],
967
+ });
968
+ }
969
+ else {
970
+ // Many edge
971
+ const arrayItems = node.decompose
972
+ ? node.decompose(value)
973
+ : Array.isArray(value)
974
+ ? value
975
+ : [];
976
+ for (const elem of arrayItems) {
977
+ const attrs = { ...elem };
978
+ mergeContextValues(attrs, contextValues);
979
+ if (node.discriminator) {
980
+ for (const [k, v] of Object.entries(node.discriminator)) {
981
+ if (typeof v !== "function")
982
+ attrs[k] = v;
983
+ }
984
+ }
985
+ // Extract ref identifiers for SK composites
986
+ const itemComposites = extractRefIdentifiers(attrs);
987
+ items.push({
988
+ entityType: node.entityType,
989
+ attributes: attrs,
990
+ transactionGroup: parentGroup,
991
+ skComposites: [...parentDiscriminatorValues, ...itemComposites],
992
+ });
993
+ }
994
+ }
995
+ });
996
+ /**
997
+ * Extract ref identifier values from an item's attributes for use as SK composites.
998
+ * Walks fields looking for embedded objects that have an identifier-annotated field.
999
+ */
1000
+ const extractRefIdentifiers = (attrs) => {
1001
+ const ids = [];
1002
+ // Case 1: Element wraps entity — look for nested objects with id-like fields
1003
+ for (const [, value] of Object.entries(attrs)) {
1004
+ if (value != null && typeof value === "object" && !Array.isArray(value)) {
1005
+ const obj = value;
1006
+ if (typeof obj.id === "string") {
1007
+ ids.push(obj.id);
1008
+ }
1009
+ else {
1010
+ for (const [k, v] of Object.entries(obj)) {
1011
+ if (k.endsWith("Id") && typeof v === "string") {
1012
+ ids.push(v);
1013
+ break;
1014
+ }
1015
+ }
1016
+ }
1017
+ }
1018
+ }
1019
+ // Case 2: Element IS entity — top-level id field (e.g., Umpire with { id: "ump-1", ... })
1020
+ if (ids.length === 0 && typeof attrs.id === "string") {
1021
+ ids.push(attrs.id);
1022
+ }
1023
+ return ids;
1024
+ };
1025
+ // ---------------------------------------------------------------------------
1026
+ // Internal: Hash-based shard selection for list collection cardinality
1027
+ // ---------------------------------------------------------------------------
1028
+ const hashToShard = (value, cardinality) => {
1029
+ let hash = 5381;
1030
+ for (let i = 0; i < value.length; i++) {
1031
+ hash = ((hash << 5) + hash + value.charCodeAt(i)) | 0;
1032
+ }
1033
+ return Math.abs(hash) % cardinality;
1034
+ };
1035
+ // ---------------------------------------------------------------------------
1036
+ // Internal: Compose DynamoDB items from decomposed data
1037
+ // ---------------------------------------------------------------------------
1038
+ /**
1039
+ * Convert decomposed items into DynamoDB attribute maps with composed keys.
1040
+ */
1041
+ const buildDynamoItems = (groups, config, pkValue, rootCollectionSkComposites) => groups.map((group) => ({
1042
+ group: group.name,
1043
+ items: group.items.map((item) => {
1044
+ const attrs = { ...item.attributes };
1045
+ // Add entity type discriminator
1046
+ attrs.__edd_e__ = item.entityType;
1047
+ // PK: shared collection key
1048
+ attrs[config.pk.field] = pkValue;
1049
+ // SK: entity-type specific
1050
+ attrs.sk = composeKey(config.schema, item.entityType, [...item.skComposites]);
1051
+ // Collection SK (LSI): root uses collection composites, others mirror their SK
1052
+ const isRootItem = item.entityType === config.root.entityType && item.skComposites.length === 0;
1053
+ attrs[config.collection.sk.field] = isRootItem
1054
+ ? composeCollectionKey(config.schema, config.collection.name, rootCollectionSkComposites)
1055
+ : composeKey(config.schema, item.entityType, [...item.skComposites]);
1056
+ // List collection GSI keys (root items only)
1057
+ if (isRootItem && config.list) {
1058
+ const listPkComposites = KeyComposer.extractComposites(config.list.pk.composite, item.attributes);
1059
+ if (config.list.cardinality) {
1060
+ const shard = hashToShard(pkValue, config.list.cardinality);
1061
+ attrs[config.list.pk.field] = composeCollectionKey(config.schema, config.list.name, [
1062
+ ...listPkComposites,
1063
+ String(shard),
1064
+ ]);
1065
+ }
1066
+ else {
1067
+ attrs[config.list.pk.field] = composeCollectionKey(config.schema, config.list.name, listPkComposites);
1068
+ }
1069
+ const listSkComposites = KeyComposer.extractComposites(config.list.sk.composite, item.attributes);
1070
+ attrs[config.list.sk.field] = composeCollectionKey(config.schema, config.list.name, listSkComposites);
1071
+ }
1072
+ return toAttributeMap(attrs);
1073
+ }),
1074
+ }));
1075
+ // ---------------------------------------------------------------------------
1076
+ // Internal: Write transaction groups to DynamoDB
1077
+ // ---------------------------------------------------------------------------
1078
+ const writeTransactionGroups = (client, tableName, groups, aggregateName) => Effect.gen(function* () {
1079
+ for (const group of groups) {
1080
+ // Check transaction size limit
1081
+ if (group.items.length > 100) {
1082
+ return yield* new AggregateTransactionOverflow({
1083
+ aggregate: aggregateName,
1084
+ subgraph: group.group,
1085
+ itemCount: group.items.length,
1086
+ limit: 100,
1087
+ });
1088
+ }
1089
+ if (group.items.length === 0)
1090
+ continue;
1091
+ // Build TransactWriteItems request
1092
+ const transactItems = group.items.map((item) => ({
1093
+ Put: { TableName: tableName, Item: item },
1094
+ }));
1095
+ yield* client.transactWriteItems({ TransactItems: transactItems }).pipe(Effect.mapError((error) => {
1096
+ if (error.cause != null &&
1097
+ typeof error.cause === "object" &&
1098
+ "name" in error.cause &&
1099
+ error.cause.name === "TransactionCanceledException") {
1100
+ const cancelled = error.cause;
1101
+ return new TransactionCancelled({
1102
+ operation: "TransactWriteItems",
1103
+ reasons: (cancelled.CancellationReasons ?? []).map((r) => ({
1104
+ code: r?.Code,
1105
+ message: r?.Message,
1106
+ })),
1107
+ cause: error.cause,
1108
+ });
1109
+ }
1110
+ return error;
1111
+ }));
1112
+ }
1113
+ });
1114
+ // ---------------------------------------------------------------------------
1115
+ // Internal: Delete all items in an aggregate partition
1116
+ // ---------------------------------------------------------------------------
1117
+ const deleteAllItems = (client, tableName, allItems, pkField) => Effect.gen(function* () {
1118
+ // Extract primary keys from items
1119
+ const deleteRequests = allItems.map((item) => ({
1120
+ DeleteRequest: {
1121
+ Key: toAttributeMap({
1122
+ [pkField]: item[pkField],
1123
+ sk: item.sk,
1124
+ }),
1125
+ },
1126
+ }));
1127
+ // Chunk into batches of 25 (DynamoDB batchWriteItem limit)
1128
+ for (let i = 0; i < deleteRequests.length; i += 25) {
1129
+ const chunk = deleteRequests.slice(i, i + 25);
1130
+ yield* client.batchWriteItem({
1131
+ RequestItems: { [tableName]: chunk },
1132
+ });
1133
+ }
1134
+ });
1135
+ const queryListPartition = (client, tableConfig, listConfig, schema, pkValue, skValues, limit, startKey) => Effect.gen(function* () {
1136
+ const exprNames = { "#pk": listConfig.pk.field };
1137
+ const exprValues = { ":pk": toAttributeValue(pkValue) };
1138
+ let keyCondition = "#pk = :pk";
1139
+ if (skValues.length > 0) {
1140
+ const skPrefix = composeCollectionKey(schema, listConfig.name, skValues);
1141
+ exprNames["#sk"] = listConfig.sk.field;
1142
+ exprValues[":skPrefix"] = toAttributeValue(skPrefix);
1143
+ keyCondition += " AND begins_with(#sk, :skPrefix)";
1144
+ }
1145
+ const items = [];
1146
+ let lastKey = startKey;
1147
+ do {
1148
+ const result = yield* client.query({
1149
+ TableName: tableConfig.name,
1150
+ IndexName: listConfig.index,
1151
+ KeyConditionExpression: keyCondition,
1152
+ ExpressionAttributeNames: exprNames,
1153
+ ExpressionAttributeValues: exprValues,
1154
+ ExclusiveStartKey: lastKey,
1155
+ ...(limit !== undefined ? { Limit: limit - items.length } : {}),
1156
+ });
1157
+ if (result.Items) {
1158
+ for (const item of result.Items) {
1159
+ items.push(fromAttributeMap(item));
1160
+ }
1161
+ }
1162
+ lastKey = result.LastEvaluatedKey;
1163
+ // Stop when we've collected enough items
1164
+ if (limit !== undefined && items.length >= limit)
1165
+ break;
1166
+ } while (lastKey !== undefined);
1167
+ return { items, lastKey };
1168
+ });
1169
+ //# sourceMappingURL=Aggregate.js.map