effect-dynamodb 1.21.0 → 1.23.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/README.md +2 -0
- package/dist/Aggregate.d.ts +9 -4
- package/dist/Aggregate.d.ts.map +1 -1
- package/dist/Aggregate.js +644 -219
- package/dist/Aggregate.js.map +1 -1
- package/dist/Batch.d.ts +7 -0
- package/dist/Batch.d.ts.map +1 -1
- package/dist/Batch.js +132 -7
- package/dist/Batch.js.map +1 -1
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +18 -32
- package/dist/Collection.js.map +1 -1
- package/dist/DynamoClient.d.ts +40 -3
- package/dist/DynamoClient.d.ts.map +1 -1
- package/dist/DynamoClient.js +72 -20
- package/dist/DynamoClient.js.map +1 -1
- package/dist/Entity.d.ts +138 -55
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +3061 -626
- package/dist/Entity.js.map +1 -1
- package/dist/EventStore.d.ts +30 -8
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +203 -155
- package/dist/EventStore.js.map +1 -1
- package/dist/Query.d.ts +75 -3
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +228 -30
- package/dist/Query.js.map +1 -1
- package/dist/Transaction.d.ts +13 -2
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +48 -38
- package/dist/Transaction.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/BoundCrud.d.ts +31 -20
- package/dist/internal/BoundCrud.d.ts.map +1 -1
- package/dist/internal/BoundCrud.js +1 -1
- package/dist/internal/BoundCrud.js.map +1 -1
- package/dist/internal/BoundQuery.d.ts +47 -2
- package/dist/internal/BoundQuery.d.ts.map +1 -1
- package/dist/internal/BoundQuery.js +182 -18
- package/dist/internal/BoundQuery.js.map +1 -1
- package/dist/internal/EntityCombinators.d.ts +32 -10
- package/dist/internal/EntityCombinators.d.ts.map +1 -1
- package/dist/internal/EntityCombinators.js +16 -7
- package/dist/internal/EntityCombinators.js.map +1 -1
- package/dist/internal/EntityOps.d.ts +44 -10
- package/dist/internal/EntityOps.d.ts.map +1 -1
- package/dist/internal/EntityOps.js +48 -5
- package/dist/internal/EntityOps.js.map +1 -1
- package/dist/internal/Expr.d.ts +23 -0
- package/dist/internal/Expr.d.ts.map +1 -1
- package/dist/internal/Expr.js +84 -9
- package/dist/internal/Expr.js.map +1 -1
- package/dist/internal/ItemSize.d.ts +38 -0
- package/dist/internal/ItemSize.d.ts.map +1 -0
- package/dist/internal/ItemSize.js +62 -0
- package/dist/internal/ItemSize.js.map +1 -0
- package/dist/internal/TransactWriteOps.d.ts +112 -19
- package/dist/internal/TransactWriteOps.d.ts.map +1 -1
- package/dist/internal/TransactWriteOps.js +210 -33
- package/dist/internal/TransactWriteOps.js.map +1 -1
- package/dist/internal/TransactableOps.d.ts +33 -14
- package/dist/internal/TransactableOps.d.ts.map +1 -1
- package/dist/internal/TransactableOps.js +46 -19
- package/dist/internal/TransactableOps.js.map +1 -1
- package/package.json +2 -2
package/dist/Aggregate.js
CHANGED
|
@@ -11,16 +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, AggregateDecompositionError, AggregateTransactionOverflow, RefNotFound, TransactionCancelled, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
|
|
14
|
+
import { AggregateAssemblyError, AggregateDecompositionError, AggregateTransactionOverflow, ConditionalCheckFailed, DynamoError, RefNotFound, TransactionCancelled, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
|
|
15
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
|
+
import { buildDateTransform, containsWireTransform, matchDateRepresentation, optionalField, resolveSystemFields, substituteSchemaDeep, validateNoTransformOverride, } from "@effect-dynamodb/schema/internal/EntitySchemas.js";
|
|
17
|
+
import { extractArrayElement, getSchemaFields, hasEncodingTransformation, } from "@effect-dynamodb/schema/internal/SchemaAccessors.js";
|
|
18
18
|
import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
|
|
19
|
-
import { DateTime, Effect, Option, Schema, SchemaAST } from "effect";
|
|
19
|
+
import { DateTime, Effect, Equal, Option, Schema, SchemaAST, } from "effect";
|
|
20
20
|
import * as Batch from "./Batch.js";
|
|
21
21
|
import { DynamoClient } from "./DynamoClient.js";
|
|
22
22
|
import { fromDefinition as entityFromDefinition } from "./Entity.js";
|
|
23
|
-
import { compileExpr, createConditionOps, parseSimpleShorthand, } from "./internal/Expr.js";
|
|
23
|
+
import { compileExpr, createConditionOps, emptyPartProblem, isEmptyExpr, parseSimpleShorthand, } from "./internal/Expr.js";
|
|
24
24
|
import { createPathBuilder } from "./internal/PathBuilder.js";
|
|
25
25
|
import { generateTimestampPrimitive } from "./internal/TransactableOps.js";
|
|
26
26
|
import { fromAttributeMap, toAttributeMap, toAttributeValue } from "./Marshaller.js";
|
|
@@ -99,6 +99,64 @@ const inferDateEncoding = (ast) => {
|
|
|
99
99
|
}
|
|
100
100
|
return undefined;
|
|
101
101
|
};
|
|
102
|
+
/**
|
|
103
|
+
* A union that holds a date member alongside a member that is neither a date
|
|
104
|
+
* nor `Undefined` / `Null` — `Union([DateTimeUtcFromString, Number])`.
|
|
105
|
+
* `inferDateEncoding` looks through unions to find the date in `optional(date)`,
|
|
106
|
+
* so it reports such a field as a date, and encoding a `5` through a date
|
|
107
|
+
* transform throws. Its stored form is the union's own business: it is encoded
|
|
108
|
+
* member by member through the substituted union instead.
|
|
109
|
+
*/
|
|
110
|
+
const isMixedDateUnion = (ast) => {
|
|
111
|
+
if (!SchemaAST.isUnion(ast))
|
|
112
|
+
return false;
|
|
113
|
+
const isDate = (member) => matchDateRepresentation(SchemaAST.resolve(member)) !==
|
|
114
|
+
undefined ||
|
|
115
|
+
matchDateRepresentation(member.annotations) !== undefined;
|
|
116
|
+
return ast.types.some((member) => !SchemaAST.isUndefined(member) && !SchemaAST.isNull(member) && !isDate(member));
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* The date branch shared by both encoder builders: an explicit
|
|
120
|
+
* `DynamoModel.storedAs` annotation, or the inferred default for a standard
|
|
121
|
+
* Effect date schema. It wins over the field's own encode because `storedAs` is
|
|
122
|
+
* precisely an override of it (a `Schema.DateTimeUtc` field marked
|
|
123
|
+
* `storedAs(DateEpochMs)` must store the epoch, not the ISO string its own schema
|
|
124
|
+
* would produce).
|
|
125
|
+
*/
|
|
126
|
+
const dateAttrEncoder = (fieldSchema, options) => {
|
|
127
|
+
if (options?.mixedUnions !== true && isMixedDateUnion(fieldSchema.ast))
|
|
128
|
+
return undefined;
|
|
129
|
+
const encoding = DynamoModel.getEncoding(fieldSchema) ?? inferDateEncoding(fieldSchema.ast);
|
|
130
|
+
if (!encoding)
|
|
131
|
+
return undefined;
|
|
132
|
+
const encode = Schema.encodeUnknownSync(buildDateTransform(encoding));
|
|
133
|
+
return (value) => encode(value);
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* The field's own encode, with `decode -> encode` as the fallback so a caller
|
|
137
|
+
* who already supplied wire form round-trips to itself (the same strategy
|
|
138
|
+
* `Entity.put` uses). Neither working means the value is neither Type nor
|
|
139
|
+
* Encoded for this field; it is stored unchanged rather than introducing a
|
|
140
|
+
* write-time failure mode — that shape is already unreadable, and the tolerant
|
|
141
|
+
* read path reports it.
|
|
142
|
+
*/
|
|
143
|
+
const ownAttrEncoder = (fieldSchema) => {
|
|
144
|
+
const codec = fieldSchema;
|
|
145
|
+
const encode = Schema.encodeUnknownOption(codec);
|
|
146
|
+
const decode = Schema.decodeUnknownOption(codec);
|
|
147
|
+
return (value) => {
|
|
148
|
+
const direct = encode(value);
|
|
149
|
+
if (Option.isSome(direct))
|
|
150
|
+
return direct.value;
|
|
151
|
+
const decoded = decode(value);
|
|
152
|
+
if (Option.isSome(decoded)) {
|
|
153
|
+
const reencoded = encode(decoded.value);
|
|
154
|
+
if (Option.isSome(reencoded))
|
|
155
|
+
return reencoded.value;
|
|
156
|
+
}
|
|
157
|
+
return value;
|
|
158
|
+
};
|
|
159
|
+
};
|
|
102
160
|
/**
|
|
103
161
|
* Build per-field ATTRIBUTE encoders for a schema's own fields — the write-side
|
|
104
162
|
* equivalent of what `Entity.put` gets for free by encoding its whole input
|
|
@@ -106,70 +164,106 @@ const inferDateEncoding = (ast) => {
|
|
|
106
164
|
*
|
|
107
165
|
* Decomposition works from the schema-DECODED domain object, so every attribute
|
|
108
166
|
* it produces is a Type-side value. Marshalling those directly stores a shape
|
|
109
|
-
* the read path cannot decode: a `
|
|
110
|
-
* `{
|
|
111
|
-
*
|
|
112
|
-
* got a date-only pass; the same argument applies to every transformed field.
|
|
167
|
+
* the read path cannot decode: a `DateTime` lands as a marshalled
|
|
168
|
+
* `{ epochMilliseconds, "~effect/DateTime", _tag }` map, a `Date` as `{M:{}}`,
|
|
169
|
+
* a `bigint` as `{N:"5"}`.
|
|
113
170
|
*
|
|
114
171
|
* Exactly ONE encoder per field, so nothing is ever encoded twice:
|
|
115
172
|
*
|
|
116
|
-
* 1. **A date encoding** —
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
173
|
+
* 1. **A date encoding** — see {@link dateAttrEncoder}.
|
|
174
|
+
* 2. **Anything that holds a wire transform AT ANY DEPTH** — a leaf transform, a
|
|
175
|
+
* `Schema.Class`, and also an `Array` / `Struct` / `Union` containing one, or
|
|
176
|
+
* a self date or `Redacted` nested anywhere ({@link containsWireTransform}).
|
|
177
|
+
* The attribute is encoded through the SAME substituted, tolerant schema the
|
|
178
|
+
* read path decodes it with (`substituteSchemaDeep` under
|
|
179
|
+
* `tolerantTransforms`, with the aggregate's ref resolver), so the stored
|
|
180
|
+
* form is exactly what assembly reads back — nested date leaves included —
|
|
181
|
+
* and a `DynamoModel.ref`-annotated field is encoded through the ref
|
|
182
|
+
* target's model. The field's own encode is the fallback.
|
|
183
|
+
*
|
|
184
|
+
* Gating on the TOP-LEVEL node instead (as before #133) skipped every
|
|
185
|
+
* container, because an `Arrays` / `Objects` node carries no encoding of its
|
|
186
|
+
* own: `Schema.Array(DateTimeUtcFromString)` and `Schema.Array(SomeClass)`
|
|
187
|
+
* attributes stored their `DateTime`s as maps.
|
|
188
|
+
* 3. **Nothing to encode** — no encoder at all, so the stored bytes are
|
|
126
189
|
* identical to before this existed.
|
|
127
190
|
*
|
|
128
|
-
*
|
|
129
|
-
* (
|
|
191
|
+
* Encoding stays PER ATTRIBUTE: decomposition and key composition need the
|
|
192
|
+
* Type-side values (`numericTypeWithStringEncoding`), so the aggregate is never
|
|
193
|
+
* encoded as a whole before it is decomposed.
|
|
130
194
|
*/
|
|
131
|
-
const buildAttrEncoders = (fields) => {
|
|
195
|
+
const buildAttrEncoders = (fields, resolveRef) => {
|
|
132
196
|
const encoders = {};
|
|
133
197
|
if (!fields)
|
|
134
198
|
return encoders;
|
|
135
199
|
for (const field of Object.keys(fields)) {
|
|
136
200
|
const fieldSchema = fields[field];
|
|
137
|
-
// 1. Date encoding
|
|
138
|
-
const
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
encoders[field] = (value) => encode(value);
|
|
201
|
+
// 1. Date encoding.
|
|
202
|
+
const date = dateAttrEncoder(fieldSchema);
|
|
203
|
+
if (date) {
|
|
204
|
+
encoders[field] = date;
|
|
142
205
|
continue;
|
|
143
206
|
}
|
|
144
|
-
// 3.
|
|
145
|
-
|
|
146
|
-
// only change bytes that are already correct.
|
|
147
|
-
if (!hasEncodingTransformation(fieldSchema))
|
|
207
|
+
// 3. Nothing anywhere in the value has a wire form distinct from its domain form.
|
|
208
|
+
if (!containsWireTransform(fieldSchema))
|
|
148
209
|
continue;
|
|
149
|
-
// 2.
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
const
|
|
210
|
+
// 2. Encode through the read path's own (substituted, tolerant) schema. A
|
|
211
|
+
// one-field struct carries the field's optionality and lets `resolveRef`
|
|
212
|
+
// re-point the field itself, exactly as it does inside `decodeSchema`.
|
|
213
|
+
const substituted = substituteSchemaDeep(Schema.Struct({ [field]: fieldSchema }), {
|
|
214
|
+
tolerantTransforms: true,
|
|
215
|
+
resolveRef,
|
|
216
|
+
});
|
|
217
|
+
const encodeSubstituted = Schema.encodeUnknownOption(substituted);
|
|
218
|
+
const own = ownAttrEncoder(fieldSchema);
|
|
158
219
|
encoders[field] = (value) => {
|
|
159
|
-
const
|
|
160
|
-
if (Option.isSome(
|
|
161
|
-
return
|
|
162
|
-
|
|
163
|
-
if (Option.isSome(decoded)) {
|
|
164
|
-
const reencoded = encode(decoded.value);
|
|
165
|
-
if (Option.isSome(reencoded))
|
|
166
|
-
return reencoded.value;
|
|
167
|
-
}
|
|
168
|
-
return value;
|
|
220
|
+
const encoded = encodeSubstituted({ [field]: value });
|
|
221
|
+
if (Option.isSome(encoded))
|
|
222
|
+
return encoded.value[field];
|
|
223
|
+
return own(value);
|
|
169
224
|
};
|
|
170
225
|
}
|
|
171
226
|
return encoders;
|
|
172
227
|
};
|
|
228
|
+
/**
|
|
229
|
+
* The encoders sort-key composites are READ from — the encoders as they stood
|
|
230
|
+
* before #133, gated on the field's TOP-LEVEL encoding.
|
|
231
|
+
*
|
|
232
|
+
* A `many` edge's declared `sk.composite` (and a root list index's composites)
|
|
233
|
+
* are read off the item's attributes. Those attributes used to be encoded by
|
|
234
|
+
* these encoders only, so the stored keys were composed from their output: a
|
|
235
|
+
* top-level transformed field from its encoded form, anything nested (a
|
|
236
|
+
* `DateTime` or `NumberFromString` reached through `"umpire.dateOfBirth"`) from
|
|
237
|
+
* its domain form. Composing keys from the new, deeper encoders instead would
|
|
238
|
+
* re-spell those keys — orphaning every stored row and making `update` diff
|
|
239
|
+
* against keys that do not exist. Keys therefore keep this exact behaviour,
|
|
240
|
+
* and only the STORED attribute values gain the deeper encoding.
|
|
241
|
+
*/
|
|
242
|
+
const buildKeyAttrEncoders = (fields) => {
|
|
243
|
+
const encoders = {};
|
|
244
|
+
if (!fields)
|
|
245
|
+
return encoders;
|
|
246
|
+
for (const field of Object.keys(fields)) {
|
|
247
|
+
const fieldSchema = fields[field];
|
|
248
|
+
const date = dateAttrEncoder(fieldSchema, { mixedUnions: true });
|
|
249
|
+
if (date) {
|
|
250
|
+
// Before, a value this encoder rejects (the `5` of a mixed date union)
|
|
251
|
+
// threw and failed the write, so there is no stored key to stay
|
|
252
|
+
// byte-identical with: compose from the value as it is.
|
|
253
|
+
encoders[field] = (value) => {
|
|
254
|
+
try {
|
|
255
|
+
return date(value);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return value;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
else if (hasEncodingTransformation(fieldSchema))
|
|
263
|
+
encoders[field] = ownAttrEncoder(fieldSchema);
|
|
264
|
+
}
|
|
265
|
+
return encoders;
|
|
266
|
+
};
|
|
173
267
|
/**
|
|
174
268
|
* A schema's declared fields, if it exposes them (Schema.Struct / Schema.Class).
|
|
175
269
|
*
|
|
@@ -185,11 +279,156 @@ const fieldsOf = (schema) => {
|
|
|
185
279
|
const source = DynamoModel.isConfiguredModel(schema) ? schema.model : schema;
|
|
186
280
|
return source?.fields;
|
|
187
281
|
};
|
|
282
|
+
/** The value schema of a struct field, with any `optionalKey` / `optional` wrapper removed. */
|
|
283
|
+
const unwrapOptionalField = (field) => optionalField(field)?.inner ?? field;
|
|
284
|
+
/**
|
|
285
|
+
* Whether a field is a class-like value whose fields the schema walker cannot
|
|
286
|
+
* see. `DynamoModel.ref` annotates with `Schema.annotate`, which drops a
|
|
287
|
+
* `Schema.Class`'s `.fields`, so `substituteSchemaDeep` cannot recurse into it
|
|
288
|
+
* and the target's transformed fields keep their strict decoders. A plain class
|
|
289
|
+
* is introspectable and is left to the walker.
|
|
290
|
+
*/
|
|
291
|
+
const isOpaqueRefField = (field) => {
|
|
292
|
+
const inner = unwrapOptionalField(field);
|
|
293
|
+
return getSchemaFields(inner) === undefined && SchemaAST.isDeclaration(inner.ast);
|
|
294
|
+
};
|
|
295
|
+
/**
|
|
296
|
+
* The fields of a `many` edge's ELEMENT, read off the parent model's array
|
|
297
|
+
* field (`players: Schema.Array(PlayerSheet)` → `PlayerSheet`'s fields).
|
|
298
|
+
* `undefined` when the field is not array-shaped (a custom `decompose`) or its
|
|
299
|
+
* element exposes no fields (a scalar, or an opaque ref).
|
|
300
|
+
*/
|
|
301
|
+
const manyElementFields = (parentFields, edgeName) => {
|
|
302
|
+
const field = parentFields?.[edgeName];
|
|
303
|
+
const element = field === undefined ? undefined : extractArrayElement(field);
|
|
304
|
+
return element === undefined ? undefined : getSchemaFields(element);
|
|
305
|
+
};
|
|
188
306
|
/**
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
307
|
+
* A sub-aggregate nested in another inherits its parent's discriminator, and
|
|
308
|
+
* its rows carry both (#133). Re-using an inherited attribute name would
|
|
309
|
+
* overwrite the parent's value on the inner rows: the two bindings of the
|
|
310
|
+
* parent would then key and assemble the inner sub-aggregate identically.
|
|
311
|
+
* Refused at `make()` time, naming the edge, rather than surfacing as a
|
|
312
|
+
* key collision or a mis-assembly at runtime.
|
|
313
|
+
*/
|
|
314
|
+
const validateNestedDiscriminators = (aggregateName, edges, inherited = new Map(), path = []) => {
|
|
315
|
+
for (const [edgeName, edge] of Object.entries(edges)) {
|
|
316
|
+
if (!("_tag" in edge) || edge._tag !== "BoundSubAggregate")
|
|
317
|
+
continue;
|
|
318
|
+
const bound = edge;
|
|
319
|
+
const here = [...path, edgeName];
|
|
320
|
+
const own = Object.keys(bound.discriminator ?? {});
|
|
321
|
+
for (const attr of own) {
|
|
322
|
+
const owner = inherited.get(attr);
|
|
323
|
+
if (owner !== undefined) {
|
|
324
|
+
throw new Error(`[EDD-9056] Aggregate "${aggregateName}": the sub-aggregate at "${here.join(".")}" ` +
|
|
325
|
+
`declares discriminator "${attr}", which it already inherits from "${owner}". ` +
|
|
326
|
+
`A nested sub-aggregate's rows carry every enclosing discriminator, so the inner ` +
|
|
327
|
+
`value would overwrite the outer one. Give the nested binding a distinct attribute ` +
|
|
328
|
+
`name (e.g. { squadNo: 1 } inside { clubNo: 1 }).`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const next = new Map(inherited);
|
|
332
|
+
for (const attr of own)
|
|
333
|
+
next.set(attr, here.join("."));
|
|
334
|
+
validateNestedDiscriminators(aggregateName, bound.aggregate.edges, next, here);
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
const unwrapEntityModel = (model) => DynamoModel.isConfiguredModel(model) ? model.model : model;
|
|
338
|
+
/**
|
|
339
|
+
* Map each ref FIELD SCHEMA the aggregate's schema walker cannot see into to the
|
|
340
|
+
* model it should be decoded (and encoded) as.
|
|
341
|
+
*
|
|
342
|
+
* `substituteSchemaDeep` recurses into plain classes / structs / arrays on its
|
|
343
|
+
* own. It cannot recurse into a `DynamoModel.ref`-annotated class (the
|
|
344
|
+
* annotation drops `.fields`), so that field's transformed leaves keep their
|
|
345
|
+
* strict decoders: a re-decoded domain `DateTime` is rejected as "Expected
|
|
346
|
+
* string", and a legacy marshalled one is never lifted. Registered here:
|
|
347
|
+
*
|
|
348
|
+
* - **root `one` / `ref` edge fields** — unconditionally, re-pointed at the
|
|
349
|
+
* edge entity's model (the behaviour since #72);
|
|
350
|
+
* - **sub-aggregate `one` / `ref` edge fields** and **the ref field inside a
|
|
351
|
+
* `many` element** (`PlayerSheet.player`, found by
|
|
352
|
+
* `deriveEntityFieldName(entity)` as hydration finds it), at root or in a
|
|
353
|
+
* sub-aggregate — only when OPAQUE. A plain class there is already walked,
|
|
354
|
+
* and swapping it for the entity's model could only make its decode stricter
|
|
355
|
+
* or change its class (#133);
|
|
356
|
+
* - **a `many` edge field whose ELEMENT is an opaque ref**
|
|
357
|
+
* (`Schema.Array(Player.pipe(DynamoModel.ref))`) — the array field itself,
|
|
358
|
+
* re-pointed at `Schema.Array(<entity model>)`, since the walker can only
|
|
359
|
+
* re-point fields, not array elements;
|
|
360
|
+
* - **refs nested inside an edge entity's own model** (`maker` on a `supplier`
|
|
361
|
+
* edge entity, #116).
|
|
362
|
+
*
|
|
363
|
+
* Keyed by schema IDENTITY. The resolver is consulted at every depth of the
|
|
364
|
+
* walk, so the former name-keyed table re-pointed every same-named field
|
|
365
|
+
* anywhere in the model — a `coach` field three levels down was decoded as the
|
|
366
|
+
* root `coach` edge's entity.
|
|
367
|
+
*/
|
|
368
|
+
const collectRefTargets = (rootFields, rootEdges) => {
|
|
369
|
+
const targets = new Map();
|
|
370
|
+
const register = (field, model) => {
|
|
371
|
+
if (field !== undefined && model !== undefined && !targets.has(field)) {
|
|
372
|
+
targets.set(field, unwrapEntityModel(model));
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
const registerNested = (entity) => {
|
|
376
|
+
if (entity?.model === undefined)
|
|
377
|
+
return;
|
|
378
|
+
const entityFields = getSchemaFields(unwrapEntityModel(entity.model));
|
|
379
|
+
for (const nested of entity._data?.resolvedRefs ?? []) {
|
|
380
|
+
register(entityFields?.[nested.fieldName], nested.refEntity?.model);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
const walk = (fields, edges, isRoot) => {
|
|
384
|
+
for (const [edgeName, edge] of Object.entries(edges)) {
|
|
385
|
+
if (!("_tag" in edge))
|
|
386
|
+
continue;
|
|
387
|
+
if (edge._tag === "RefEdge" || edge._tag === "OneEdge") {
|
|
388
|
+
const entity = edge.entity;
|
|
389
|
+
const field = fields?.[edgeName];
|
|
390
|
+
if (field !== undefined && (isRoot || isOpaqueRefField(field))) {
|
|
391
|
+
register(field, entity?.model);
|
|
392
|
+
}
|
|
393
|
+
registerNested(entity);
|
|
394
|
+
}
|
|
395
|
+
else if (edge._tag === "ManyEdge") {
|
|
396
|
+
const entity = edge.entity;
|
|
397
|
+
const arrayField = fields?.[edgeName];
|
|
398
|
+
const element = arrayField === undefined ? undefined : extractArrayElement(arrayField);
|
|
399
|
+
if (arrayField !== undefined && element !== undefined && isOpaqueRefField(element)) {
|
|
400
|
+
// The element IS an opaque ref (`Schema.Array(Player.pipe(DynamoModel.ref))`):
|
|
401
|
+
// re-point the whole array field at an array of the entity's model. The
|
|
402
|
+
// walker re-applies the field's optionality around the target.
|
|
403
|
+
const model = entity?.model;
|
|
404
|
+
if (model !== undefined && !targets.has(arrayField)) {
|
|
405
|
+
targets.set(arrayField, Schema.Array(unwrapEntityModel(model)));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
else if (edge.entity !== undefined) {
|
|
409
|
+
// The element WRAPS the ref (`PlayerSheet { player }`), found by name as
|
|
410
|
+
// hydration finds it.
|
|
411
|
+
const refField = manyElementFields(fields, edgeName)?.[deriveEntityFieldName(edge.entity)];
|
|
412
|
+
if (refField !== undefined && isOpaqueRefField(refField)) {
|
|
413
|
+
register(refField, entity?.model);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
registerNested(entity);
|
|
417
|
+
}
|
|
418
|
+
else if (edge._tag === "BoundSubAggregate") {
|
|
419
|
+
const bound = edge;
|
|
420
|
+
walk(fieldsOf(bound.aggregate.schema), bound.aggregate.edges, false);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
walk(rootFields, rootEdges, true);
|
|
425
|
+
return targets;
|
|
426
|
+
};
|
|
427
|
+
/**
|
|
428
|
+
* Apply a node's attribute encoders in place — puts each attribute into its
|
|
429
|
+
* stored form before the decomposed item is marshalled (#72, #133: without
|
|
430
|
+
* this, a `DateTime` marshals to a `{ epochMilliseconds, … }` map that the
|
|
431
|
+
* read path cannot decode as the wire form).
|
|
193
432
|
*/
|
|
194
433
|
const applyNodeAttrEncoders = (attrs, encoders) => {
|
|
195
434
|
if (!encoders)
|
|
@@ -203,26 +442,8 @@ const applyNodeAttrEncoders = (attrs, encoders) => {
|
|
|
203
442
|
// Top-level aggregate construction
|
|
204
443
|
// ---------------------------------------------------------------------------
|
|
205
444
|
const makeAggregate = (schema, config) => {
|
|
206
|
-
// Build resolved graph by walking edges recursively
|
|
207
|
-
const rootNode = resolveNode({
|
|
208
|
-
fieldName: null,
|
|
209
|
-
entityType: config.root.entityType,
|
|
210
|
-
cardinality: "root",
|
|
211
|
-
edges: config.edges,
|
|
212
|
-
});
|
|
213
445
|
const aggregateName = config.root.entityType;
|
|
214
446
|
const contextFields = config.context ?? [];
|
|
215
|
-
// Detect date encodings for all root schema fields (once, at make() time)
|
|
216
|
-
// and build a per-field bidirectional substitute schema.
|
|
217
|
-
//
|
|
218
|
-
// Aggregates decompose/assemble at the root-attribute level — each date
|
|
219
|
-
// field is converted in isolation (write: domain → wire primitive; read:
|
|
220
|
-
// wire primitive → domain). To stay schema-driven (issue #29), we route
|
|
221
|
-
// the conversion through the same `buildDateTransform` substitute used by
|
|
222
|
-
// Entity. `Schema.encodeUnknownSync` produces the wire form on writes and
|
|
223
|
-
// `Schema.decodeUnknownSync` lifts the wire form back on reads — wire
|
|
224
|
-
// format is byte-identical to the legacy per-field helpers.
|
|
225
|
-
//
|
|
226
447
|
// Policy: aggregate fields that already carry a date transform schema
|
|
227
448
|
// (e.g. `Schema.DateTimeUtcFromString`) cannot be combined with a
|
|
228
449
|
// `DynamoModel.storedAs(...)` annotation that conflicts with the
|
|
@@ -235,58 +456,57 @@ const makeAggregate = (schema, config) => {
|
|
|
235
456
|
// annotation conflict is checked.
|
|
236
457
|
validateNoTransformOverride(schemaFields, {});
|
|
237
458
|
}
|
|
238
|
-
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
459
|
+
validateNestedDiscriminators(aggregateName, config.edges);
|
|
460
|
+
// Ref fields whose target the schema walker must be told about (see
|
|
461
|
+
// `collectRefTargets`). Keyed by the field SCHEMA, not its name: the resolver
|
|
462
|
+
// is consulted at every depth, so a name-keyed table re-pointed any
|
|
463
|
+
// same-named field anywhere in the model.
|
|
464
|
+
const refTargets = collectRefTargets(schemaFields, config.edges);
|
|
465
|
+
const resolveRef = (_name, field) => refTargets.get(field);
|
|
466
|
+
// Build resolved graph by walking edges recursively. Each node's attribute
|
|
467
|
+
// encoders come from the schema its decomposed values actually have — for a
|
|
468
|
+
// `many` edge that is the aggregate model's array ELEMENT (#133).
|
|
469
|
+
const rootNode = resolveNode({
|
|
470
|
+
fieldName: null,
|
|
471
|
+
entityType: config.root.entityType,
|
|
472
|
+
cardinality: "root",
|
|
473
|
+
edges: config.edges,
|
|
474
|
+
parentFields: schemaFields,
|
|
475
|
+
resolveRef,
|
|
476
|
+
});
|
|
477
|
+
// Attribute ENCODERS for the root item: decompose works from the
|
|
478
|
+
// schema-decoded domain object, so every root attribute holding a wire
|
|
479
|
+
// transform — a date, a transformed scalar, or a container of either — is
|
|
480
|
+
// put into its stored form. The key encoders are the pre-#133 ones, which the
|
|
481
|
+
// root's list-index composites are still read from (see `buildKeyAttrEncoders`).
|
|
482
|
+
const attrEncoders = buildAttrEncoders(schemaFields, resolveRef);
|
|
483
|
+
const keyAttrEncoders = buildKeyAttrEncoders(schemaFields);
|
|
484
|
+
const rootEncoders = { stored: attrEncoders, key: keyAttrEncoders };
|
|
242
485
|
// Decode schema for read/assemble + input validation. Mirrors the raw `schema`
|
|
243
|
-
// but recursively substitutes EVERY date / Redacted leaf — root
|
|
244
|
-
//
|
|
245
|
-
//
|
|
486
|
+
// but recursively substitutes EVERY date / Redacted / transformed leaf — root
|
|
487
|
+
// and nested — with a TOLERANT transform. This is the single decode path for
|
|
488
|
+
// get / create / update:
|
|
246
489
|
// - reads supply the stored wire form (string/number) → lifted to domain;
|
|
490
|
+
// a legacy marshalled `DateTime` map is rebuilt into a real one (#133);
|
|
247
491
|
// - update re-decodes the mutated state, which carries domain `DateTime`
|
|
248
492
|
// values → accepted as-is (a strict `*FromString` decoder would reject
|
|
249
493
|
// them). It is decode-only, so making transforms tolerant is safe — the
|
|
250
|
-
// stored wire format is still produced by
|
|
494
|
+
// stored wire format is still produced by the attribute encoders.
|
|
251
495
|
// Nested edge / ref classes round-trip with their class instance identity
|
|
252
496
|
// preserved (Option A). Returns the raw `schema` unchanged when it carries no
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
// SINGLE ref/one edge fields carry the opaque `DynamoModel.ref` annotation, so
|
|
256
|
-
// the substitution can't introspect them — `resolveRef` re-points each to its
|
|
257
|
-
// edge target model (unwrapping `DynamoModel.configure`). MANY edges are
|
|
258
|
-
// excluded: their model field is a `Schema.Array(...)` (or a wrapper class) that
|
|
259
|
-
// `substituteSchemaDeep` introspects directly — re-pointing it at the element
|
|
260
|
-
// model would drop the `Array` and yield "Expected object, got []" on assemble.
|
|
261
|
-
const edgeRefModels = new Map();
|
|
262
|
-
const unwrapModel = (model) => DynamoModel.isConfiguredModel(model) ? model.model : model;
|
|
263
|
-
for (const [edgeName, edge] of Object.entries(config.edges)) {
|
|
264
|
-
if (!("_tag" in edge))
|
|
265
|
-
continue;
|
|
266
|
-
if (edge._tag !== "RefEdge" && edge._tag !== "OneEdge")
|
|
267
|
-
continue;
|
|
268
|
-
const entity = edge.entity;
|
|
269
|
-
const model = entity?.model;
|
|
270
|
-
if (!model)
|
|
271
|
-
continue;
|
|
272
|
-
edgeRefModels.set(edgeName, unwrapModel(model));
|
|
273
|
-
// A ref nested INSIDE an edge's model needs the same treatment. `maker` on
|
|
274
|
-
// an edge entity is annotated with `DynamoModel.ref`, and `Schema.annotate`
|
|
275
|
-
// drops a `Schema.Class`'s `.fields`, so the substitution cannot introspect
|
|
276
|
-
// it and the target's own transformed fields keep their strict schemas —
|
|
277
|
-
// which is what made `update` reject a `bigint` at
|
|
278
|
-
// `["supplier"]["maker"]["founded"]` (#116). Registering the target by field
|
|
279
|
-
// name re-points it exactly as a top-level edge is re-pointed.
|
|
280
|
-
for (const nested of entity?._data?.resolvedRefs ?? []) {
|
|
281
|
-
const nestedModel = nested.refEntity?.model;
|
|
282
|
-
if (nestedModel && !edgeRefModels.has(nested.fieldName)) {
|
|
283
|
-
edgeRefModels.set(nested.fieldName, unwrapModel(nestedModel));
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
}
|
|
497
|
+
// such leaf at all (zero overhead).
|
|
287
498
|
const decodeSchema = substituteSchemaDeep(schema, {
|
|
288
499
|
tolerantTransforms: true,
|
|
289
|
-
resolveRef
|
|
500
|
+
resolveRef,
|
|
501
|
+
});
|
|
502
|
+
// The same schema for WRITES — `create` input and `update`'s mutated state —
|
|
503
|
+
// with the container `.check()` refinements the substitution rebuilds kept, so
|
|
504
|
+
// a write that breaks one is rejected (#133). Assembly keeps `decodeSchema`:
|
|
505
|
+
// a row written while those checks were not enforced must stay readable.
|
|
506
|
+
const writeSchema = substituteSchemaDeep(schema, {
|
|
507
|
+
tolerantTransforms: true,
|
|
508
|
+
resolveRef,
|
|
509
|
+
enforceChecks: true,
|
|
290
510
|
});
|
|
291
511
|
// Validate the collection index / consistency combination against the table's
|
|
292
512
|
// primary key. Best effort: a table registering only aggregates has no primary key
|
|
@@ -360,6 +580,19 @@ const makeAggregate = (schema, config) => {
|
|
|
360
580
|
// Build optics at construction time (once per aggregate definition)
|
|
361
581
|
const classToPlain = Schema.toIso(schema);
|
|
362
582
|
const opticRoot = Schema.toIsoFocus(schema);
|
|
583
|
+
// The same conversion through the READ schema. `toIso(schema)` re-checks the
|
|
584
|
+
// model's `.check()` refinements, so a stored row written while a container
|
|
585
|
+
// check was not enforced (#133) would make every `update` throw before the
|
|
586
|
+
// mutation could repair it. Reads accept such a row; so does this.
|
|
587
|
+
const readToPlain = Schema.toIso(decodeSchema);
|
|
588
|
+
const toPlainState = (value) => {
|
|
589
|
+
try {
|
|
590
|
+
return classToPlain.get(value);
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
return readToPlain.get(value);
|
|
594
|
+
}
|
|
595
|
+
};
|
|
363
596
|
// Composite key form — the SAME rule and the SAME function the entity path
|
|
364
597
|
// uses (`internal/CompositeCodec.ts`). Aggregates compose from assembled
|
|
365
598
|
// domain objects, so without this a `DateEpochMs` composite would key on its
|
|
@@ -371,7 +604,24 @@ const makeAggregate = (schema, config) => {
|
|
|
371
604
|
`${JSON.stringify(String(value))} resolves under neither encode nor decode->encode. ` +
|
|
372
605
|
`Supply a value of the attribute's own type.`);
|
|
373
606
|
});
|
|
374
|
-
|
|
607
|
+
// Only the attributes a key is ever composed from are put into key form. The
|
|
608
|
+
// rest of the record is never read off the result, and normalising it could
|
|
609
|
+
// only fail: a stored value that breaks a field's `.check()` (#133) made
|
|
610
|
+
// every `update` throw EDD-9050 over an attribute no key uses.
|
|
611
|
+
const compositeAttrs = new Set([
|
|
612
|
+
...config.pk.composite,
|
|
613
|
+
...(config.collection.sk?.composite ?? []),
|
|
614
|
+
...(config.list?.pk.composite ?? []),
|
|
615
|
+
...(config.list?.sk.composite ?? []),
|
|
616
|
+
]);
|
|
617
|
+
const keyRecord = (record) => {
|
|
618
|
+
const composites = {};
|
|
619
|
+
for (const attr of compositeAttrs) {
|
|
620
|
+
if (attr in record)
|
|
621
|
+
composites[attr] = record[attr];
|
|
622
|
+
}
|
|
623
|
+
return toCompositeKeyRecord(compositeKeyForm, composites);
|
|
624
|
+
};
|
|
375
625
|
/** Shared: compose PK and query all items */
|
|
376
626
|
const fetchPartition = (key) => Effect.gen(function* () {
|
|
377
627
|
const client = yield* DynamoClient;
|
|
@@ -423,7 +673,7 @@ const makeAggregate = (schema, config) => {
|
|
|
423
673
|
// 2. Validate via schema decode (decodeSchema substitutes nested edge
|
|
424
674
|
// self-date/Redacted leaves — Option A — and is identical to `schema`
|
|
425
675
|
// when no edge needs it).
|
|
426
|
-
const decoded = yield* Schema.decodeUnknownEffect(
|
|
676
|
+
const decoded = yield* Schema.decodeUnknownEffect(writeSchema)(hydrated).pipe(Effect.mapError((cause) => new ValidationError({
|
|
427
677
|
entityType: aggregateName,
|
|
428
678
|
operation: "aggregate.create",
|
|
429
679
|
cause,
|
|
@@ -433,7 +683,7 @@ const makeAggregate = (schema, config) => {
|
|
|
433
683
|
const composites = KeyComposer.extractComposites(config.pk.composite, keyRecord(assembled));
|
|
434
684
|
const pkValue = composeCollectionKey(config.schema, config.collection.name, composites);
|
|
435
685
|
// 4. Decompose into items grouped by sub-aggregate transaction boundaries
|
|
436
|
-
const groups = yield* decomposeAggregate(assembled, rootNode, contextFields,
|
|
686
|
+
const groups = yield* decomposeAggregate(assembled, rootNode, contextFields, rootEncoders, aggregateName, config.schema);
|
|
437
687
|
// 5. Compose collection SK composites for root item. Through the key
|
|
438
688
|
// form — `update` already did (see `newDynamo` / `oldDynamo` below), so
|
|
439
689
|
// leaving `create` raw made the two write paths mirror the collection SK
|
|
@@ -443,8 +693,25 @@ const makeAggregate = (schema, config) => {
|
|
|
443
693
|
// Every row is new, so `created` has nothing to carry forward.
|
|
444
694
|
const now = yield* DateTime.now;
|
|
445
695
|
const dynamoGroups = buildDynamoItems(groups, config, pkValue, collectionSkComposites, keyRecord, stampFor(now, EMPTY_CREATED));
|
|
446
|
-
// 7. Write via sub-aggregate transactions
|
|
447
|
-
|
|
696
|
+
// 7. Write via sub-aggregate transactions. The root item goes first in
|
|
697
|
+
// the first transaction, guarded by `attribute_not_exists` (#134):
|
|
698
|
+
// an existing aggregate cancels it before any edge or sub-aggregate
|
|
699
|
+
// row is written. Rows below the root need no guard of their own —
|
|
700
|
+
// they are only ever written after the root's transaction commits.
|
|
701
|
+
const rootSk = composeKey(config.schema, config.root.entityType, []);
|
|
702
|
+
const isRoot = (item) => item.sk?.S === rootSk;
|
|
703
|
+
const ordered = [...dynamoGroups]
|
|
704
|
+
.sort((a, b) => Number(b.items.some(isRoot)) - Number(a.items.some(isRoot)))
|
|
705
|
+
.map((g) => ({
|
|
706
|
+
group: g.group,
|
|
707
|
+
puts: [...g.items].sort((a, b) => Number(isRoot(b)) - Number(isRoot(a))),
|
|
708
|
+
deletes: [],
|
|
709
|
+
}));
|
|
710
|
+
yield* writeTransactionGroups(client, tableConfig.name, ordered, aggregateName, {
|
|
711
|
+
pkField: config.pk.field,
|
|
712
|
+
entityType: config.root.entityType,
|
|
713
|
+
key: { [config.pk.field]: pkValue, sk: rootSk },
|
|
714
|
+
});
|
|
448
715
|
return decoded;
|
|
449
716
|
}),
|
|
450
717
|
update: (key, mutationFn) => Effect.gen(function* () {
|
|
@@ -459,7 +726,7 @@ const makeAggregate = (schema, config) => {
|
|
|
459
726
|
// 1. Assemble current state
|
|
460
727
|
const current = yield* assembleAggregate(decodeSchema, rootNode, allItems, contextFields, key, aggregateName, systemAttrs);
|
|
461
728
|
// 2. Apply mutation — provide optics context for composable updates
|
|
462
|
-
const state =
|
|
729
|
+
const state = toPlainState(current);
|
|
463
730
|
const updated = mutationFn({
|
|
464
731
|
state,
|
|
465
732
|
cursor: makeCursor(state, opticRoot),
|
|
@@ -471,7 +738,7 @@ const makeAggregate = (schema, config) => {
|
|
|
471
738
|
// for every date field (root + nested edges, Pattern A and Pattern B),
|
|
472
739
|
// so update no longer trips the "Expected string, got DateTime" decode
|
|
473
740
|
// that a strict `*FromString` schema would raise (#72 update path).
|
|
474
|
-
const decoded = yield* Schema.decodeUnknownEffect(
|
|
741
|
+
const decoded = yield* Schema.decodeUnknownEffect(writeSchema)(updated).pipe(Effect.mapError((cause) => new ValidationError({
|
|
475
742
|
entityType: aggregateName,
|
|
476
743
|
operation: "aggregate.update",
|
|
477
744
|
cause,
|
|
@@ -479,8 +746,8 @@ const makeAggregate = (schema, config) => {
|
|
|
479
746
|
const assembledOld = { ...current };
|
|
480
747
|
const assembledNew = { ...decoded };
|
|
481
748
|
// 4. Decompose both old and new
|
|
482
|
-
const oldGroups = yield* decomposeAggregate(assembledOld, rootNode, contextFields,
|
|
483
|
-
const newGroups = yield* decomposeAggregate(assembledNew, rootNode, contextFields,
|
|
749
|
+
const oldGroups = yield* decomposeAggregate(assembledOld, rootNode, contextFields, rootEncoders, aggregateName, config.schema);
|
|
750
|
+
const newGroups = yield* decomposeAggregate(assembledNew, rootNode, contextFields, rootEncoders, aggregateName, config.schema);
|
|
484
751
|
// 5. Diff at the ITEM (primary-key) level, not just the group level.
|
|
485
752
|
// A `many`-edge element — and a cleared `one` edge — lives in its PARENT's
|
|
486
753
|
// transaction group, so removing it SHRINKS a group rather than dropping a
|
|
@@ -576,7 +843,7 @@ const makeAggregate = (schema, config) => {
|
|
|
576
843
|
// Root-item FilterExpression. Compiled once — the same two forms
|
|
577
844
|
// `BoundQuery.filter()` takes, through the same `Expr` compiler, so
|
|
578
845
|
// there is one filter vocabulary rather than a second dialect here.
|
|
579
|
-
const rootFilter = compileListFilter(options?.filter);
|
|
846
|
+
const rootFilter = yield* compileListFilter(options?.filter, aggregateName);
|
|
580
847
|
// Attribute names that make up a resume key on the list index: the index
|
|
581
848
|
// key plus the table key. A `LastEvaluatedKey` supplies them when there
|
|
582
849
|
// is one; this is the fallback for the case a filtered request over-reads
|
|
@@ -754,7 +1021,7 @@ const deepEqualGroups = (a, b) => {
|
|
|
754
1021
|
});
|
|
755
1022
|
};
|
|
756
1023
|
const resolveNode = (args) => {
|
|
757
|
-
const { assemble, cardinality, attrEncoders, decompose, discriminator, edges, entityType, fieldName, ownDiscriminator, refIdentifierField, skComposite, } = args;
|
|
1024
|
+
const { assemble, cardinality, attrEncoders, decompose, discriminator, edges, entityType, fieldName, keyAttrEncoders, ownDiscriminator, parentFields, refIdentifierField, resolveRef, skComposite, } = args;
|
|
758
1025
|
const children = [];
|
|
759
1026
|
for (const [field, edge] of Object.entries(edges)) {
|
|
760
1027
|
if ("_tag" in edge) {
|
|
@@ -763,6 +1030,14 @@ const resolveNode = (args) => {
|
|
|
763
1030
|
const mergedDisc = edge.discriminator
|
|
764
1031
|
? { ...(discriminator ?? {}), ...edge.discriminator }
|
|
765
1032
|
: discriminator;
|
|
1033
|
+
// The item is the edge value flattened: encode it field by field with
|
|
1034
|
+
// the edge entity's model (#72), or — for an edge without an entity —
|
|
1035
|
+
// with the model field's own class.
|
|
1036
|
+
const parentField = parentFields?.[field];
|
|
1037
|
+
const valueFields = fieldsOf(edge.entity?.model) ??
|
|
1038
|
+
(parentField === undefined
|
|
1039
|
+
? undefined
|
|
1040
|
+
: getSchemaFields(unwrapOptionalField(parentField)));
|
|
766
1041
|
children.push(resolveNode({
|
|
767
1042
|
fieldName: field,
|
|
768
1043
|
entityType: edge.entityType,
|
|
@@ -770,11 +1045,19 @@ const resolveNode = (args) => {
|
|
|
770
1045
|
edges: {},
|
|
771
1046
|
discriminator: mergedDisc,
|
|
772
1047
|
ownDiscriminator: edge.discriminator,
|
|
773
|
-
|
|
774
|
-
attrEncoders: buildAttrEncoders(fieldsOf(edge.entity?.model)),
|
|
1048
|
+
attrEncoders: buildAttrEncoders(valueFields, resolveRef),
|
|
775
1049
|
}));
|
|
776
1050
|
}
|
|
777
1051
|
else if (edge._tag === "ManyEdge") {
|
|
1052
|
+
// The item is one decomposed ELEMENT. Its encoders must come from the
|
|
1053
|
+
// element's schema — `PlayerSheet { player, isCaptain }` — not from the
|
|
1054
|
+
// edge entity's model (`Player { id, name, dateOfBirth }`), whose field
|
|
1055
|
+
// names never match a wrapper element's, so a hydrated ref inside the
|
|
1056
|
+
// element was never encoded (#133). An element that exposes no fields
|
|
1057
|
+
// (a custom `decompose`, an opaque ref element) falls back to the
|
|
1058
|
+
// entity's model, which is what such an element IS.
|
|
1059
|
+
const entityFields = fieldsOf(edge.entity?.model);
|
|
1060
|
+
const elementFields = manyElementFields(parentFields, field) ?? entityFields;
|
|
778
1061
|
children.push(resolveNode({
|
|
779
1062
|
fieldName: field,
|
|
780
1063
|
entityType: edge.entityType,
|
|
@@ -783,7 +1066,10 @@ const resolveNode = (args) => {
|
|
|
783
1066
|
discriminator,
|
|
784
1067
|
assemble: edge.assemble,
|
|
785
1068
|
decompose: edge.decompose,
|
|
786
|
-
attrEncoders: buildAttrEncoders(
|
|
1069
|
+
attrEncoders: buildAttrEncoders(elementFields, resolveRef),
|
|
1070
|
+
// Sort-key composites keep reading the pre-#133 encoding (the entity
|
|
1071
|
+
// model's top-level transforms), so no stored key is re-spelled.
|
|
1072
|
+
keyAttrEncoders: buildKeyAttrEncoders(entityFields),
|
|
787
1073
|
// Declared sort-key composites, authoritative when present (#103).
|
|
788
1074
|
skComposite: edge.sk?.composite,
|
|
789
1075
|
refIdentifierField: edge.entity
|
|
@@ -793,16 +1079,28 @@ const resolveNode = (args) => {
|
|
|
793
1079
|
}
|
|
794
1080
|
else if (edge._tag === "BoundSubAggregate") {
|
|
795
1081
|
const bound = edge;
|
|
1082
|
+
const subFields = fieldsOf(bound.aggregate.schema);
|
|
1083
|
+
// A sub-aggregate nested in another inherits its parent's discriminator,
|
|
1084
|
+
// exactly as a `one` edge does: its rows carry the parent's attributes
|
|
1085
|
+
// (`clubNo`) as well as their own (`squadNo`), so assembly — which
|
|
1086
|
+
// matches on the merged set — can tell the two bindings of the parent
|
|
1087
|
+
// apart. At the top level there is no parent discriminator and this is
|
|
1088
|
+
// `bound.discriminator`, as before.
|
|
1089
|
+
const inherited = discriminator === undefined
|
|
1090
|
+
? bound.discriminator
|
|
1091
|
+
: { ...discriminator, ...bound.discriminator };
|
|
796
1092
|
const subChildren = resolveNode({
|
|
797
1093
|
fieldName: field,
|
|
798
1094
|
entityType: bound.aggregate.root.entityType,
|
|
799
1095
|
cardinality: "one",
|
|
800
1096
|
edges: bound.aggregate.edges,
|
|
801
|
-
|
|
1097
|
+
parentFields: subFields,
|
|
1098
|
+
resolveRef,
|
|
1099
|
+
discriminator: inherited,
|
|
802
1100
|
ownDiscriminator: bound.discriminator,
|
|
803
1101
|
// The sub-aggregate root item carries the sub-schema's own (non-edge)
|
|
804
|
-
//
|
|
805
|
-
attrEncoders: buildAttrEncoders(
|
|
1102
|
+
// fields; its child edges get their own encoders via recursion.
|
|
1103
|
+
attrEncoders: buildAttrEncoders(subFields, resolveRef),
|
|
806
1104
|
});
|
|
807
1105
|
children.push(subChildren);
|
|
808
1106
|
}
|
|
@@ -821,6 +1119,7 @@ const resolveNode = (args) => {
|
|
|
821
1119
|
...(skComposite !== undefined && { skComposite }),
|
|
822
1120
|
...(refIdentifierField !== undefined && { refIdentifierField }),
|
|
823
1121
|
attrEncoders,
|
|
1122
|
+
...(keyAttrEncoders !== undefined && { keyAttrEncoders }),
|
|
824
1123
|
};
|
|
825
1124
|
};
|
|
826
1125
|
// ---------------------------------------------------------------------------
|
|
@@ -1203,12 +1502,62 @@ const collectRefIdsFromEdges = (result, input, edges, path) => {
|
|
|
1203
1502
|
}
|
|
1204
1503
|
}
|
|
1205
1504
|
};
|
|
1505
|
+
/**
|
|
1506
|
+
* Deep-copy a create input so ref replacement can mutate it — like
|
|
1507
|
+
* `structuredClone`, which this replaces, except that DOMAIN VALUES survive.
|
|
1508
|
+
*
|
|
1509
|
+
* `structuredClone` copies only own enumerable properties, so it reduced every
|
|
1510
|
+
* Effect data type to a bare object: a `DateTime` became `{ epochMilliseconds }`
|
|
1511
|
+
* with no prototype or tag, a `Redacted` became `{}`, an `Option` lost its
|
|
1512
|
+
* variant. No schema accepts those, so any aggregate with a ref edge rejected
|
|
1513
|
+
* such a value in its create input (#133).
|
|
1514
|
+
*
|
|
1515
|
+
* - Values implementing `Equal` — `DateTime`, `Redacted`, `Option`, `Duration`,
|
|
1516
|
+
* `Chunk`, `HashMap`, … — are immutable, so they are kept by reference.
|
|
1517
|
+
* - Built-ins `structuredClone` knows (`Date`, `Map`, `Set`, `RegExp`, binary
|
|
1518
|
+
* data) still go through it.
|
|
1519
|
+
* - Plain objects, arrays and other class instances (a `Schema.Class` value
|
|
1520
|
+
* among them) are walked and copied to plain objects, as `structuredClone`
|
|
1521
|
+
* copied them — so ref replacement never mutates the caller's objects.
|
|
1522
|
+
* - Cycles are preserved, as `structuredClone` preserved them.
|
|
1523
|
+
*/
|
|
1524
|
+
const cloneInput = (value, seen = new WeakMap()) => {
|
|
1525
|
+
if (value === null || typeof value !== "object")
|
|
1526
|
+
return value;
|
|
1527
|
+
if (Equal.isEqual(value))
|
|
1528
|
+
return value;
|
|
1529
|
+
const prior = seen.get(value);
|
|
1530
|
+
if (prior !== undefined)
|
|
1531
|
+
return prior;
|
|
1532
|
+
if (Array.isArray(value)) {
|
|
1533
|
+
const copy = [];
|
|
1534
|
+
seen.set(value, copy);
|
|
1535
|
+
for (const entry of value)
|
|
1536
|
+
copy.push(cloneInput(entry, seen));
|
|
1537
|
+
return copy;
|
|
1538
|
+
}
|
|
1539
|
+
if (value instanceof Date ||
|
|
1540
|
+
value instanceof Map ||
|
|
1541
|
+
value instanceof Set ||
|
|
1542
|
+
value instanceof RegExp ||
|
|
1543
|
+
ArrayBuffer.isView(value) ||
|
|
1544
|
+
value instanceof ArrayBuffer) {
|
|
1545
|
+
const copy = structuredClone(value);
|
|
1546
|
+
seen.set(value, copy);
|
|
1547
|
+
return copy;
|
|
1548
|
+
}
|
|
1549
|
+
const copy = {};
|
|
1550
|
+
seen.set(value, copy);
|
|
1551
|
+
for (const [key, entry] of Object.entries(value))
|
|
1552
|
+
copy[key] = cloneInput(entry, seen);
|
|
1553
|
+
return copy;
|
|
1554
|
+
};
|
|
1206
1555
|
/**
|
|
1207
1556
|
* Replace ref ID fields in the input with hydrated domain data.
|
|
1208
1557
|
*/
|
|
1209
1558
|
const replaceRefIds = (input, refRequests, lookup) => {
|
|
1210
1559
|
// Deep clone the input for mutation
|
|
1211
|
-
const result =
|
|
1560
|
+
const result = cloneInput(input);
|
|
1212
1561
|
for (const req of refRequests) {
|
|
1213
1562
|
const lookupKey = `${req.entity.entityType}:${req.id}`;
|
|
1214
1563
|
const data = lookup.get(lookupKey);
|
|
@@ -1239,31 +1588,35 @@ const replaceRefIds = (input, refRequests, lookup) => {
|
|
|
1239
1588
|
* Decompose an assembled domain object into DynamoDB items grouped by
|
|
1240
1589
|
* sub-aggregate transaction boundaries.
|
|
1241
1590
|
*/
|
|
1242
|
-
const decomposeAggregate = (assembled, rootNode, contextFields,
|
|
1591
|
+
const decomposeAggregate = (assembled, rootNode, contextFields, encoders, aggregateName, schema) => Effect.gen(function* () {
|
|
1243
1592
|
const items = [];
|
|
1244
|
-
|
|
1593
|
+
const encodeWith = (set, field, value) => {
|
|
1594
|
+
const encode = set[field];
|
|
1595
|
+
return encode && value != null ? encode(value) : value;
|
|
1596
|
+
};
|
|
1597
|
+
// Extract context values from the root, encoded for DynamoDB storage.
|
|
1245
1598
|
// Domain Date/DateTime objects must be encoded before toAttributeMap() marshalling,
|
|
1246
1599
|
// otherwise Date objects become { M: {} } (no enumerable properties).
|
|
1247
|
-
|
|
1248
|
-
const contextValues = {};
|
|
1600
|
+
const contextValues = { stored: {}, key: {} };
|
|
1249
1601
|
for (const field of contextFields) {
|
|
1250
1602
|
const value = assembled[field];
|
|
1251
|
-
|
|
1252
|
-
contextValues[field] =
|
|
1603
|
+
contextValues.stored[field] = encodeWith(encoders.stored, field, value);
|
|
1604
|
+
contextValues.key[field] = encodeWith(encoders.key, field, value);
|
|
1253
1605
|
}
|
|
1254
1606
|
// Root item: fields not claimed by edges
|
|
1255
1607
|
const edgeFieldNames = new Set(rootNode.children.map((c) => c.fieldName).filter(Boolean));
|
|
1256
1608
|
const rootAttrs = {};
|
|
1609
|
+
const rootKeyAttrs = {};
|
|
1257
1610
|
for (const [key, value] of Object.entries(assembled)) {
|
|
1258
1611
|
if (edgeFieldNames.has(key))
|
|
1259
1612
|
continue;
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
rootAttrs[key] = encode && value != null ? encode(value) : value;
|
|
1613
|
+
rootAttrs[key] = encodeWith(encoders.stored, key, value);
|
|
1614
|
+
rootKeyAttrs[key] = encodeWith(encoders.key, key, value);
|
|
1263
1615
|
}
|
|
1264
1616
|
items.push({
|
|
1265
1617
|
entityType: rootNode.entityType,
|
|
1266
1618
|
attributes: rootAttrs,
|
|
1619
|
+
keyAttributes: rootKeyAttrs,
|
|
1267
1620
|
transactionGroup: "root",
|
|
1268
1621
|
skComposites: [],
|
|
1269
1622
|
member: rootNode.entityType,
|
|
@@ -1326,13 +1679,21 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
|
|
|
1326
1679
|
if (node.cardinality === "one" && node.children.length > 0) {
|
|
1327
1680
|
// Sub-aggregate — becomes its own transaction group
|
|
1328
1681
|
const subValue = value;
|
|
1329
|
-
|
|
1330
|
-
//
|
|
1331
|
-
const
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1682
|
+
// A nested sub-aggregate is its own transaction group, named by its path
|
|
1683
|
+
// (`club2.squad`) so the two bindings of its parent stay distinct groups.
|
|
1684
|
+
const ownName = node.fieldName ?? node.entityType;
|
|
1685
|
+
const txGroup = parentGroup === "root" ? ownName : `${parentGroup}.${ownName}`;
|
|
1686
|
+
// Discriminator values for SK composition: the parent sub-aggregate's
|
|
1687
|
+
// (name#value pairs, empty at the top level) then this node's own, so a
|
|
1688
|
+
// nested sub-aggregate's rows are keyed apart per binding of its parent.
|
|
1689
|
+
const discValues = [
|
|
1690
|
+
...parentDiscriminatorValues,
|
|
1691
|
+
...(node.ownDiscriminator
|
|
1692
|
+
? Object.entries(node.ownDiscriminator)
|
|
1693
|
+
.filter(([, v]) => typeof v !== "function")
|
|
1694
|
+
.flatMap(([k, v]) => [k, KeyComposer.serializeValue(v)])
|
|
1695
|
+
: []),
|
|
1696
|
+
];
|
|
1336
1697
|
// Sub-aggregate root item: fields not claimed by child edges
|
|
1337
1698
|
const childEdgeNames = new Set(node.children.map((c) => c.fieldName).filter(Boolean));
|
|
1338
1699
|
const subRootAttrs = {};
|
|
@@ -1344,7 +1705,7 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
|
|
|
1344
1705
|
// Serialize this node's own date fields to their wire primitive (#72).
|
|
1345
1706
|
applyNodeAttrEncoders(subRootAttrs, node.attrEncoders);
|
|
1346
1707
|
// Inject context and discriminator
|
|
1347
|
-
mergeContextValues(subRootAttrs, contextValues);
|
|
1708
|
+
mergeContextValues(subRootAttrs, contextValues.stored);
|
|
1348
1709
|
if (node.discriminator) {
|
|
1349
1710
|
for (const [k, v] of Object.entries(node.discriminator)) {
|
|
1350
1711
|
if (typeof v !== "function")
|
|
@@ -1369,7 +1730,7 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
|
|
|
1369
1730
|
const attrs = { ...value };
|
|
1370
1731
|
// Serialize this edge entity's own date fields to their wire primitive (#72).
|
|
1371
1732
|
applyNodeAttrEncoders(attrs, node.attrEncoders);
|
|
1372
|
-
mergeContextValues(attrs, contextValues);
|
|
1733
|
+
mergeContextValues(attrs, contextValues.stored);
|
|
1373
1734
|
if (node.discriminator) {
|
|
1374
1735
|
for (const [k, v] of Object.entries(node.discriminator)) {
|
|
1375
1736
|
if (typeof v !== "function")
|
|
@@ -1397,18 +1758,30 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
|
|
|
1397
1758
|
: Array.isArray(value)
|
|
1398
1759
|
? value
|
|
1399
1760
|
: [];
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
// Serialize this edge entity's own date fields to their wire primitive (#72).
|
|
1403
|
-
applyNodeAttrEncoders(attrs, node.attrEncoders);
|
|
1404
|
-
mergeContextValues(attrs, contextValues);
|
|
1761
|
+
const withContext = (attrs, context) => {
|
|
1762
|
+
mergeContextValues(attrs, context);
|
|
1405
1763
|
if (node.discriminator) {
|
|
1406
1764
|
for (const [k, v] of Object.entries(node.discriminator)) {
|
|
1407
1765
|
if (typeof v !== "function")
|
|
1408
1766
|
attrs[k] = v;
|
|
1409
1767
|
}
|
|
1410
1768
|
}
|
|
1411
|
-
|
|
1769
|
+
return attrs;
|
|
1770
|
+
};
|
|
1771
|
+
for (const elem of arrayItems) {
|
|
1772
|
+
// The element's attributes in their STORED form — every nested date,
|
|
1773
|
+
// transformed scalar and hydrated ref encoded (#72, #133).
|
|
1774
|
+
const attrs = { ...elem };
|
|
1775
|
+
applyNodeAttrEncoders(attrs, node.attrEncoders);
|
|
1776
|
+
withContext(attrs, contextValues.stored);
|
|
1777
|
+
// The sort key is read from the element as the pre-#133 encoders left
|
|
1778
|
+
// it, NOT from `attrs`: encoding a ref's nested `NumberFromString` turns
|
|
1779
|
+
// a padded `0000000000000005` key segment into `5`, so composing from
|
|
1780
|
+
// the stored form would re-spell (and orphan) existing rows.
|
|
1781
|
+
const keyAttrs = { ...elem };
|
|
1782
|
+
applyNodeAttrEncoders(keyAttrs, node.keyAttrEncoders);
|
|
1783
|
+
withContext(keyAttrs, contextValues.key);
|
|
1784
|
+
const itemComposites = yield* manyEdgeSkComposites(node, keyAttrs, aggregateName);
|
|
1412
1785
|
items.push({
|
|
1413
1786
|
entityType: node.entityType,
|
|
1414
1787
|
attributes: attrs,
|
|
@@ -1593,7 +1966,7 @@ keyRecord, stamp) => groups.map((group) => ({
|
|
|
1593
1966
|
}
|
|
1594
1967
|
// List collection GSI keys (root items only)
|
|
1595
1968
|
if (isRootItem && config.list) {
|
|
1596
|
-
const listPkComposites = KeyComposer.extractComposites(config.list.pk.composite, keyRecord(item.attributes));
|
|
1969
|
+
const listPkComposites = KeyComposer.extractComposites(config.list.pk.composite, keyRecord(item.keyAttributes ?? item.attributes));
|
|
1597
1970
|
if (config.list.cardinality) {
|
|
1598
1971
|
const shard = hashToShard(pkValue, config.list.cardinality);
|
|
1599
1972
|
attrs[config.list.pk.field] = composeCollectionKey(config.schema, config.list.name, [
|
|
@@ -1604,7 +1977,7 @@ keyRecord, stamp) => groups.map((group) => ({
|
|
|
1604
1977
|
else {
|
|
1605
1978
|
attrs[config.list.pk.field] = composeCollectionKey(config.schema, config.list.name, listPkComposites);
|
|
1606
1979
|
}
|
|
1607
|
-
const listSkComposites = KeyComposer.extractComposites(config.list.sk.composite, keyRecord(item.attributes));
|
|
1980
|
+
const listSkComposites = KeyComposer.extractComposites(config.list.sk.composite, keyRecord(item.keyAttributes ?? item.attributes));
|
|
1608
1981
|
attrs[config.list.sk.field] = composeCollectionKey(config.schema, config.list.name, listSkComposites);
|
|
1609
1982
|
}
|
|
1610
1983
|
// System timestamps — last, so a generated value wins over the same-named
|
|
@@ -1623,56 +1996,78 @@ keyRecord, stamp) => groups.map((group) => ({
|
|
|
1623
1996
|
return toAttributeMap(attrs);
|
|
1624
1997
|
}),
|
|
1625
1998
|
}));
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
//
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
itemCount,
|
|
1642
|
-
limit: 100,
|
|
1643
|
-
});
|
|
1644
|
-
}
|
|
1645
|
-
}
|
|
1646
|
-
for (const group of groups) {
|
|
1647
|
-
// Each group is one transaction — Puts AND Deletes applied atomically, so an
|
|
1648
|
-
// edge add and a sibling edge removal in the same sub-aggregate commit together.
|
|
1649
|
-
const itemCount = group.puts.length + group.deletes.length;
|
|
1650
|
-
if (itemCount === 0)
|
|
1651
|
-
continue;
|
|
1652
|
-
// Build TransactWriteItems request — Puts first, then Deletes for orphans.
|
|
1653
|
-
const transactItems = [
|
|
1654
|
-
...group.puts.map((item) => ({ Put: { TableName: tableName, Item: item } })),
|
|
1655
|
-
...group.deletes.map((key) => ({ Delete: { TableName: tableName, Key: key } })),
|
|
1656
|
-
];
|
|
1657
|
-
yield* client.transactWriteItems({ TransactItems: transactItems }).pipe(Effect.mapError((error) => {
|
|
1658
|
-
if (error.cause != null &&
|
|
1659
|
-
typeof error.cause === "object" &&
|
|
1660
|
-
"name" in error.cause &&
|
|
1661
|
-
error.cause.name === "TransactionCanceledException") {
|
|
1662
|
-
const cancelled = error.cause;
|
|
1663
|
-
return new TransactionCancelled({
|
|
1664
|
-
operation: "TransactWriteItems",
|
|
1665
|
-
reasons: (cancelled.CancellationReasons ?? []).map((r) => ({
|
|
1666
|
-
code: r?.Code,
|
|
1667
|
-
message: r?.Message,
|
|
1668
|
-
})),
|
|
1669
|
-
cause: error.cause,
|
|
1999
|
+
function writeTransactionGroups(client, tableName, groups, aggregateName, rootGuard) {
|
|
2000
|
+
return Effect.gen(function* () {
|
|
2001
|
+
// Validate ALL group sizes BEFORE writing any group. Each group is its own
|
|
2002
|
+
// transaction (DynamoDB: 100 items per TransactWriteItems), and the per-group
|
|
2003
|
+
// count now spans Puts AND Deletes together — so an `update` that both adds and
|
|
2004
|
+
// removes many edges roughly doubles the count. Checking up front means an
|
|
2005
|
+
// oversized later group fails fast instead of partially committing earlier ones.
|
|
2006
|
+
for (const group of groups) {
|
|
2007
|
+
const itemCount = group.puts.length + group.deletes.length;
|
|
2008
|
+
if (itemCount > 100) {
|
|
2009
|
+
return yield* new AggregateTransactionOverflow({
|
|
2010
|
+
aggregate: aggregateName,
|
|
2011
|
+
subgraph: group.group,
|
|
2012
|
+
itemCount,
|
|
2013
|
+
limit: 100,
|
|
1670
2014
|
});
|
|
1671
2015
|
}
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
2016
|
+
}
|
|
2017
|
+
for (const [groupIndex, group] of groups.entries()) {
|
|
2018
|
+
// Each group is one transaction — Puts AND Deletes applied atomically, so an
|
|
2019
|
+
// edge add and a sibling edge removal in the same sub-aggregate commit together.
|
|
2020
|
+
const itemCount = group.puts.length + group.deletes.length;
|
|
2021
|
+
if (itemCount === 0)
|
|
2022
|
+
continue;
|
|
2023
|
+
// The root item, in the first transaction only.
|
|
2024
|
+
const rootIndex = rootGuard !== undefined && groupIndex === 0
|
|
2025
|
+
? group.puts.findIndex((item) => item.sk?.S === rootGuard.key.sk)
|
|
2026
|
+
: -1;
|
|
2027
|
+
// Build TransactWriteItems request — Puts first, then Deletes for orphans.
|
|
2028
|
+
const transactItems = [
|
|
2029
|
+
...group.puts.map((item, i) => ({
|
|
2030
|
+
Put: {
|
|
2031
|
+
TableName: tableName,
|
|
2032
|
+
Item: item,
|
|
2033
|
+
...(rootGuard !== undefined && i === rootIndex
|
|
2034
|
+
? {
|
|
2035
|
+
ConditionExpression: "attribute_not_exists(#pk)",
|
|
2036
|
+
ExpressionAttributeNames: { "#pk": rootGuard.pkField },
|
|
2037
|
+
}
|
|
2038
|
+
: {}),
|
|
2039
|
+
},
|
|
2040
|
+
})),
|
|
2041
|
+
...group.deletes.map((key) => ({ Delete: { TableName: tableName, Key: key } })),
|
|
2042
|
+
];
|
|
2043
|
+
yield* client.transactWriteItems({ TransactItems: transactItems }).pipe(Effect.mapError((error) => {
|
|
2044
|
+
if (error.cause != null &&
|
|
2045
|
+
typeof error.cause === "object" &&
|
|
2046
|
+
"name" in error.cause &&
|
|
2047
|
+
error.cause.name === "TransactionCanceledException") {
|
|
2048
|
+
const cancelled = error.cause;
|
|
2049
|
+
if (rootGuard !== undefined &&
|
|
2050
|
+
rootIndex >= 0 &&
|
|
2051
|
+
cancelled.CancellationReasons?.[rootIndex]?.Code === "ConditionalCheckFailed") {
|
|
2052
|
+
return new ConditionalCheckFailed({
|
|
2053
|
+
entityType: rootGuard.entityType,
|
|
2054
|
+
key: rootGuard.key,
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
return new TransactionCancelled({
|
|
2058
|
+
operation: "TransactWriteItems",
|
|
2059
|
+
reasons: (cancelled.CancellationReasons ?? []).map((r) => ({
|
|
2060
|
+
code: r?.Code,
|
|
2061
|
+
message: r?.Message,
|
|
2062
|
+
})),
|
|
2063
|
+
cause: error.cause,
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
2066
|
+
return error;
|
|
2067
|
+
}));
|
|
2068
|
+
}
|
|
2069
|
+
});
|
|
2070
|
+
}
|
|
1676
2071
|
// ---------------------------------------------------------------------------
|
|
1677
2072
|
// Internal: Delete all items in an aggregate partition
|
|
1678
2073
|
// ---------------------------------------------------------------------------
|
|
@@ -1686,14 +2081,35 @@ const deleteAllItems = (client, tableName, allItems, pkField) => Effect.gen(func
|
|
|
1686
2081
|
}),
|
|
1687
2082
|
},
|
|
1688
2083
|
}));
|
|
1689
|
-
// Chunk into batches of 25 (DynamoDB batchWriteItem limit)
|
|
2084
|
+
// Chunk into batches of 25 (DynamoDB batchWriteItem limit). Items DynamoDB
|
|
2085
|
+
// leaves unprocessed (throttling) are retried with exponential backoff, as
|
|
2086
|
+
// `Batch.write` retries them; a bounded number of retries, then a clear
|
|
2087
|
+
// failure — never a silent partial delete (#134).
|
|
1690
2088
|
for (let i = 0; i < deleteRequests.length; i += 25) {
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
2089
|
+
let pending = deleteRequests.slice(i, i + 25);
|
|
2090
|
+
let retries = 0;
|
|
2091
|
+
while (pending.length > 0) {
|
|
2092
|
+
const response = yield* client.batchWriteItem({
|
|
2093
|
+
RequestItems: { [tableName]: pending },
|
|
2094
|
+
});
|
|
2095
|
+
pending = (response.UnprocessedItems?.[tableName] ?? []);
|
|
2096
|
+
if (pending.length === 0)
|
|
2097
|
+
break;
|
|
2098
|
+
retries++;
|
|
2099
|
+
if (retries > DELETE_MAX_RETRIES) {
|
|
2100
|
+
return yield* new DynamoError({
|
|
2101
|
+
operation: "BatchWriteItem",
|
|
2102
|
+
cause: new Error(`Aggregate delete: ${pending.length} unprocessed item(s) remain after ` +
|
|
2103
|
+
`${DELETE_MAX_RETRIES} retries; the partition is partly deleted.`),
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
yield* Effect.sleep(`${DELETE_BASE_DELAY_MS * 2 ** (retries - 1)} millis`);
|
|
2107
|
+
}
|
|
1695
2108
|
}
|
|
1696
2109
|
});
|
|
2110
|
+
/** `Batch.write`'s bound and backoff, for `deleteAllItems`' unprocessed items. */
|
|
2111
|
+
const DELETE_MAX_RETRIES = 5;
|
|
2112
|
+
const DELETE_BASE_DELAY_MS = 100;
|
|
1697
2113
|
// ---------------------------------------------------------------------------
|
|
1698
2114
|
// Internal: list filter compilation
|
|
1699
2115
|
// ---------------------------------------------------------------------------
|
|
@@ -1702,18 +2118,27 @@ const deleteAllItems = (client, tableName, allItems, pkField) => Effect.gen(func
|
|
|
1702
2118
|
*
|
|
1703
2119
|
* Both forms route through the same `Expr` compiler `BoundQuery.filter()` uses,
|
|
1704
2120
|
* so `{ status: "shipped" }` and `(t, { eq }) => eq(t.status, "shipped")` mean
|
|
1705
|
-
* the same thing here as they do on an entity query
|
|
1706
|
-
*
|
|
1707
|
-
*
|
|
2121
|
+
* the same thing here as they do on an entity query, under the same empty-part
|
|
2122
|
+
* rules (#134): a filter that asserts nothing (`{}`, `and()`) is no filter, an
|
|
2123
|
+
* empty part directly under `and` is left out, and one anywhere else — or an
|
|
2124
|
+
* `or()` with no parts, or an `isIn` with no values — is refused with a
|
|
2125
|
+
* `ValidationError` before anything is sent.
|
|
1708
2126
|
*/
|
|
1709
|
-
const compileListFilter = (filter) => {
|
|
2127
|
+
const compileListFilter = (filter, aggregateName) => {
|
|
1710
2128
|
if (filter === undefined)
|
|
1711
|
-
return undefined;
|
|
2129
|
+
return Effect.succeed(undefined);
|
|
1712
2130
|
const expr = typeof filter === "function"
|
|
1713
2131
|
? filter(createPathBuilder(), createConditionOps())
|
|
1714
2132
|
: parseSimpleShorthand(filter);
|
|
1715
|
-
const
|
|
1716
|
-
|
|
2133
|
+
const problem = emptyPartProblem(expr);
|
|
2134
|
+
if (problem !== undefined) {
|
|
2135
|
+
return Effect.fail(new ValidationError({
|
|
2136
|
+
entityType: aggregateName,
|
|
2137
|
+
operation: "list.filter",
|
|
2138
|
+
cause: `list filter: ${problem} Nothing was sent.`,
|
|
2139
|
+
}));
|
|
2140
|
+
}
|
|
2141
|
+
return Effect.succeed(isEmptyExpr(expr) ? undefined : compileExpr(expr));
|
|
1717
2142
|
};
|
|
1718
2143
|
/**
|
|
1719
2144
|
* @internal DynamoDB `Limit` for the next request — the same rule `Query.ts`
|