effect-dynamodb 1.14.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Aggregate.d.ts +17 -1
- package/dist/Aggregate.d.ts.map +1 -1
- package/dist/Aggregate.js +413 -82
- package/dist/Aggregate.js.map +1 -1
- package/dist/Batch.d.ts +10 -2
- package/dist/Batch.d.ts.map +1 -1
- package/dist/Batch.js +45 -6
- package/dist/Batch.js.map +1 -1
- package/dist/Collection.d.ts +0 -7
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +84 -2
- package/dist/Collection.js.map +1 -1
- package/dist/DynamoClient.d.ts +3 -1
- package/dist/DynamoClient.d.ts.map +1 -1
- package/dist/DynamoClient.js +253 -26
- package/dist/DynamoClient.js.map +1 -1
- package/dist/Entity.d.ts +87 -6
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +413 -144
- package/dist/Entity.js.map +1 -1
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +49 -17
- package/dist/EventStore.js.map +1 -1
- package/dist/Query.d.ts +56 -5
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +206 -30
- package/dist/Query.js.map +1 -1
- package/dist/Transaction.d.ts +2 -2
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +29 -9
- package/dist/Transaction.js.map +1 -1
- package/dist/internal/BoundCrud.d.ts +76 -9
- package/dist/internal/BoundCrud.d.ts.map +1 -1
- package/dist/internal/BoundCrud.js +22 -1
- package/dist/internal/BoundCrud.js.map +1 -1
- package/dist/internal/BoundQuery.d.ts +75 -13
- package/dist/internal/BoundQuery.d.ts.map +1 -1
- package/dist/internal/BoundQuery.js +85 -11
- package/dist/internal/BoundQuery.js.map +1 -1
- package/dist/internal/BoundVectorQuery.d.ts +9 -0
- package/dist/internal/BoundVectorQuery.d.ts.map +1 -1
- package/dist/internal/BoundVectorQuery.js +3 -2
- package/dist/internal/BoundVectorQuery.js.map +1 -1
- package/dist/internal/EntityCombinators.d.ts +31 -5
- package/dist/internal/EntityCombinators.d.ts.map +1 -1
- package/dist/internal/EntityCombinators.js +7 -7
- package/dist/internal/EntityCombinators.js.map +1 -1
- package/dist/internal/EntityOps.d.ts +14 -1
- package/dist/internal/EntityOps.d.ts.map +1 -1
- package/dist/internal/EntityOps.js +4 -1
- package/dist/internal/EntityOps.js.map +1 -1
- package/dist/internal/TransactWriteOps.d.ts +44 -5
- package/dist/internal/TransactWriteOps.d.ts.map +1 -1
- package/dist/internal/TransactWriteOps.js +98 -20
- package/dist/internal/TransactWriteOps.js.map +1 -1
- package/dist/internal/TransactableOps.d.ts +69 -1
- package/dist/internal/TransactableOps.d.ts.map +1 -1
- package/dist/internal/TransactableOps.js +138 -4
- package/dist/internal/TransactableOps.js.map +1 -1
- package/package.json +2 -2
package/dist/Aggregate.js
CHANGED
|
@@ -11,13 +11,16 @@
|
|
|
11
11
|
import { TypeId as SchemaAggregateTypeId } from "@effect-dynamodb/schema/Aggregate.js";
|
|
12
12
|
import * as DynamoModel from "@effect-dynamodb/schema/DynamoModel.js";
|
|
13
13
|
import { composeCollectionKey, composeKey } from "@effect-dynamodb/schema/DynamoSchema.js";
|
|
14
|
-
import { AggregateAssemblyError, AggregateTransactionOverflow, RefNotFound, TransactionCancelled, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
|
|
15
|
-
import {
|
|
14
|
+
import { AggregateAssemblyError, AggregateDecompositionError, AggregateTransactionOverflow, RefNotFound, TransactionCancelled, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
|
|
15
|
+
import { makeCompositeKeyForm, toCompositeKeyRecord, } from "@effect-dynamodb/schema/internal/CompositeCodec.js";
|
|
16
|
+
import { buildDateTransform, matchDateRepresentation, resolveSystemFields, substituteSchemaDeep, validateNoTransformOverride, } from "@effect-dynamodb/schema/internal/EntitySchemas.js";
|
|
17
|
+
import { hasEncodingTransformation } from "@effect-dynamodb/schema/internal/SchemaAccessors.js";
|
|
16
18
|
import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
|
|
17
|
-
import { Effect, Schema, SchemaAST } from "effect";
|
|
19
|
+
import { DateTime, Effect, Option, Schema, SchemaAST } from "effect";
|
|
18
20
|
import * as Batch from "./Batch.js";
|
|
19
21
|
import { DynamoClient } from "./DynamoClient.js";
|
|
20
22
|
import { fromDefinition as entityFromDefinition } from "./Entity.js";
|
|
23
|
+
import { generateTimestampPrimitive } from "./internal/TransactableOps.js";
|
|
21
24
|
import { fromAttributeMap, toAttributeMap, toAttributeValue } from "./Marshaller.js";
|
|
22
25
|
import { resolvePrimaryKey } from "./Table.js";
|
|
23
26
|
// Internal modules (decomposed from Aggregate.ts)
|
|
@@ -95,38 +98,98 @@ const inferDateEncoding = (ast) => {
|
|
|
95
98
|
return undefined;
|
|
96
99
|
};
|
|
97
100
|
/**
|
|
98
|
-
* Build per-field
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
101
|
+
* Build per-field ATTRIBUTE encoders for a schema's own fields — the write-side
|
|
102
|
+
* equivalent of what `Entity.put` gets for free by encoding its whole input
|
|
103
|
+
* through `inputSchema`.
|
|
104
|
+
*
|
|
105
|
+
* Decomposition works from the schema-DECODED domain object, so every attribute
|
|
106
|
+
* it produces is a Type-side value. Marshalling those directly stores a shape
|
|
107
|
+
* the read path cannot decode: a `Schema.BigIntFromString` field lands as
|
|
108
|
+
* `{N:"5"}` and assembly's decode (which expects the encoded string) rejects it,
|
|
109
|
+
* so the aggregate cannot round-trip at all. Dates were noticed first (#72) and
|
|
110
|
+
* got a date-only pass; the same argument applies to every transformed field.
|
|
111
|
+
*
|
|
112
|
+
* Exactly ONE encoder per field, so nothing is ever encoded twice:
|
|
113
|
+
*
|
|
114
|
+
* 1. **A date encoding** — an explicit `DynamoModel.storedAs` annotation, or the
|
|
115
|
+
* inferred default for a standard Effect date schema. This wins over the
|
|
116
|
+
* field's own encode because `storedAs` is precisely an override of it
|
|
117
|
+
* (a `Schema.DateTimeUtc` field marked `storedAs(DateEpochMs)` must store the
|
|
118
|
+
* epoch, not the ISO string its own schema would produce). This branch is the
|
|
119
|
+
* former `buildAttrEncoders`, unchanged.
|
|
120
|
+
* 2. **Any other encoding transformation** — the field's own `encode`. A ref
|
|
121
|
+
* field's schema is the referenced entity's, so a hydrated ref is encoded
|
|
122
|
+
* with the entity it came from, not with the aggregate's schema.
|
|
123
|
+
* 3. **No transformation** — no encoder at all, so the stored bytes are
|
|
124
|
+
* identical to before this existed.
|
|
125
|
+
*
|
|
126
|
+
* The READ path is handled entirely by the aggregate's tolerant `decodeSchema`
|
|
127
|
+
* (see `makeAggregate`), so no separate decoders are needed.
|
|
104
128
|
*/
|
|
105
|
-
const
|
|
129
|
+
const buildAttrEncoders = (fields) => {
|
|
106
130
|
const encoders = {};
|
|
107
|
-
if (fields)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
continue;
|
|
131
|
+
if (!fields)
|
|
132
|
+
return encoders;
|
|
133
|
+
for (const field of Object.keys(fields)) {
|
|
134
|
+
const fieldSchema = fields[field];
|
|
135
|
+
// 1. Date encoding — `storedAs` override, else the inferred date default.
|
|
136
|
+
const encoding = DynamoModel.getEncoding(fieldSchema) ?? inferDateEncoding(fieldSchema.ast);
|
|
137
|
+
if (encoding) {
|
|
115
138
|
const encode = Schema.encodeUnknownSync(buildDateTransform(encoding));
|
|
116
139
|
encoders[field] = (value) => encode(value);
|
|
140
|
+
continue;
|
|
117
141
|
}
|
|
142
|
+
// 3. Identity by construction — `SchemaAST` documents `encoding === undefined`
|
|
143
|
+
// as "type and encoded forms are identical". Adding an encoder here could
|
|
144
|
+
// only change bytes that are already correct.
|
|
145
|
+
if (!hasEncodingTransformation(fieldSchema))
|
|
146
|
+
continue;
|
|
147
|
+
// 2. The field's own encode, with `decode -> encode` as the fallback so a
|
|
148
|
+
// caller who already supplied wire form round-trips to itself (the same
|
|
149
|
+
// strategy `Entity.put` uses). Neither working means the value is neither
|
|
150
|
+
// Type nor Encoded for this field; store it unchanged rather than
|
|
151
|
+
// introduce a new write-time failure mode mid-release — that shape is
|
|
152
|
+
// already unreadable, and the tolerant read path reports it.
|
|
153
|
+
const codec = fieldSchema;
|
|
154
|
+
const encode = Schema.encodeUnknownOption(codec);
|
|
155
|
+
const decode = Schema.decodeUnknownOption(codec);
|
|
156
|
+
encoders[field] = (value) => {
|
|
157
|
+
const direct = encode(value);
|
|
158
|
+
if (Option.isSome(direct))
|
|
159
|
+
return direct.value;
|
|
160
|
+
const decoded = decode(value);
|
|
161
|
+
if (Option.isSome(decoded)) {
|
|
162
|
+
const reencoded = encode(decoded.value);
|
|
163
|
+
if (Option.isSome(reencoded))
|
|
164
|
+
return reencoded.value;
|
|
165
|
+
}
|
|
166
|
+
return value;
|
|
167
|
+
};
|
|
118
168
|
}
|
|
119
169
|
return encoders;
|
|
120
170
|
};
|
|
121
|
-
/**
|
|
122
|
-
|
|
171
|
+
/**
|
|
172
|
+
* A schema's declared fields, if it exposes them (Schema.Struct / Schema.Class).
|
|
173
|
+
*
|
|
174
|
+
* Unwraps `DynamoModel.configure(...)` first: that returns a `{ model, attributes }`
|
|
175
|
+
* WRAPPER, not a schema, so reading `.fields` off it yields nothing. An edge
|
|
176
|
+
* entity declared with a configured model therefore got NO encoders at all —
|
|
177
|
+
* its dates marshalled as `{M:{...}}` and its transformed fields as their Type
|
|
178
|
+
* values, on every row of that edge. Configured models are the norm (any
|
|
179
|
+
* `identifier: true` / `field:` rename produces one), so this blind spot covered
|
|
180
|
+
* most real edges.
|
|
181
|
+
*/
|
|
182
|
+
const fieldsOf = (schema) => {
|
|
183
|
+
const source = DynamoModel.isConfiguredModel(schema) ? schema.model : schema;
|
|
184
|
+
return source?.fields;
|
|
185
|
+
};
|
|
123
186
|
/**
|
|
124
187
|
* Apply a node's date encoders in place — converts the node entity's own domain
|
|
125
188
|
* date fields to their wire primitives before the decomposed item is marshalled
|
|
126
189
|
* (issue #72: without this, an edge's `DateTime` field marshals to `{M:{}}` and
|
|
127
190
|
* the subsequent `get`/assemble fails decoding it as a string).
|
|
128
191
|
*/
|
|
129
|
-
const
|
|
192
|
+
const applyNodeAttrEncoders = (attrs, encoders) => {
|
|
130
193
|
if (!encoders)
|
|
131
194
|
return;
|
|
132
195
|
for (const [field, encode] of Object.entries(encoders)) {
|
|
@@ -139,7 +202,12 @@ const applyNodeDateEncoders = (attrs, encoders) => {
|
|
|
139
202
|
// ---------------------------------------------------------------------------
|
|
140
203
|
const makeAggregate = (schema, config) => {
|
|
141
204
|
// Build resolved graph by walking edges recursively
|
|
142
|
-
const rootNode = resolveNode(
|
|
205
|
+
const rootNode = resolveNode({
|
|
206
|
+
fieldName: null,
|
|
207
|
+
entityType: config.root.entityType,
|
|
208
|
+
cardinality: "root",
|
|
209
|
+
edges: config.edges,
|
|
210
|
+
});
|
|
143
211
|
const aggregateName = config.root.entityType;
|
|
144
212
|
const contextFields = config.context ?? [];
|
|
145
213
|
// Detect date encodings for all root schema fields (once, at make() time)
|
|
@@ -168,7 +236,7 @@ const makeAggregate = (schema, config) => {
|
|
|
168
236
|
// Date ENCODERS for the write path: decompose works from the schema-decoded
|
|
169
237
|
// domain object, so every root date field (Pattern A self-date AND Pattern B
|
|
170
238
|
// transform) is serialized to its wire primitive for storage.
|
|
171
|
-
const
|
|
239
|
+
const attrEncoders = buildAttrEncoders(schemaFields);
|
|
172
240
|
// Decode schema for read/assemble + input validation. Mirrors the raw `schema`
|
|
173
241
|
// but recursively substitutes EVERY date / Redacted leaf — root and nested,
|
|
174
242
|
// Pattern A and Pattern B — with a TOLERANT transform. This is the single
|
|
@@ -177,7 +245,7 @@ const makeAggregate = (schema, config) => {
|
|
|
177
245
|
// - update re-decodes the mutated state, which carries domain `DateTime`
|
|
178
246
|
// values → accepted as-is (a strict `*FromString` decoder would reject
|
|
179
247
|
// them). It is decode-only, so making transforms tolerant is safe — the
|
|
180
|
-
// stored wire format is still produced by `
|
|
248
|
+
// stored wire format is still produced by `attrEncoders` / node encoders.
|
|
181
249
|
// Nested edge / ref classes round-trip with their class instance identity
|
|
182
250
|
// preserved (Option A). Returns the raw `schema` unchanged when it carries no
|
|
183
251
|
// date / Redacted leaf at all (zero overhead).
|
|
@@ -189,6 +257,7 @@ const makeAggregate = (schema, config) => {
|
|
|
189
257
|
// `substituteSchemaDeep` introspects directly — re-pointing it at the element
|
|
190
258
|
// model would drop the `Array` and yield "Expected object, got []" on assemble.
|
|
191
259
|
const edgeRefModels = new Map();
|
|
260
|
+
const unwrapModel = (model) => DynamoModel.isConfiguredModel(model) ? model.model : model;
|
|
192
261
|
for (const [edgeName, edge] of Object.entries(config.edges)) {
|
|
193
262
|
if (!("_tag" in edge))
|
|
194
263
|
continue;
|
|
@@ -198,7 +267,20 @@ const makeAggregate = (schema, config) => {
|
|
|
198
267
|
const model = entity?.model;
|
|
199
268
|
if (!model)
|
|
200
269
|
continue;
|
|
201
|
-
edgeRefModels.set(edgeName,
|
|
270
|
+
edgeRefModels.set(edgeName, unwrapModel(model));
|
|
271
|
+
// A ref nested INSIDE an edge's model needs the same treatment. `maker` on
|
|
272
|
+
// an edge entity is annotated with `DynamoModel.ref`, and `Schema.annotate`
|
|
273
|
+
// drops a `Schema.Class`'s `.fields`, so the substitution cannot introspect
|
|
274
|
+
// it and the target's own transformed fields keep their strict schemas —
|
|
275
|
+
// which is what made `update` reject a `bigint` at
|
|
276
|
+
// `["supplier"]["maker"]["founded"]` (#116). Registering the target by field
|
|
277
|
+
// name re-points it exactly as a top-level edge is re-pointed.
|
|
278
|
+
for (const nested of entity?._data?.resolvedRefs ?? []) {
|
|
279
|
+
const nestedModel = nested.refEntity?.model;
|
|
280
|
+
if (nestedModel && !edgeRefModels.has(nested.fieldName)) {
|
|
281
|
+
edgeRefModels.set(nested.fieldName, unwrapModel(nestedModel));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
202
284
|
}
|
|
203
285
|
const decodeSchema = substituteSchemaDeep(schema, {
|
|
204
286
|
tolerantTransforms: true,
|
|
@@ -234,14 +316,65 @@ const makeAggregate = (schema, config) => {
|
|
|
234
316
|
`eventually consistent only. Drop \`consistentRead\`, or key the aggregate on the ` +
|
|
235
317
|
`table's primary partition key so assembly reads the base table.`);
|
|
236
318
|
}
|
|
319
|
+
// System timestamps (#98). Aggregates compose their DynamoDB items directly
|
|
320
|
+
// rather than routing through Entity write ops, so Entity's timestamp
|
|
321
|
+
// machinery never reached these rows. Resolution goes through the SAME
|
|
322
|
+
// resolver as Entity, which means field names, `DynamoModel` storage
|
|
323
|
+
// annotations and the EDD-9044 guard on an un-annotated `schema` all behave
|
|
324
|
+
// identically here. Passing the root schema's fields also inherits Entity's
|
|
325
|
+
// collision rule: a date-compatible root field of the same name supplies the
|
|
326
|
+
// encoding, and a non-date one means the user owns the attribute outright.
|
|
327
|
+
const timestampFields = resolveSystemFields(config.timestamps, undefined, undefined, schemaFields ?? {}, {}, `Aggregate "${aggregateName}"`);
|
|
328
|
+
const stampFields = timestampFields.createdAt !== null || timestampFields.updatedAt !== null
|
|
329
|
+
? {
|
|
330
|
+
createdAt: timestampFields.createdAt,
|
|
331
|
+
createdEncoding: timestampFields.createdAtEncoding,
|
|
332
|
+
updatedAt: timestampFields.updatedAt,
|
|
333
|
+
updatedEncoding: timestampFields.updatedAtEncoding,
|
|
334
|
+
}
|
|
335
|
+
: undefined;
|
|
336
|
+
// Attributes the read path must drop before decoding. A field the root model
|
|
337
|
+
// declares itself is NOT stripped — it belongs to the domain object.
|
|
338
|
+
const systemAttrs = new Set([
|
|
339
|
+
timestampFields.createdAtCollision ? null : timestampFields.createdAt,
|
|
340
|
+
timestampFields.updatedAtCollision ? null : timestampFields.updatedAt,
|
|
341
|
+
].filter((name) => name !== null));
|
|
342
|
+
/** Read stored `created` values back, keyed by sk, so a rewrite preserves them. */
|
|
343
|
+
const readExistingCreated = (allItems) => {
|
|
344
|
+
const createdField = stampFields?.createdAt;
|
|
345
|
+
if (createdField === undefined || createdField === null)
|
|
346
|
+
return EMPTY_CREATED;
|
|
347
|
+
const map = new Map();
|
|
348
|
+
for (const item of allItems) {
|
|
349
|
+
const sk = item.sk;
|
|
350
|
+
const prior = item[createdField];
|
|
351
|
+
if (typeof sk === "string" && prior !== undefined)
|
|
352
|
+
map.set(sk, prior);
|
|
353
|
+
}
|
|
354
|
+
return map;
|
|
355
|
+
};
|
|
356
|
+
/** Bind the per-write stamp: config + the Clock-backed instant + carry-forward. */
|
|
357
|
+
const stampFor = (now, existingCreated) => stampFields === undefined ? undefined : { fields: stampFields, now, existingCreated };
|
|
237
358
|
// Build optics at construction time (once per aggregate definition)
|
|
238
359
|
const classToPlain = Schema.toIso(schema);
|
|
239
360
|
const opticRoot = Schema.toIsoFocus(schema);
|
|
361
|
+
// Composite key form — the SAME rule and the SAME function the entity path
|
|
362
|
+
// uses (`internal/CompositeCodec.ts`). Aggregates compose from assembled
|
|
363
|
+
// domain objects, so without this a `DateEpochMs` composite would key on its
|
|
364
|
+
// ISO form here and its epoch form on the entity path, and a numeric
|
|
365
|
+
// composite with a string encoding would not be zero-padded on either.
|
|
366
|
+
const compositeKeyForm = makeCompositeKeyForm(schema, (attr, value) => {
|
|
367
|
+
throw new Error(`[EDD-9050] Composite "${attr}" on aggregate "${aggregateName}" could not be put into ` +
|
|
368
|
+
`its key form. The attribute's schema carries an encoding transformation, but ` +
|
|
369
|
+
`${JSON.stringify(String(value))} resolves under neither encode nor decode->encode. ` +
|
|
370
|
+
`Supply a value of the attribute's own type.`);
|
|
371
|
+
});
|
|
372
|
+
const keyRecord = (record) => toCompositeKeyRecord(compositeKeyForm, record);
|
|
240
373
|
/** Shared: compose PK and query all items */
|
|
241
374
|
const fetchPartition = (key) => Effect.gen(function* () {
|
|
242
375
|
const client = yield* DynamoClient;
|
|
243
376
|
const tableConfig = yield* config.table.Tag;
|
|
244
|
-
const composites = KeyComposer.extractComposites(config.pk.composite, key);
|
|
377
|
+
const composites = KeyComposer.extractComposites(config.pk.composite, keyRecord(key));
|
|
245
378
|
const pkValue = composeCollectionKey(config.schema, config.collection.name, composites);
|
|
246
379
|
const allItems = yield* queryAllItems(client, tableConfig.name, config.collection.index, config.pk.field, pkValue, consistentRead);
|
|
247
380
|
return { client, tableConfig, pkValue, composites, allItems };
|
|
@@ -277,7 +410,7 @@ const makeAggregate = (schema, config) => {
|
|
|
277
410
|
key,
|
|
278
411
|
});
|
|
279
412
|
}
|
|
280
|
-
const result = yield* assembleAggregate(decodeSchema, rootNode, allItems, contextFields, key, aggregateName);
|
|
413
|
+
const result = yield* assembleAggregate(decodeSchema, rootNode, allItems, contextFields, key, aggregateName, systemAttrs);
|
|
281
414
|
return result;
|
|
282
415
|
}),
|
|
283
416
|
create: (input) => Effect.gen(function* () {
|
|
@@ -295,14 +428,19 @@ const makeAggregate = (schema, config) => {
|
|
|
295
428
|
})));
|
|
296
429
|
const assembled = { ...decoded };
|
|
297
430
|
// 3. Compose PK
|
|
298
|
-
const composites = KeyComposer.extractComposites(config.pk.composite, assembled);
|
|
431
|
+
const composites = KeyComposer.extractComposites(config.pk.composite, keyRecord(assembled));
|
|
299
432
|
const pkValue = composeCollectionKey(config.schema, config.collection.name, composites);
|
|
300
433
|
// 4. Decompose into items grouped by sub-aggregate transaction boundaries
|
|
301
|
-
const groups = yield* decomposeAggregate(assembled, rootNode, contextFields,
|
|
302
|
-
// 5. Compose collection SK composites for root item
|
|
303
|
-
|
|
304
|
-
//
|
|
305
|
-
|
|
434
|
+
const groups = yield* decomposeAggregate(assembled, rootNode, contextFields, attrEncoders, aggregateName, config.schema);
|
|
435
|
+
// 5. Compose collection SK composites for root item. Through the key
|
|
436
|
+
// form — `update` already did (see `newDynamo` / `oldDynamo` below), so
|
|
437
|
+
// leaving `create` raw made the two write paths mirror the collection SK
|
|
438
|
+
// differently for a transformed composite.
|
|
439
|
+
const collectionSkComposites = KeyComposer.extractComposites(config.collection.sk?.composite ?? [], keyRecord(assembled));
|
|
440
|
+
// 6. Build DynamoDB items with composed keys (create only ever PUTs).
|
|
441
|
+
// Every row is new, so `created` has nothing to carry forward.
|
|
442
|
+
const now = yield* DateTime.now;
|
|
443
|
+
const dynamoGroups = buildDynamoItems(groups, config, pkValue, collectionSkComposites, keyRecord, stampFor(now, EMPTY_CREATED));
|
|
306
444
|
// 7. Write via sub-aggregate transactions
|
|
307
445
|
yield* writeTransactionGroups(client, tableConfig.name, dynamoGroups.map((g) => ({ group: g.group, puts: g.items, deletes: [] })), aggregateName);
|
|
308
446
|
return decoded;
|
|
@@ -317,7 +455,7 @@ const makeAggregate = (schema, config) => {
|
|
|
317
455
|
});
|
|
318
456
|
}
|
|
319
457
|
// 1. Assemble current state
|
|
320
|
-
const current = yield* assembleAggregate(decodeSchema, rootNode, allItems, contextFields, key, aggregateName);
|
|
458
|
+
const current = yield* assembleAggregate(decodeSchema, rootNode, allItems, contextFields, key, aggregateName, systemAttrs);
|
|
321
459
|
// 2. Apply mutation — provide optics context for composable updates
|
|
322
460
|
const state = classToPlain.get(current);
|
|
323
461
|
const updated = mutationFn({
|
|
@@ -339,8 +477,8 @@ const makeAggregate = (schema, config) => {
|
|
|
339
477
|
const assembledOld = { ...current };
|
|
340
478
|
const assembledNew = { ...decoded };
|
|
341
479
|
// 4. Decompose both old and new
|
|
342
|
-
const oldGroups = yield* decomposeAggregate(assembledOld, rootNode, contextFields,
|
|
343
|
-
const newGroups = yield* decomposeAggregate(assembledNew, rootNode, contextFields,
|
|
480
|
+
const oldGroups = yield* decomposeAggregate(assembledOld, rootNode, contextFields, attrEncoders, aggregateName, config.schema);
|
|
481
|
+
const newGroups = yield* decomposeAggregate(assembledNew, rootNode, contextFields, attrEncoders, aggregateName, config.schema);
|
|
344
482
|
// 5. Diff at the ITEM (primary-key) level, not just the group level.
|
|
345
483
|
// A `many`-edge element — and a cleared `one` edge — lives in its PARENT's
|
|
346
484
|
// transaction group, so removing it SHRINKS a group rather than dropping a
|
|
@@ -363,8 +501,13 @@ const makeAggregate = (schema, config) => {
|
|
|
363
501
|
// collection SK composites only affect the LSI/GSI mirror attributes,
|
|
364
502
|
// never the (pk, sk) identity used to PUT or DELETE, so the old set's
|
|
365
503
|
// composites are irrelevant — we read only its (pk, sk) for deletes.
|
|
366
|
-
|
|
367
|
-
|
|
504
|
+
// Rewritten rows are stamped `updated`; their stored `created` is read
|
|
505
|
+
// back off the fetched partition and re-emitted verbatim, since these are
|
|
506
|
+
// PUTs and would otherwise clobber it. The old set is only mined for
|
|
507
|
+
// (pk, sk) delete keys, so it is never stamped.
|
|
508
|
+
const now = yield* DateTime.now;
|
|
509
|
+
const newDynamo = buildDynamoItems(newGroups, config, pkValue, KeyComposer.extractComposites(config.collection.sk?.composite ?? [], keyRecord(assembledNew)), keyRecord, stampFor(now, readExistingCreated(allItems)));
|
|
510
|
+
const oldDynamo = buildDynamoItems(oldGroups, config, pkValue, KeyComposer.extractComposites(config.collection.sk?.composite ?? [], keyRecord(assembledOld)), keyRecord);
|
|
368
511
|
const skOf = (item) => item.sk?.S ?? "";
|
|
369
512
|
const newItemsByGroup = new Map(newDynamo.map((g) => [g.group, g.items]));
|
|
370
513
|
const oldItemsByGroup = new Map(oldDynamo.map((g) => [g.group, g.items]));
|
|
@@ -430,13 +573,19 @@ const makeAggregate = (schema, config) => {
|
|
|
430
573
|
const startKey = options?.cursor
|
|
431
574
|
? JSON.parse(atob(options.cursor))
|
|
432
575
|
: undefined;
|
|
433
|
-
// Compose PK from filter values matching PK composites
|
|
434
|
-
|
|
435
|
-
//
|
|
576
|
+
// Compose PK from filter values matching PK composites. The filter is a
|
|
577
|
+
// DOMAIN record, so it goes through the same key form the write side
|
|
578
|
+
// used — otherwise `list` composes a key `create` never wrote and
|
|
579
|
+
// silently returns nothing (#111).
|
|
580
|
+
const listPkComposites = KeyComposer.extractComposites(listConfig.pk.composite, keyRecord(filter ?? {}));
|
|
581
|
+
// Build SK prefix from contiguous filter values matching SK composites.
|
|
582
|
+
// `serializeValue`, NOT `String(v)` — the write side pads numerics, so
|
|
583
|
+
// `String(5)` would look for `5` where `0000000000000005` is stored.
|
|
436
584
|
const skValues = [];
|
|
585
|
+
const listSkFilter = keyRecord(filter ?? {});
|
|
437
586
|
for (const attr of listConfig.sk.composite) {
|
|
438
|
-
if (
|
|
439
|
-
skValues.push(
|
|
587
|
+
if (listSkFilter[attr] !== undefined)
|
|
588
|
+
skValues.push(KeyComposer.serializeValue(listSkFilter[attr]));
|
|
440
589
|
else
|
|
441
590
|
break; // Stop at first gap (prefix matching)
|
|
442
591
|
}
|
|
@@ -473,7 +622,7 @@ const makeAggregate = (schema, config) => {
|
|
|
473
622
|
const { allItems } = yield* fetchPartition(key);
|
|
474
623
|
if (allItems.length === 0)
|
|
475
624
|
return undefined;
|
|
476
|
-
const result = yield* assembleAggregate(decodeSchema, rootNode, allItems, contextFields, key, aggregateName);
|
|
625
|
+
const result = yield* assembleAggregate(decodeSchema, rootNode, allItems, contextFields, key, aggregateName, systemAttrs);
|
|
477
626
|
return result;
|
|
478
627
|
});
|
|
479
628
|
if (assembled !== undefined)
|
|
@@ -546,10 +695,8 @@ const deepEqualGroups = (a, b) => {
|
|
|
546
695
|
return deepEqual(item.attributes, other.attributes);
|
|
547
696
|
});
|
|
548
697
|
};
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
// ---------------------------------------------------------------------------
|
|
552
|
-
const resolveNode = (fieldName, entityType, cardinality, edges, discriminator, assemble, decompose, ownDiscriminator, dateEncoders) => {
|
|
698
|
+
const resolveNode = (args) => {
|
|
699
|
+
const { assemble, cardinality, attrEncoders, decompose, discriminator, edges, entityType, fieldName, ownDiscriminator, refIdentifierField, skComposite, } = args;
|
|
553
700
|
const children = [];
|
|
554
701
|
for (const [field, edge] of Object.entries(edges)) {
|
|
555
702
|
if ("_tag" in edge) {
|
|
@@ -558,19 +705,47 @@ const resolveNode = (fieldName, entityType, cardinality, edges, discriminator, a
|
|
|
558
705
|
const mergedDisc = edge.discriminator
|
|
559
706
|
? { ...(discriminator ?? {}), ...edge.discriminator }
|
|
560
707
|
: discriminator;
|
|
561
|
-
children.push(resolveNode(
|
|
562
|
-
|
|
563
|
-
|
|
708
|
+
children.push(resolveNode({
|
|
709
|
+
fieldName: field,
|
|
710
|
+
entityType: edge.entityType,
|
|
711
|
+
cardinality: "one",
|
|
712
|
+
edges: {},
|
|
713
|
+
discriminator: mergedDisc,
|
|
714
|
+
ownDiscriminator: edge.discriminator,
|
|
715
|
+
// Encode this edge entity's own date fields on write (issue #72).
|
|
716
|
+
attrEncoders: buildAttrEncoders(fieldsOf(edge.entity?.model)),
|
|
717
|
+
}));
|
|
564
718
|
}
|
|
565
719
|
else if (edge._tag === "ManyEdge") {
|
|
566
|
-
children.push(resolveNode(
|
|
720
|
+
children.push(resolveNode({
|
|
721
|
+
fieldName: field,
|
|
722
|
+
entityType: edge.entityType,
|
|
723
|
+
cardinality: "many",
|
|
724
|
+
edges: {},
|
|
725
|
+
discriminator,
|
|
726
|
+
assemble: edge.assemble,
|
|
727
|
+
decompose: edge.decompose,
|
|
728
|
+
attrEncoders: buildAttrEncoders(fieldsOf(edge.entity?.model)),
|
|
729
|
+
// Declared sort-key composites, authoritative when present (#103).
|
|
730
|
+
skComposite: edge.sk?.composite,
|
|
731
|
+
refIdentifierField: edge.entity
|
|
732
|
+
? DynamoModel.getIdentifierField(edge.entity.model)?.name
|
|
733
|
+
: undefined,
|
|
734
|
+
}));
|
|
567
735
|
}
|
|
568
736
|
else if (edge._tag === "BoundSubAggregate") {
|
|
569
737
|
const bound = edge;
|
|
570
|
-
const subChildren = resolveNode(
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
738
|
+
const subChildren = resolveNode({
|
|
739
|
+
fieldName: field,
|
|
740
|
+
entityType: bound.aggregate.root.entityType,
|
|
741
|
+
cardinality: "one",
|
|
742
|
+
edges: bound.aggregate.edges,
|
|
743
|
+
discriminator: bound.discriminator,
|
|
744
|
+
ownDiscriminator: bound.discriminator,
|
|
745
|
+
// The sub-aggregate root item carries the sub-schema's own (non-edge)
|
|
746
|
+
// date fields; its child edges get their own encoders via recursion.
|
|
747
|
+
attrEncoders: buildAttrEncoders(fieldsOf(bound.aggregate.schema)),
|
|
748
|
+
});
|
|
574
749
|
children.push(subChildren);
|
|
575
750
|
}
|
|
576
751
|
}
|
|
@@ -585,7 +760,9 @@ const resolveNode = (fieldName, entityType, cardinality, edges, discriminator, a
|
|
|
585
760
|
children,
|
|
586
761
|
...(assemble !== undefined && { assemble }),
|
|
587
762
|
...(decompose !== undefined && { decompose }),
|
|
588
|
-
|
|
763
|
+
...(skComposite !== undefined && { skComposite }),
|
|
764
|
+
...(refIdentifierField !== undefined && { refIdentifierField }),
|
|
765
|
+
attrEncoders,
|
|
589
766
|
};
|
|
590
767
|
};
|
|
591
768
|
// ---------------------------------------------------------------------------
|
|
@@ -619,7 +796,9 @@ const queryAllItems = (client, tableName, indexName, pkField, pkValue, consisten
|
|
|
619
796
|
// ---------------------------------------------------------------------------
|
|
620
797
|
// Internal: Assembly algorithm
|
|
621
798
|
// ---------------------------------------------------------------------------
|
|
622
|
-
const assembleAggregate = (decodeSchema, rootNode, allItems, _contextFields, key, aggregateName
|
|
799
|
+
const assembleAggregate = (decodeSchema, rootNode, allItems, _contextFields, key, aggregateName,
|
|
800
|
+
/** Library-managed attribute names (system timestamps) to drop before decode. */
|
|
801
|
+
systemAttrs = EMPTY_ATTRS) => Effect.gen(function* () {
|
|
623
802
|
// Find the root item
|
|
624
803
|
const rootItems = allItems.filter((item) => item.__edd_e__ === rootNode.entityType);
|
|
625
804
|
if (rootItems.length === 0) {
|
|
@@ -640,7 +819,7 @@ const assembleAggregate = (decodeSchema, rootNode, allItems, _contextFields, key
|
|
|
640
819
|
// Assemble edge values from the item collection
|
|
641
820
|
const edgeValues = {};
|
|
642
821
|
for (const child of rootNode.children) {
|
|
643
|
-
const assembled = yield* assembleNode(child, allItems, key, aggregateName);
|
|
822
|
+
const assembled = yield* assembleNode(child, allItems, key, aggregateName, systemAttrs);
|
|
644
823
|
if (child.fieldName !== null && assembled !== undefined) {
|
|
645
824
|
edgeValues[child.fieldName] = assembled;
|
|
646
825
|
}
|
|
@@ -664,6 +843,7 @@ const assembleAggregate = (decodeSchema, rootNode, allItems, _contextFields, key
|
|
|
664
843
|
"gsi5sk",
|
|
665
844
|
"lsi1sk",
|
|
666
845
|
"lsi2sk",
|
|
846
|
+
...systemAttrs,
|
|
667
847
|
]);
|
|
668
848
|
for (const [fieldKey, value] of Object.entries(rootItem)) {
|
|
669
849
|
if (metaFields.has(fieldKey))
|
|
@@ -684,7 +864,7 @@ const assembleAggregate = (decodeSchema, rootNode, allItems, _contextFields, key
|
|
|
684
864
|
})));
|
|
685
865
|
return decoded;
|
|
686
866
|
});
|
|
687
|
-
const assembleNode = (node, allItems, key, aggregateName) => Effect.gen(function* () {
|
|
867
|
+
const assembleNode = (node, allItems, key, aggregateName, systemAttrs = EMPTY_ATTRS) => Effect.gen(function* () {
|
|
688
868
|
// Find items matching this node's entity type and discriminator
|
|
689
869
|
const matchingItems = allItems.filter((item) => {
|
|
690
870
|
if (item.__edd_e__ !== node.entityType)
|
|
@@ -722,7 +902,7 @@ const assembleNode = (node, allItems, key, aggregateName) => Effect.gen(function
|
|
|
722
902
|
const childNode = node.discriminator
|
|
723
903
|
? { ...child, discriminator: { ...node.discriminator, ...child.discriminator } }
|
|
724
904
|
: child;
|
|
725
|
-
const assembled = yield* assembleNode(childNode, allItems, key, aggregateName);
|
|
905
|
+
const assembled = yield* assembleNode(childNode, allItems, key, aggregateName, systemAttrs);
|
|
726
906
|
if (child.fieldName !== null && assembled !== undefined) {
|
|
727
907
|
edgeValues[child.fieldName] = assembled;
|
|
728
908
|
}
|
|
@@ -738,6 +918,8 @@ const assembleNode = (node, allItems, key, aggregateName) => Effect.gen(function
|
|
|
738
918
|
continue;
|
|
739
919
|
if (fieldKey.startsWith("gsi") || fieldKey.startsWith("lsi"))
|
|
740
920
|
continue;
|
|
921
|
+
if (systemAttrs.has(fieldKey))
|
|
922
|
+
continue;
|
|
741
923
|
if (edgeFieldNames.has(fieldKey))
|
|
742
924
|
continue;
|
|
743
925
|
// Skip discriminator attributes
|
|
@@ -753,10 +935,10 @@ const assembleNode = (node, allItems, key, aggregateName) => Effect.gen(function
|
|
|
753
935
|
return undefined;
|
|
754
936
|
// Return the item (stripped of DynamoDB metadata)
|
|
755
937
|
const item = matchingItems[0];
|
|
756
|
-
return stripMetadata(item);
|
|
938
|
+
return stripMetadata(item, systemAttrs);
|
|
757
939
|
}
|
|
758
940
|
// Many edge: collect all matching items
|
|
759
|
-
const stripped = matchingItems.map(stripMetadata);
|
|
941
|
+
const stripped = matchingItems.map((item) => stripMetadata(item, systemAttrs));
|
|
760
942
|
if (node.assemble) {
|
|
761
943
|
return node.assemble(stripped);
|
|
762
944
|
}
|
|
@@ -765,7 +947,8 @@ const assembleNode = (node, allItems, key, aggregateName) => Effect.gen(function
|
|
|
765
947
|
// ---------------------------------------------------------------------------
|
|
766
948
|
// Internal: Strip DynamoDB metadata from items
|
|
767
949
|
// ---------------------------------------------------------------------------
|
|
768
|
-
const
|
|
950
|
+
const EMPTY_ATTRS = new Set();
|
|
951
|
+
const stripMetadata = (item, systemAttrs = EMPTY_ATTRS) => {
|
|
769
952
|
const result = {};
|
|
770
953
|
for (const [key, value] of Object.entries(item)) {
|
|
771
954
|
if (key === "__edd_e__")
|
|
@@ -774,6 +957,8 @@ const stripMetadata = (item) => {
|
|
|
774
957
|
continue;
|
|
775
958
|
if (key.startsWith("gsi") || key.startsWith("lsi"))
|
|
776
959
|
continue;
|
|
960
|
+
if (systemAttrs.has(key))
|
|
961
|
+
continue;
|
|
777
962
|
result[key] = value;
|
|
778
963
|
}
|
|
779
964
|
return result;
|
|
@@ -996,7 +1181,7 @@ const replaceRefIds = (input, refRequests, lookup) => {
|
|
|
996
1181
|
* Decompose an assembled domain object into DynamoDB items grouped by
|
|
997
1182
|
* sub-aggregate transaction boundaries.
|
|
998
1183
|
*/
|
|
999
|
-
const decomposeAggregate = (assembled, rootNode, contextFields,
|
|
1184
|
+
const decomposeAggregate = (assembled, rootNode, contextFields, attrEncoders, aggregateName, schema) => Effect.gen(function* () {
|
|
1000
1185
|
const items = [];
|
|
1001
1186
|
// Extract context values from the root, serializing date fields for DynamoDB storage.
|
|
1002
1187
|
// Domain Date/DateTime objects must be encoded before toAttributeMap() marshalling,
|
|
@@ -1005,7 +1190,7 @@ const decomposeAggregate = (assembled, rootNode, contextFields, dateEncoders, ag
|
|
|
1005
1190
|
const contextValues = {};
|
|
1006
1191
|
for (const field of contextFields) {
|
|
1007
1192
|
const value = assembled[field];
|
|
1008
|
-
const encode =
|
|
1193
|
+
const encode = attrEncoders[field];
|
|
1009
1194
|
contextValues[field] = encode && value != null ? encode(value) : value;
|
|
1010
1195
|
}
|
|
1011
1196
|
// Root item: fields not claimed by edges
|
|
@@ -1015,7 +1200,7 @@ const decomposeAggregate = (assembled, rootNode, contextFields, dateEncoders, ag
|
|
|
1015
1200
|
if (edgeFieldNames.has(key))
|
|
1016
1201
|
continue;
|
|
1017
1202
|
// Serialize date fields in root attributes (same as context values)
|
|
1018
|
-
const encode =
|
|
1203
|
+
const encode = attrEncoders[key];
|
|
1019
1204
|
rootAttrs[key] = encode && value != null ? encode(value) : value;
|
|
1020
1205
|
}
|
|
1021
1206
|
items.push({
|
|
@@ -1023,12 +1208,37 @@ const decomposeAggregate = (assembled, rootNode, contextFields, dateEncoders, ag
|
|
|
1023
1208
|
attributes: rootAttrs,
|
|
1024
1209
|
transactionGroup: "root",
|
|
1025
1210
|
skComposites: [],
|
|
1211
|
+
member: rootNode.entityType,
|
|
1026
1212
|
});
|
|
1027
1213
|
// Decompose each edge
|
|
1028
1214
|
for (const child of rootNode.children) {
|
|
1029
1215
|
const fieldValue = assembled[child.fieldName];
|
|
1030
1216
|
yield* decomposeNode(items, child, fieldValue, contextValues, "root", [], aggregateName);
|
|
1031
1217
|
}
|
|
1218
|
+
// Two items composing the same sort key are two writes to one row. DynamoDB
|
|
1219
|
+
// rejects the whole TransactWriteItems with a ValidationException ("Transaction
|
|
1220
|
+
// request cannot include multiple operations on one item") that names no edge,
|
|
1221
|
+
// and carries no CancellationReasons — so `writeTransactionGroups` cannot
|
|
1222
|
+
// decode it and the collision reaches the caller as an opaque client error.
|
|
1223
|
+
// Catch it here, where the edge that produced it is still known (#103).
|
|
1224
|
+
const seen = new Map();
|
|
1225
|
+
for (const item of items) {
|
|
1226
|
+
const sk = composeKey(schema, item.entityType, [...item.skComposites]);
|
|
1227
|
+
const prior = seen.get(sk);
|
|
1228
|
+
if (prior !== undefined) {
|
|
1229
|
+
const hint = prior.member === item.member
|
|
1230
|
+
? ` Give the "${item.member}" edge a declared sort key naming attributes that differ` +
|
|
1231
|
+
` between its elements: Aggregate.many("${item.member}", { …, sk: { composite: [...] } }).`
|
|
1232
|
+
: "";
|
|
1233
|
+
return yield* new AggregateDecompositionError({
|
|
1234
|
+
aggregate: aggregateName,
|
|
1235
|
+
member: item.member,
|
|
1236
|
+
reason: `"${prior.member}" and "${item.member}" both compose the sort key "${sk}", ` +
|
|
1237
|
+
`so one would overwrite the other.${hint}`,
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
seen.set(sk, item);
|
|
1241
|
+
}
|
|
1032
1242
|
// Group by transaction
|
|
1033
1243
|
const groups = new Map();
|
|
1034
1244
|
for (const item of items) {
|
|
@@ -1074,7 +1284,7 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
|
|
|
1074
1284
|
subRootAttrs[key] = val;
|
|
1075
1285
|
}
|
|
1076
1286
|
// Serialize this node's own date fields to their wire primitive (#72).
|
|
1077
|
-
|
|
1287
|
+
applyNodeAttrEncoders(subRootAttrs, node.attrEncoders);
|
|
1078
1288
|
// Inject context and discriminator
|
|
1079
1289
|
mergeContextValues(subRootAttrs, contextValues);
|
|
1080
1290
|
if (node.discriminator) {
|
|
@@ -1088,6 +1298,7 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
|
|
|
1088
1298
|
attributes: subRootAttrs,
|
|
1089
1299
|
transactionGroup: txGroup,
|
|
1090
1300
|
skComposites: discValues,
|
|
1301
|
+
member: node.fieldName ?? node.entityType,
|
|
1091
1302
|
});
|
|
1092
1303
|
// Decompose children within this sub-aggregate
|
|
1093
1304
|
for (const child of node.children) {
|
|
@@ -1099,7 +1310,7 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
|
|
|
1099
1310
|
// Simple one-to-one edge (no children)
|
|
1100
1311
|
const attrs = { ...value };
|
|
1101
1312
|
// Serialize this edge entity's own date fields to their wire primitive (#72).
|
|
1102
|
-
|
|
1313
|
+
applyNodeAttrEncoders(attrs, node.attrEncoders);
|
|
1103
1314
|
mergeContextValues(attrs, contextValues);
|
|
1104
1315
|
if (node.discriminator) {
|
|
1105
1316
|
for (const [k, v] of Object.entries(node.discriminator)) {
|
|
@@ -1118,6 +1329,7 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
|
|
|
1118
1329
|
attributes: attrs,
|
|
1119
1330
|
transactionGroup: parentGroup,
|
|
1120
1331
|
skComposites: [...parentDiscriminatorValues, ...ownDiscValues],
|
|
1332
|
+
member: node.fieldName ?? node.entityType,
|
|
1121
1333
|
});
|
|
1122
1334
|
}
|
|
1123
1335
|
else {
|
|
@@ -1130,7 +1342,7 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
|
|
|
1130
1342
|
for (const elem of arrayItems) {
|
|
1131
1343
|
const attrs = { ...elem };
|
|
1132
1344
|
// Serialize this edge entity's own date fields to their wire primitive (#72).
|
|
1133
|
-
|
|
1345
|
+
applyNodeAttrEncoders(attrs, node.attrEncoders);
|
|
1134
1346
|
mergeContextValues(attrs, contextValues);
|
|
1135
1347
|
if (node.discriminator) {
|
|
1136
1348
|
for (const [k, v] of Object.entries(node.discriminator)) {
|
|
@@ -1138,22 +1350,109 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
|
|
|
1138
1350
|
attrs[k] = v;
|
|
1139
1351
|
}
|
|
1140
1352
|
}
|
|
1141
|
-
|
|
1142
|
-
const itemComposites = extractRefIdentifiers(attrs);
|
|
1353
|
+
const itemComposites = yield* manyEdgeSkComposites(node, attrs, aggregateName);
|
|
1143
1354
|
items.push({
|
|
1144
1355
|
entityType: node.entityType,
|
|
1145
1356
|
attributes: attrs,
|
|
1146
1357
|
transactionGroup: parentGroup,
|
|
1147
1358
|
skComposites: [...parentDiscriminatorValues, ...itemComposites],
|
|
1359
|
+
member: node.fieldName ?? node.entityType,
|
|
1148
1360
|
});
|
|
1149
1361
|
}
|
|
1150
1362
|
}
|
|
1151
1363
|
});
|
|
1364
|
+
/**
|
|
1365
|
+
* Read one declared sk composite off a decomposed element.
|
|
1366
|
+
*
|
|
1367
|
+
* Dotted paths are supported because ref hydration REPLACES the id field with the
|
|
1368
|
+
* hydrated object — an input element `{ contactId, role }` decomposes to
|
|
1369
|
+
* `{ contact: { contactId, … }, role }`, so the referenced entity's identifier is
|
|
1370
|
+
* only reachable as `"contact.contactId"`.
|
|
1371
|
+
*/
|
|
1372
|
+
const readCompositePath = (attrs, path) => {
|
|
1373
|
+
if (!path.includes("."))
|
|
1374
|
+
return attrs[path];
|
|
1375
|
+
let current = attrs;
|
|
1376
|
+
for (const segment of path.split(".")) {
|
|
1377
|
+
if (current == null || typeof current !== "object")
|
|
1378
|
+
return undefined;
|
|
1379
|
+
current = current[segment];
|
|
1380
|
+
}
|
|
1381
|
+
return current;
|
|
1382
|
+
};
|
|
1383
|
+
/**
|
|
1384
|
+
* Whether a resolved composite value can be a sort-key segment.
|
|
1385
|
+
*
|
|
1386
|
+
* `KeyComposer.serializeValue` falls through to `String(value)`, so an object
|
|
1387
|
+
* reaches the key as `[object Object]` or, for a Schema.Class instance, its
|
|
1388
|
+
* whole JSON body. Either is silently wrong and unqueryable — and naming the
|
|
1389
|
+
* ref object (`"umpire"`) instead of a scalar path (`"umpire.id"`) is the easy
|
|
1390
|
+
* mistake to make, precisely because the dotted path is the unfamiliar part.
|
|
1391
|
+
*/
|
|
1392
|
+
const isScalarComposite = (value) => {
|
|
1393
|
+
switch (typeof value) {
|
|
1394
|
+
case "string":
|
|
1395
|
+
case "number":
|
|
1396
|
+
case "bigint":
|
|
1397
|
+
case "boolean":
|
|
1398
|
+
return true;
|
|
1399
|
+
case "object":
|
|
1400
|
+
return value instanceof Date || DateTime.isDateTime(value);
|
|
1401
|
+
default:
|
|
1402
|
+
return false;
|
|
1403
|
+
}
|
|
1404
|
+
};
|
|
1405
|
+
/**
|
|
1406
|
+
* Sort-key composites for one element of a `many` edge.
|
|
1407
|
+
*
|
|
1408
|
+
* A declared `sk.composite` is AUTHORITATIVE — it replaces the ref-identifier
|
|
1409
|
+
* heuristic rather than extending it, so both the uniqueness and the ordering of
|
|
1410
|
+
* an edge's elements are the user's to state (#103). Without one, uniqueness is
|
|
1411
|
+
* bounded by the referenced entity's identifier and the same entity cannot appear
|
|
1412
|
+
* twice in one aggregate.
|
|
1413
|
+
*
|
|
1414
|
+
* Resolution never throws: a declared attribute that is missing or null is a
|
|
1415
|
+
* modelling error, reported as an {@link AggregateDecompositionError} naming the
|
|
1416
|
+
* edge and the attributes, not a defect escaping the decompose walk.
|
|
1417
|
+
*/
|
|
1418
|
+
const manyEdgeSkComposites = (node, attrs, aggregateName) => Effect.gen(function* () {
|
|
1419
|
+
const declared = node.skComposite;
|
|
1420
|
+
if (declared === undefined)
|
|
1421
|
+
return extractRefIdentifiers(attrs, node.refIdentifierField);
|
|
1422
|
+
const values = declared.map((path) => readCompositePath(attrs, path));
|
|
1423
|
+
const missing = declared.filter((_, i) => values[i] == null);
|
|
1424
|
+
if (missing.length > 0) {
|
|
1425
|
+
return yield* new AggregateDecompositionError({
|
|
1426
|
+
aggregate: aggregateName,
|
|
1427
|
+
member: node.fieldName ?? node.entityType,
|
|
1428
|
+
reason: `sk.composite names ${missing.map((m) => `"${m}"`).join(", ")}, ` +
|
|
1429
|
+
`${missing.length === 1 ? "which is" : "which are"} missing from the element. ` +
|
|
1430
|
+
`A referenced entity's identifier is reachable by dotted path after hydration ` +
|
|
1431
|
+
`(e.g. "contact.contactId", not "contactId").`,
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
const nonScalar = declared.filter((_, i) => !isScalarComposite(values[i]));
|
|
1435
|
+
if (nonScalar.length > 0) {
|
|
1436
|
+
return yield* new AggregateDecompositionError({
|
|
1437
|
+
aggregate: aggregateName,
|
|
1438
|
+
member: node.fieldName ?? node.entityType,
|
|
1439
|
+
reason: `sk.composite names ${nonScalar.map((m) => `"${m}"`).join(", ")}, ` +
|
|
1440
|
+
`${nonScalar.length === 1 ? "which resolves" : "which resolve"} to a non-scalar value. ` +
|
|
1441
|
+
`A sort key segment must be a string, number, bigint, boolean or date — name the ` +
|
|
1442
|
+
`attribute itself by dotted path (e.g. "umpire.id", not "umpire").`,
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
return values.map((value) => KeyComposer.serializeValue(value));
|
|
1446
|
+
});
|
|
1152
1447
|
/**
|
|
1153
1448
|
* Extract ref identifier values from an item's attributes for use as SK composites.
|
|
1154
1449
|
* Walks fields looking for embedded objects that have an identifier-annotated field.
|
|
1450
|
+
*
|
|
1451
|
+
* A heuristic, and order-dependent: it takes each nested object's `id` (else its
|
|
1452
|
+
* first `*Id` key) in property order. Declaring `sk.composite` on the edge opts
|
|
1453
|
+
* out of it entirely — see {@link manyEdgeSkComposites}.
|
|
1155
1454
|
*/
|
|
1156
|
-
const extractRefIdentifiers = (attrs) => {
|
|
1455
|
+
const extractRefIdentifiers = (attrs, refIdentifierField) => {
|
|
1157
1456
|
const ids = [];
|
|
1158
1457
|
// Case 1: Element wraps entity — look for nested objects with id-like fields
|
|
1159
1458
|
for (const [, value] of Object.entries(attrs)) {
|
|
@@ -1172,9 +1471,17 @@ const extractRefIdentifiers = (attrs) => {
|
|
|
1172
1471
|
}
|
|
1173
1472
|
}
|
|
1174
1473
|
}
|
|
1175
|
-
// Case 2: Element IS entity —
|
|
1176
|
-
|
|
1177
|
-
|
|
1474
|
+
// Case 2: Element IS entity — the entity's own fields are flat, so there is no
|
|
1475
|
+
// nested object for case 1 to find. Prefer the edge entity's declared
|
|
1476
|
+
// `DynamoModel.identifier` field; fall back to a literal `id` for edges with no
|
|
1477
|
+
// entity. Without this an `Array(Player)` edge composes NO composites and every
|
|
1478
|
+
// element collapses onto one row (#103).
|
|
1479
|
+
if (ids.length === 0) {
|
|
1480
|
+
const declared = refIdentifierField !== undefined ? attrs[refIdentifierField] : undefined;
|
|
1481
|
+
if (typeof declared === "string")
|
|
1482
|
+
ids.push(declared);
|
|
1483
|
+
else if (typeof attrs.id === "string")
|
|
1484
|
+
ids.push(attrs.id);
|
|
1178
1485
|
}
|
|
1179
1486
|
return ids;
|
|
1180
1487
|
};
|
|
@@ -1188,13 +1495,24 @@ const hashToShard = (value, cardinality) => {
|
|
|
1188
1495
|
}
|
|
1189
1496
|
return Math.abs(hash) % cardinality;
|
|
1190
1497
|
};
|
|
1191
|
-
|
|
1192
|
-
// Internal: Compose DynamoDB items from decomposed data
|
|
1193
|
-
// ---------------------------------------------------------------------------
|
|
1498
|
+
const EMPTY_CREATED = new Map();
|
|
1194
1499
|
/**
|
|
1195
1500
|
* Convert decomposed items into DynamoDB attribute maps with composed keys.
|
|
1501
|
+
*
|
|
1502
|
+
* `stamp` is applied HERE rather than in `decomposeAggregate` on purpose: update
|
|
1503
|
+
* diffs the decomposed groups (`deepEqualGroups`) to decide which sub-aggregate
|
|
1504
|
+
* transactions to rewrite, so a timestamp mixed into decomposition would differ
|
|
1505
|
+
* on every comparison and silently collapse the diff into a full-partition
|
|
1506
|
+
* rewrite on every update (#98).
|
|
1507
|
+
*/
|
|
1508
|
+
const buildDynamoItems = (groups, config, pkValue, rootCollectionSkComposites,
|
|
1509
|
+
/**
|
|
1510
|
+
* The aggregate's composite key-form normaliser. Required, not optional: the
|
|
1511
|
+
* list-GSI keys composed below have to be spelled exactly as `Aggregate.list`
|
|
1512
|
+
* spells them when it reads, and the only way to guarantee that is for both
|
|
1513
|
+
* to go through the same function (#111).
|
|
1196
1514
|
*/
|
|
1197
|
-
|
|
1515
|
+
keyRecord, stamp) => groups.map((group) => ({
|
|
1198
1516
|
group: group.name,
|
|
1199
1517
|
items: group.items.map((item) => {
|
|
1200
1518
|
const attrs = { ...item.attributes };
|
|
@@ -1217,7 +1535,7 @@ const buildDynamoItems = (groups, config, pkValue, rootCollectionSkComposites) =
|
|
|
1217
1535
|
}
|
|
1218
1536
|
// List collection GSI keys (root items only)
|
|
1219
1537
|
if (isRootItem && config.list) {
|
|
1220
|
-
const listPkComposites = KeyComposer.extractComposites(config.list.pk.composite, item.attributes);
|
|
1538
|
+
const listPkComposites = KeyComposer.extractComposites(config.list.pk.composite, keyRecord(item.attributes));
|
|
1221
1539
|
if (config.list.cardinality) {
|
|
1222
1540
|
const shard = hashToShard(pkValue, config.list.cardinality);
|
|
1223
1541
|
attrs[config.list.pk.field] = composeCollectionKey(config.schema, config.list.name, [
|
|
@@ -1228,9 +1546,22 @@ const buildDynamoItems = (groups, config, pkValue, rootCollectionSkComposites) =
|
|
|
1228
1546
|
else {
|
|
1229
1547
|
attrs[config.list.pk.field] = composeCollectionKey(config.schema, config.list.name, listPkComposites);
|
|
1230
1548
|
}
|
|
1231
|
-
const listSkComposites = KeyComposer.extractComposites(config.list.sk.composite, item.attributes);
|
|
1549
|
+
const listSkComposites = KeyComposer.extractComposites(config.list.sk.composite, keyRecord(item.attributes));
|
|
1232
1550
|
attrs[config.list.sk.field] = composeCollectionKey(config.schema, config.list.name, listSkComposites);
|
|
1233
1551
|
}
|
|
1552
|
+
// System timestamps — last, so a generated value wins over the same-named
|
|
1553
|
+
// field propagated onto edge items via `context`.
|
|
1554
|
+
if (stamp) {
|
|
1555
|
+
const { existingCreated, fields, now } = stamp;
|
|
1556
|
+
const sk = attrs.sk;
|
|
1557
|
+
if (fields.createdAt !== null) {
|
|
1558
|
+
const prior = existingCreated.get(sk);
|
|
1559
|
+
attrs[fields.createdAt] = prior ?? generateTimestampPrimitive(now, fields.createdEncoding);
|
|
1560
|
+
}
|
|
1561
|
+
if (fields.updatedAt !== null) {
|
|
1562
|
+
attrs[fields.updatedAt] = generateTimestampPrimitive(now, fields.updatedEncoding);
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1234
1565
|
return toAttributeMap(attrs);
|
|
1235
1566
|
}),
|
|
1236
1567
|
}));
|