effect-dynamodb 1.15.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 +1 -1
- package/dist/Aggregate.d.ts.map +1 -1
- package/dist/Aggregate.js +326 -67
- 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,10 +11,12 @@
|
|
|
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";
|
|
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";
|
|
15
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 { DateTime, 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";
|
|
@@ -96,38 +98,98 @@ const inferDateEncoding = (ast) => {
|
|
|
96
98
|
return undefined;
|
|
97
99
|
};
|
|
98
100
|
/**
|
|
99
|
-
* Build per-field
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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.
|
|
105
128
|
*/
|
|
106
|
-
const
|
|
129
|
+
const buildAttrEncoders = (fields) => {
|
|
107
130
|
const encoders = {};
|
|
108
|
-
if (fields)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
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) {
|
|
116
138
|
const encode = Schema.encodeUnknownSync(buildDateTransform(encoding));
|
|
117
139
|
encoders[field] = (value) => encode(value);
|
|
140
|
+
continue;
|
|
118
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
|
+
};
|
|
119
168
|
}
|
|
120
169
|
return encoders;
|
|
121
170
|
};
|
|
122
|
-
/**
|
|
123
|
-
|
|
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
|
+
};
|
|
124
186
|
/**
|
|
125
187
|
* Apply a node's date encoders in place — converts the node entity's own domain
|
|
126
188
|
* date fields to their wire primitives before the decomposed item is marshalled
|
|
127
189
|
* (issue #72: without this, an edge's `DateTime` field marshals to `{M:{}}` and
|
|
128
190
|
* the subsequent `get`/assemble fails decoding it as a string).
|
|
129
191
|
*/
|
|
130
|
-
const
|
|
192
|
+
const applyNodeAttrEncoders = (attrs, encoders) => {
|
|
131
193
|
if (!encoders)
|
|
132
194
|
return;
|
|
133
195
|
for (const [field, encode] of Object.entries(encoders)) {
|
|
@@ -140,7 +202,12 @@ const applyNodeDateEncoders = (attrs, encoders) => {
|
|
|
140
202
|
// ---------------------------------------------------------------------------
|
|
141
203
|
const makeAggregate = (schema, config) => {
|
|
142
204
|
// Build resolved graph by walking edges recursively
|
|
143
|
-
const rootNode = resolveNode(
|
|
205
|
+
const rootNode = resolveNode({
|
|
206
|
+
fieldName: null,
|
|
207
|
+
entityType: config.root.entityType,
|
|
208
|
+
cardinality: "root",
|
|
209
|
+
edges: config.edges,
|
|
210
|
+
});
|
|
144
211
|
const aggregateName = config.root.entityType;
|
|
145
212
|
const contextFields = config.context ?? [];
|
|
146
213
|
// Detect date encodings for all root schema fields (once, at make() time)
|
|
@@ -169,7 +236,7 @@ const makeAggregate = (schema, config) => {
|
|
|
169
236
|
// Date ENCODERS for the write path: decompose works from the schema-decoded
|
|
170
237
|
// domain object, so every root date field (Pattern A self-date AND Pattern B
|
|
171
238
|
// transform) is serialized to its wire primitive for storage.
|
|
172
|
-
const
|
|
239
|
+
const attrEncoders = buildAttrEncoders(schemaFields);
|
|
173
240
|
// Decode schema for read/assemble + input validation. Mirrors the raw `schema`
|
|
174
241
|
// but recursively substitutes EVERY date / Redacted leaf — root and nested,
|
|
175
242
|
// Pattern A and Pattern B — with a TOLERANT transform. This is the single
|
|
@@ -178,7 +245,7 @@ const makeAggregate = (schema, config) => {
|
|
|
178
245
|
// - update re-decodes the mutated state, which carries domain `DateTime`
|
|
179
246
|
// values → accepted as-is (a strict `*FromString` decoder would reject
|
|
180
247
|
// them). It is decode-only, so making transforms tolerant is safe — the
|
|
181
|
-
// stored wire format is still produced by `
|
|
248
|
+
// stored wire format is still produced by `attrEncoders` / node encoders.
|
|
182
249
|
// Nested edge / ref classes round-trip with their class instance identity
|
|
183
250
|
// preserved (Option A). Returns the raw `schema` unchanged when it carries no
|
|
184
251
|
// date / Redacted leaf at all (zero overhead).
|
|
@@ -190,6 +257,7 @@ const makeAggregate = (schema, config) => {
|
|
|
190
257
|
// `substituteSchemaDeep` introspects directly — re-pointing it at the element
|
|
191
258
|
// model would drop the `Array` and yield "Expected object, got []" on assemble.
|
|
192
259
|
const edgeRefModels = new Map();
|
|
260
|
+
const unwrapModel = (model) => DynamoModel.isConfiguredModel(model) ? model.model : model;
|
|
193
261
|
for (const [edgeName, edge] of Object.entries(config.edges)) {
|
|
194
262
|
if (!("_tag" in edge))
|
|
195
263
|
continue;
|
|
@@ -199,7 +267,20 @@ const makeAggregate = (schema, config) => {
|
|
|
199
267
|
const model = entity?.model;
|
|
200
268
|
if (!model)
|
|
201
269
|
continue;
|
|
202
|
-
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
|
+
}
|
|
203
284
|
}
|
|
204
285
|
const decodeSchema = substituteSchemaDeep(schema, {
|
|
205
286
|
tolerantTransforms: true,
|
|
@@ -277,11 +358,23 @@ const makeAggregate = (schema, config) => {
|
|
|
277
358
|
// Build optics at construction time (once per aggregate definition)
|
|
278
359
|
const classToPlain = Schema.toIso(schema);
|
|
279
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);
|
|
280
373
|
/** Shared: compose PK and query all items */
|
|
281
374
|
const fetchPartition = (key) => Effect.gen(function* () {
|
|
282
375
|
const client = yield* DynamoClient;
|
|
283
376
|
const tableConfig = yield* config.table.Tag;
|
|
284
|
-
const composites = KeyComposer.extractComposites(config.pk.composite, key);
|
|
377
|
+
const composites = KeyComposer.extractComposites(config.pk.composite, keyRecord(key));
|
|
285
378
|
const pkValue = composeCollectionKey(config.schema, config.collection.name, composites);
|
|
286
379
|
const allItems = yield* queryAllItems(client, tableConfig.name, config.collection.index, config.pk.field, pkValue, consistentRead);
|
|
287
380
|
return { client, tableConfig, pkValue, composites, allItems };
|
|
@@ -335,16 +428,19 @@ const makeAggregate = (schema, config) => {
|
|
|
335
428
|
})));
|
|
336
429
|
const assembled = { ...decoded };
|
|
337
430
|
// 3. Compose PK
|
|
338
|
-
const composites = KeyComposer.extractComposites(config.pk.composite, assembled);
|
|
431
|
+
const composites = KeyComposer.extractComposites(config.pk.composite, keyRecord(assembled));
|
|
339
432
|
const pkValue = composeCollectionKey(config.schema, config.collection.name, composites);
|
|
340
433
|
// 4. Decompose into items grouped by sub-aggregate transaction boundaries
|
|
341
|
-
const groups = yield* decomposeAggregate(assembled, rootNode, contextFields,
|
|
342
|
-
// 5. Compose collection SK composites for root item
|
|
343
|
-
|
|
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));
|
|
344
440
|
// 6. Build DynamoDB items with composed keys (create only ever PUTs).
|
|
345
441
|
// Every row is new, so `created` has nothing to carry forward.
|
|
346
442
|
const now = yield* DateTime.now;
|
|
347
|
-
const dynamoGroups = buildDynamoItems(groups, config, pkValue, collectionSkComposites, stampFor(now, EMPTY_CREATED));
|
|
443
|
+
const dynamoGroups = buildDynamoItems(groups, config, pkValue, collectionSkComposites, keyRecord, stampFor(now, EMPTY_CREATED));
|
|
348
444
|
// 7. Write via sub-aggregate transactions
|
|
349
445
|
yield* writeTransactionGroups(client, tableConfig.name, dynamoGroups.map((g) => ({ group: g.group, puts: g.items, deletes: [] })), aggregateName);
|
|
350
446
|
return decoded;
|
|
@@ -381,8 +477,8 @@ const makeAggregate = (schema, config) => {
|
|
|
381
477
|
const assembledOld = { ...current };
|
|
382
478
|
const assembledNew = { ...decoded };
|
|
383
479
|
// 4. Decompose both old and new
|
|
384
|
-
const oldGroups = yield* decomposeAggregate(assembledOld, rootNode, contextFields,
|
|
385
|
-
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);
|
|
386
482
|
// 5. Diff at the ITEM (primary-key) level, not just the group level.
|
|
387
483
|
// A `many`-edge element — and a cleared `one` edge — lives in its PARENT's
|
|
388
484
|
// transaction group, so removing it SHRINKS a group rather than dropping a
|
|
@@ -410,8 +506,8 @@ const makeAggregate = (schema, config) => {
|
|
|
410
506
|
// PUTs and would otherwise clobber it. The old set is only mined for
|
|
411
507
|
// (pk, sk) delete keys, so it is never stamped.
|
|
412
508
|
const now = yield* DateTime.now;
|
|
413
|
-
const newDynamo = buildDynamoItems(newGroups, config, pkValue, KeyComposer.extractComposites(config.collection.sk?.composite ?? [], assembledNew), stampFor(now, readExistingCreated(allItems)));
|
|
414
|
-
const oldDynamo = buildDynamoItems(oldGroups, config, pkValue, KeyComposer.extractComposites(config.collection.sk?.composite ?? [], assembledOld));
|
|
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);
|
|
415
511
|
const skOf = (item) => item.sk?.S ?? "";
|
|
416
512
|
const newItemsByGroup = new Map(newDynamo.map((g) => [g.group, g.items]));
|
|
417
513
|
const oldItemsByGroup = new Map(oldDynamo.map((g) => [g.group, g.items]));
|
|
@@ -477,13 +573,19 @@ const makeAggregate = (schema, config) => {
|
|
|
477
573
|
const startKey = options?.cursor
|
|
478
574
|
? JSON.parse(atob(options.cursor))
|
|
479
575
|
: undefined;
|
|
480
|
-
// Compose PK from filter values matching PK composites
|
|
481
|
-
|
|
482
|
-
//
|
|
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.
|
|
483
584
|
const skValues = [];
|
|
585
|
+
const listSkFilter = keyRecord(filter ?? {});
|
|
484
586
|
for (const attr of listConfig.sk.composite) {
|
|
485
|
-
if (
|
|
486
|
-
skValues.push(
|
|
587
|
+
if (listSkFilter[attr] !== undefined)
|
|
588
|
+
skValues.push(KeyComposer.serializeValue(listSkFilter[attr]));
|
|
487
589
|
else
|
|
488
590
|
break; // Stop at first gap (prefix matching)
|
|
489
591
|
}
|
|
@@ -593,10 +695,8 @@ const deepEqualGroups = (a, b) => {
|
|
|
593
695
|
return deepEqual(item.attributes, other.attributes);
|
|
594
696
|
});
|
|
595
697
|
};
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
// ---------------------------------------------------------------------------
|
|
599
|
-
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;
|
|
600
700
|
const children = [];
|
|
601
701
|
for (const [field, edge] of Object.entries(edges)) {
|
|
602
702
|
if ("_tag" in edge) {
|
|
@@ -605,19 +705,47 @@ const resolveNode = (fieldName, entityType, cardinality, edges, discriminator, a
|
|
|
605
705
|
const mergedDisc = edge.discriminator
|
|
606
706
|
? { ...(discriminator ?? {}), ...edge.discriminator }
|
|
607
707
|
: discriminator;
|
|
608
|
-
children.push(resolveNode(
|
|
609
|
-
|
|
610
|
-
|
|
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
|
+
}));
|
|
611
718
|
}
|
|
612
719
|
else if (edge._tag === "ManyEdge") {
|
|
613
|
-
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
|
+
}));
|
|
614
735
|
}
|
|
615
736
|
else if (edge._tag === "BoundSubAggregate") {
|
|
616
737
|
const bound = edge;
|
|
617
|
-
const subChildren = resolveNode(
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
+
});
|
|
621
749
|
children.push(subChildren);
|
|
622
750
|
}
|
|
623
751
|
}
|
|
@@ -632,7 +760,9 @@ const resolveNode = (fieldName, entityType, cardinality, edges, discriminator, a
|
|
|
632
760
|
children,
|
|
633
761
|
...(assemble !== undefined && { assemble }),
|
|
634
762
|
...(decompose !== undefined && { decompose }),
|
|
635
|
-
|
|
763
|
+
...(skComposite !== undefined && { skComposite }),
|
|
764
|
+
...(refIdentifierField !== undefined && { refIdentifierField }),
|
|
765
|
+
attrEncoders,
|
|
636
766
|
};
|
|
637
767
|
};
|
|
638
768
|
// ---------------------------------------------------------------------------
|
|
@@ -1051,7 +1181,7 @@ const replaceRefIds = (input, refRequests, lookup) => {
|
|
|
1051
1181
|
* Decompose an assembled domain object into DynamoDB items grouped by
|
|
1052
1182
|
* sub-aggregate transaction boundaries.
|
|
1053
1183
|
*/
|
|
1054
|
-
const decomposeAggregate = (assembled, rootNode, contextFields,
|
|
1184
|
+
const decomposeAggregate = (assembled, rootNode, contextFields, attrEncoders, aggregateName, schema) => Effect.gen(function* () {
|
|
1055
1185
|
const items = [];
|
|
1056
1186
|
// Extract context values from the root, serializing date fields for DynamoDB storage.
|
|
1057
1187
|
// Domain Date/DateTime objects must be encoded before toAttributeMap() marshalling,
|
|
@@ -1060,7 +1190,7 @@ const decomposeAggregate = (assembled, rootNode, contextFields, dateEncoders, ag
|
|
|
1060
1190
|
const contextValues = {};
|
|
1061
1191
|
for (const field of contextFields) {
|
|
1062
1192
|
const value = assembled[field];
|
|
1063
|
-
const encode =
|
|
1193
|
+
const encode = attrEncoders[field];
|
|
1064
1194
|
contextValues[field] = encode && value != null ? encode(value) : value;
|
|
1065
1195
|
}
|
|
1066
1196
|
// Root item: fields not claimed by edges
|
|
@@ -1070,7 +1200,7 @@ const decomposeAggregate = (assembled, rootNode, contextFields, dateEncoders, ag
|
|
|
1070
1200
|
if (edgeFieldNames.has(key))
|
|
1071
1201
|
continue;
|
|
1072
1202
|
// Serialize date fields in root attributes (same as context values)
|
|
1073
|
-
const encode =
|
|
1203
|
+
const encode = attrEncoders[key];
|
|
1074
1204
|
rootAttrs[key] = encode && value != null ? encode(value) : value;
|
|
1075
1205
|
}
|
|
1076
1206
|
items.push({
|
|
@@ -1078,12 +1208,37 @@ const decomposeAggregate = (assembled, rootNode, contextFields, dateEncoders, ag
|
|
|
1078
1208
|
attributes: rootAttrs,
|
|
1079
1209
|
transactionGroup: "root",
|
|
1080
1210
|
skComposites: [],
|
|
1211
|
+
member: rootNode.entityType,
|
|
1081
1212
|
});
|
|
1082
1213
|
// Decompose each edge
|
|
1083
1214
|
for (const child of rootNode.children) {
|
|
1084
1215
|
const fieldValue = assembled[child.fieldName];
|
|
1085
1216
|
yield* decomposeNode(items, child, fieldValue, contextValues, "root", [], aggregateName);
|
|
1086
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
|
+
}
|
|
1087
1242
|
// Group by transaction
|
|
1088
1243
|
const groups = new Map();
|
|
1089
1244
|
for (const item of items) {
|
|
@@ -1129,7 +1284,7 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
|
|
|
1129
1284
|
subRootAttrs[key] = val;
|
|
1130
1285
|
}
|
|
1131
1286
|
// Serialize this node's own date fields to their wire primitive (#72).
|
|
1132
|
-
|
|
1287
|
+
applyNodeAttrEncoders(subRootAttrs, node.attrEncoders);
|
|
1133
1288
|
// Inject context and discriminator
|
|
1134
1289
|
mergeContextValues(subRootAttrs, contextValues);
|
|
1135
1290
|
if (node.discriminator) {
|
|
@@ -1143,6 +1298,7 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
|
|
|
1143
1298
|
attributes: subRootAttrs,
|
|
1144
1299
|
transactionGroup: txGroup,
|
|
1145
1300
|
skComposites: discValues,
|
|
1301
|
+
member: node.fieldName ?? node.entityType,
|
|
1146
1302
|
});
|
|
1147
1303
|
// Decompose children within this sub-aggregate
|
|
1148
1304
|
for (const child of node.children) {
|
|
@@ -1154,7 +1310,7 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
|
|
|
1154
1310
|
// Simple one-to-one edge (no children)
|
|
1155
1311
|
const attrs = { ...value };
|
|
1156
1312
|
// Serialize this edge entity's own date fields to their wire primitive (#72).
|
|
1157
|
-
|
|
1313
|
+
applyNodeAttrEncoders(attrs, node.attrEncoders);
|
|
1158
1314
|
mergeContextValues(attrs, contextValues);
|
|
1159
1315
|
if (node.discriminator) {
|
|
1160
1316
|
for (const [k, v] of Object.entries(node.discriminator)) {
|
|
@@ -1173,6 +1329,7 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
|
|
|
1173
1329
|
attributes: attrs,
|
|
1174
1330
|
transactionGroup: parentGroup,
|
|
1175
1331
|
skComposites: [...parentDiscriminatorValues, ...ownDiscValues],
|
|
1332
|
+
member: node.fieldName ?? node.entityType,
|
|
1176
1333
|
});
|
|
1177
1334
|
}
|
|
1178
1335
|
else {
|
|
@@ -1185,7 +1342,7 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
|
|
|
1185
1342
|
for (const elem of arrayItems) {
|
|
1186
1343
|
const attrs = { ...elem };
|
|
1187
1344
|
// Serialize this edge entity's own date fields to their wire primitive (#72).
|
|
1188
|
-
|
|
1345
|
+
applyNodeAttrEncoders(attrs, node.attrEncoders);
|
|
1189
1346
|
mergeContextValues(attrs, contextValues);
|
|
1190
1347
|
if (node.discriminator) {
|
|
1191
1348
|
for (const [k, v] of Object.entries(node.discriminator)) {
|
|
@@ -1193,22 +1350,109 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
|
|
|
1193
1350
|
attrs[k] = v;
|
|
1194
1351
|
}
|
|
1195
1352
|
}
|
|
1196
|
-
|
|
1197
|
-
const itemComposites = extractRefIdentifiers(attrs);
|
|
1353
|
+
const itemComposites = yield* manyEdgeSkComposites(node, attrs, aggregateName);
|
|
1198
1354
|
items.push({
|
|
1199
1355
|
entityType: node.entityType,
|
|
1200
1356
|
attributes: attrs,
|
|
1201
1357
|
transactionGroup: parentGroup,
|
|
1202
1358
|
skComposites: [...parentDiscriminatorValues, ...itemComposites],
|
|
1359
|
+
member: node.fieldName ?? node.entityType,
|
|
1203
1360
|
});
|
|
1204
1361
|
}
|
|
1205
1362
|
}
|
|
1206
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
|
+
});
|
|
1207
1447
|
/**
|
|
1208
1448
|
* Extract ref identifier values from an item's attributes for use as SK composites.
|
|
1209
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}.
|
|
1210
1454
|
*/
|
|
1211
|
-
const extractRefIdentifiers = (attrs) => {
|
|
1455
|
+
const extractRefIdentifiers = (attrs, refIdentifierField) => {
|
|
1212
1456
|
const ids = [];
|
|
1213
1457
|
// Case 1: Element wraps entity — look for nested objects with id-like fields
|
|
1214
1458
|
for (const [, value] of Object.entries(attrs)) {
|
|
@@ -1227,9 +1471,17 @@ const extractRefIdentifiers = (attrs) => {
|
|
|
1227
1471
|
}
|
|
1228
1472
|
}
|
|
1229
1473
|
}
|
|
1230
|
-
// Case 2: Element IS entity —
|
|
1231
|
-
|
|
1232
|
-
|
|
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);
|
|
1233
1485
|
}
|
|
1234
1486
|
return ids;
|
|
1235
1487
|
};
|
|
@@ -1253,7 +1505,14 @@ const EMPTY_CREATED = new Map();
|
|
|
1253
1505
|
* on every comparison and silently collapse the diff into a full-partition
|
|
1254
1506
|
* rewrite on every update (#98).
|
|
1255
1507
|
*/
|
|
1256
|
-
const buildDynamoItems = (groups, config, pkValue, rootCollectionSkComposites,
|
|
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).
|
|
1514
|
+
*/
|
|
1515
|
+
keyRecord, stamp) => groups.map((group) => ({
|
|
1257
1516
|
group: group.name,
|
|
1258
1517
|
items: group.items.map((item) => {
|
|
1259
1518
|
const attrs = { ...item.attributes };
|
|
@@ -1276,7 +1535,7 @@ const buildDynamoItems = (groups, config, pkValue, rootCollectionSkComposites, s
|
|
|
1276
1535
|
}
|
|
1277
1536
|
// List collection GSI keys (root items only)
|
|
1278
1537
|
if (isRootItem && config.list) {
|
|
1279
|
-
const listPkComposites = KeyComposer.extractComposites(config.list.pk.composite, item.attributes);
|
|
1538
|
+
const listPkComposites = KeyComposer.extractComposites(config.list.pk.composite, keyRecord(item.attributes));
|
|
1280
1539
|
if (config.list.cardinality) {
|
|
1281
1540
|
const shard = hashToShard(pkValue, config.list.cardinality);
|
|
1282
1541
|
attrs[config.list.pk.field] = composeCollectionKey(config.schema, config.list.name, [
|
|
@@ -1287,7 +1546,7 @@ const buildDynamoItems = (groups, config, pkValue, rootCollectionSkComposites, s
|
|
|
1287
1546
|
else {
|
|
1288
1547
|
attrs[config.list.pk.field] = composeCollectionKey(config.schema, config.list.name, listPkComposites);
|
|
1289
1548
|
}
|
|
1290
|
-
const listSkComposites = KeyComposer.extractComposites(config.list.sk.composite, item.attributes);
|
|
1549
|
+
const listSkComposites = KeyComposer.extractComposites(config.list.sk.composite, keyRecord(item.attributes));
|
|
1291
1550
|
attrs[config.list.sk.field] = composeCollectionKey(config.schema, config.list.name, listSkComposites);
|
|
1292
1551
|
}
|
|
1293
1552
|
// System timestamps — last, so a generated value wins over the same-named
|