effect-dynamodb 1.9.2 → 1.9.3
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 +3 -2
- package/dist/Aggregate.d.ts.map +1 -1
- package/dist/Aggregate.js +8 -1
- package/dist/Aggregate.js.map +1 -1
- package/dist/DynamoClient.d.ts +17 -1
- package/dist/DynamoClient.d.ts.map +1 -1
- package/dist/DynamoClient.js +12 -2
- package/dist/DynamoClient.js.map +1 -1
- package/dist/Entity.d.ts +24 -7
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +84 -455
- package/dist/Entity.js.map +1 -1
- package/package.json +2 -2
package/dist/Entity.js
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
* carries derived schemas for 7 type extractors (Model, Record, Input, Update,
|
|
7
7
|
* Key, Item, Marshalled).
|
|
8
8
|
*/
|
|
9
|
-
import { getIdentifierField, getSparseFields,
|
|
9
|
+
import { getIdentifierField, getSparseFields, isRefField, } from "@effect-dynamodb/schema/DynamoModel.js";
|
|
10
10
|
import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
|
|
11
|
-
import { CascadePartialFailure, ConditionalCheckFailed, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled,
|
|
11
|
+
import { CascadePartialFailure, ConditionalCheckFailed, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled, OptimisticLockError, RefNotFound, StaleAppend, TransactionOverflow, UniqueConstraintViolation, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
|
|
12
12
|
import { makeDefaultCrypto } from "@effect-dynamodb/schema/internal/DefaultCrypto.js";
|
|
13
13
|
import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
|
|
14
14
|
import { normalizeGsiConfig } from "@effect-dynamodb/schema/KeyComposer.js";
|
|
15
|
-
import { Context, Crypto, DateTime, Effect, Option, Schema,
|
|
15
|
+
import { Context, Crypto, DateTime, Effect, Option, Schema, Stream } from "effect";
|
|
16
16
|
import { DynamoClient } from "./DynamoClient.js";
|
|
17
17
|
import { makeBoundAppend, makeBoundDelete, makeBoundPut, makeBoundUpdate, } from "./internal/BoundCrud.js";
|
|
18
18
|
import { BoundQueryImpl } from "./internal/BoundQuery.js";
|
|
@@ -27,8 +27,8 @@ export { EntityDeleteImpl, EntityDeleteTypeId, EntityGetImpl, EntityOpTypeId, En
|
|
|
27
27
|
import * as Projection from "@effect-dynamodb/schema/Projection.js";
|
|
28
28
|
import { EntityDeleteImpl, EntityDeleteTypeId, EntityGetImpl, EntityOpTypeId, EntityPutImpl, EntityUpdateImpl, emptyUpdateState, returnValuesMap, } from "./internal/EntityOps.js";
|
|
29
29
|
export { add, append, asItem, asModel, asNative, asRecord, cascade, clearMap, consistentRead, deleteFromSet, expectedVersion, pathAdd, pathAppend, pathDelete, pathIfNotExists, pathPrepend, pathRemove, pathSet, pathSubtract, project, remove, removeEntries, returnValues, set, subtract, } from "./internal/EntityCombinators.js";
|
|
30
|
+
import { allCompositeAttributes, allKeyFieldNames, getSchemaFields, primaryKeyComposites, resolveUniqueFields, } from "@effect-dynamodb/schema/internal/EntitySchemas.js";
|
|
30
31
|
import { asModel, condition as conditionCombinator, expectedVersion, set, } from "./internal/EntityCombinators.js";
|
|
31
|
-
import { allCompositeAttributes, allKeyFieldNames, buildDerivedSchemas, buildFieldEncodings, getFields, getSchemaFields, primaryKeyComposites, resolveSystemFields, resolveUniqueFields, validateNoTransformOverride, } from "@effect-dynamodb/schema/internal/EntitySchemas.js";
|
|
32
32
|
// ---------------------------------------------------------------------------
|
|
33
33
|
// Sparse-aware unique sentinel composition
|
|
34
34
|
// ---------------------------------------------------------------------------
|
|
@@ -50,7 +50,7 @@ import { allCompositeAttributes, allKeyFieldNames, buildDerivedSchemas, buildFie
|
|
|
50
50
|
* imported here for local use on the write path. `normalizeTtlSeconds` is
|
|
51
51
|
* re-exported below so the public `Entity` namespace surface is unchanged.
|
|
52
52
|
*/
|
|
53
|
-
import { normalizeTtlSeconds, resolveUniqueTtl } from "@effect-dynamodb/schema/Entity.js";
|
|
53
|
+
import { buildEntityDefinition, normalizeTtlSeconds, resolveUniqueTtl, } from "@effect-dynamodb/schema/Entity.js";
|
|
54
54
|
export { normalizeTtlSeconds };
|
|
55
55
|
const composeUniqueSentinel = (schema, entityType, constraintName, constraintDef, source, lookup) => {
|
|
56
56
|
const fields = resolveUniqueFields(constraintDef);
|
|
@@ -171,459 +171,88 @@ export const make = (config) => {
|
|
|
171
171
|
// Delegate to the internal implementation with normalized indexes
|
|
172
172
|
return makeImpl({ ...config, indexes });
|
|
173
173
|
};
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const hasHiddenFields = Object.values(modelFields).some(isHidden);
|
|
182
|
-
const systemFields = resolveSystemFields(config.timestamps, config.versioned, config.timeSeries, modelFields, configuredAttributes);
|
|
183
|
-
// System-field collision policy:
|
|
184
|
-
// - Timestamp collisions with a non-date model field silently yield the
|
|
185
|
-
// field to the user (see `resolveSystemFields`).
|
|
186
|
-
// - `version` collision is allowed. The field is stripped from
|
|
187
|
-
// `inputSchema` / `createSchema` / `updateSchema` (see
|
|
188
|
-
// `buildDerivedSchemas`), so user code cannot supply it via `put` /
|
|
189
|
-
// `create` / `.set()`. Library continues to own the write path
|
|
190
|
-
// (auto-increment + `.expectedVersion()` optimistic locking). The model
|
|
191
|
-
// declaration is purely for type-level visibility — callers can
|
|
192
|
-
// construct Schema.Class instances and read `version` off the record.
|
|
193
|
-
// - Manual version fixups remain available by dropping to the raw
|
|
194
|
-
// `DynamoClient` service; they deliberately do not appear on the
|
|
195
|
-
// `Entity` surface to keep the locking invariant visibly inviolable.
|
|
196
|
-
// ---------------------------------------------------------------------------
|
|
197
|
-
// Validate indexes
|
|
198
|
-
// ---------------------------------------------------------------------------
|
|
199
|
-
const primaryIndex = config.indexes.primary;
|
|
200
|
-
if (primaryIndex.pk.composite.length === 0 && primaryIndex.sk.composite.length === 0) {
|
|
201
|
-
throw new Error(`[EDD-9001] Entity "${config.entityType}": primary key must have at least one composite attribute in pk or sk`);
|
|
202
|
-
}
|
|
203
|
-
// Build the set of valid composite attribute names: model fields + ref-derived ID fields
|
|
204
|
-
const validCompositeFields = new Set(Object.keys(modelFields));
|
|
205
|
-
for (const fieldName of Object.keys(modelFields)) {
|
|
206
|
-
if (isRefField(fieldName, config.model)) {
|
|
207
|
-
validCompositeFields.add(`${fieldName}Id`);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
for (const [indexName, indexDef] of Object.entries(config.indexes)) {
|
|
211
|
-
for (const attr of [...indexDef.pk.composite, ...indexDef.sk.composite]) {
|
|
212
|
-
if (!validCompositeFields.has(attr)) {
|
|
213
|
-
throw new Error(`[EDD-9002] Entity "${config.entityType}": index "${indexName}" references unknown attribute "${attr}". ` +
|
|
214
|
-
`Valid attributes: ${[...validCompositeFields].sort().join(", ")}`);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
// ---------------------------------------------------------------------------
|
|
219
|
-
// EDD-9025: composite attribute schemas must not include `null`.
|
|
220
|
-
//
|
|
221
|
-
// Composites participate in string composition (`acc#X#alert#Y`); `null` is
|
|
222
|
-
// not a meaningful slot value. Allowing `Schema.NullOr` / `NullishOr` /
|
|
223
|
-
// `Schema.Union([..., Schema.Null])` on a composite would let
|
|
224
|
-
// `set({ composite: null })` typecheck (after the v1.6 update-payload
|
|
225
|
-
// widening was reverted), then either blow up at runtime or silently
|
|
226
|
-
// produce a key with the literal string `"null"` as a slot.
|
|
227
|
-
//
|
|
228
|
-
// Sparse pattern is still expressible via `Schema.optional(...)` (T |
|
|
229
|
-
// undefined), which produces no Null AST node. See DESIGN.md §7.
|
|
230
|
-
// ---------------------------------------------------------------------------
|
|
231
|
-
// Resolve the AST for a domain composite name. For ref-derived `${name}Id`
|
|
232
|
-
// composites, the schema is the referenced entity's identifier schema (not
|
|
233
|
-
// the model field). For all other composites, it's the model field's
|
|
234
|
-
// schema. Returns undefined when the composite isn't declared on the model
|
|
235
|
-
// (already caught by EDD-9002 above, so this is defensive).
|
|
236
|
-
const resolveCompositeSchemaForNullCheck = (compositeAttr) => {
|
|
237
|
-
if (compositeAttr in modelFields) {
|
|
238
|
-
return { schema: modelFields[compositeAttr], source: `model field` };
|
|
239
|
-
}
|
|
240
|
-
// Ref-derived ${name}Id composite — look up the identifier schema on the
|
|
241
|
-
// referenced entity's model. We can't use the resolvedRefs array here
|
|
242
|
-
// (it's built later); recompute inline against config.refs.
|
|
243
|
-
if (config.refs) {
|
|
244
|
-
for (const [refFieldName, refValue] of Object.entries(config.refs)) {
|
|
245
|
-
if (`${refFieldName}Id` !== compositeAttr)
|
|
246
|
-
continue;
|
|
247
|
-
const refEntity = refValue.entity;
|
|
248
|
-
const idField = getIdentifierField(refEntity.model);
|
|
249
|
-
if (!idField)
|
|
250
|
-
return undefined;
|
|
251
|
-
return { schema: idField.schema, source: `ref "${refFieldName}" identifier` };
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
return undefined;
|
|
255
|
-
};
|
|
256
|
-
// Recursively walk an AST and return a path-string if `null` is reachable
|
|
257
|
-
// in the type union (anywhere — direct, nested in Union, behind Suspend).
|
|
258
|
-
// `Schema.optional(X)` produces `T | undefined` (no Null) and is allowed.
|
|
259
|
-
// `Schema.NullOr(X)` produces `T | null` and is rejected.
|
|
260
|
-
const findNullInAst = (ast, path, seen = new Set()) => {
|
|
261
|
-
if (seen.has(ast))
|
|
262
|
-
return undefined;
|
|
263
|
-
seen.add(ast);
|
|
264
|
-
if (SchemaAST.isNull(ast))
|
|
265
|
-
return `${path}.Null`;
|
|
266
|
-
if (SchemaAST.isUnion(ast)) {
|
|
267
|
-
for (let i = 0; i < ast.types.length; i++) {
|
|
268
|
-
const found = findNullInAst(ast.types[i], `${path}.Union[${i}]`, seen);
|
|
269
|
-
if (found)
|
|
270
|
-
return found;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
// Other AST shapes (Declaration, String, Number, Suspend, ...) cannot
|
|
274
|
-
// introduce `null` themselves. Suspend wrappers are handled
|
|
275
|
-
// conservatively — we don't recurse into thunks because the EDD-9025
|
|
276
|
-
// check is about static type unions, and recursive schemas wrapping
|
|
277
|
-
// composite-eligible scalars are pathological for keys anyway.
|
|
278
|
-
return undefined;
|
|
279
|
-
};
|
|
280
|
-
const checkCompositeForNull = (surface, compositeAttr) => {
|
|
281
|
-
const resolved = resolveCompositeSchemaForNullCheck(compositeAttr);
|
|
282
|
-
if (!resolved)
|
|
283
|
-
return; // EDD-9002 already caught the unknown-attribute case
|
|
284
|
-
const found = findNullInAst(resolved.schema.ast, resolved.source);
|
|
285
|
-
if (found) {
|
|
286
|
-
throw makeCompositeNullableError({
|
|
287
|
-
entityType: config.entityType,
|
|
288
|
-
surface,
|
|
289
|
-
compositeAttribute: compositeAttr,
|
|
290
|
-
schemaPath: found,
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
};
|
|
294
|
-
// primaryKey composites
|
|
295
|
-
for (const attr of [
|
|
296
|
-
...config.indexes.primary.pk.composite,
|
|
297
|
-
...config.indexes.primary.sk.composite,
|
|
298
|
-
]) {
|
|
299
|
-
checkCompositeForNull("primaryKey", attr);
|
|
174
|
+
/** @internal A definition that has had its operations attached (has `.get`). */
|
|
175
|
+
const isOperational = (e) => typeof e.get === "function";
|
|
176
|
+
/** @internal Copy the schema + table tag a definition received via `_configure`
|
|
177
|
+
* (Table.make injects them at table-definition time) onto a promoted entity. */
|
|
178
|
+
const carryConfigure = (from, to) => {
|
|
179
|
+
if (from._schema !== undefined && from._tableTag !== undefined) {
|
|
180
|
+
to._configure(from._schema, from._tableTag);
|
|
300
181
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
`Every PK/SK composite must appear in appendInput so the event can be addressed.`);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
// EDD-9014: orderBy must not name a ref field or ref-derived ${name}Id field
|
|
377
|
-
for (const fieldName of Object.keys(modelFields)) {
|
|
378
|
-
if (isRefField(fieldName, config.model)) {
|
|
379
|
-
if (orderBy === fieldName || orderBy === `${fieldName}Id`) {
|
|
380
|
-
throw new Error(`[EDD-9014] Entity "${config.entityType}": timeSeries.orderBy "${orderBy}" names a ref ` +
|
|
381
|
-
`or ref-derived id field. Refs are create-time denormalisations and cannot serve as the event clock.`);
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
// Also: appendInput must not declare any ref-derived ${name}Id field —
|
|
386
|
-
// ref changes go through .update(), not .append() (§4.6).
|
|
387
|
-
for (const fieldName of Object.keys(modelFields)) {
|
|
388
|
-
if (isRefField(fieldName, config.model)) {
|
|
389
|
-
if (appendInputFieldSet.has(`${fieldName}Id`)) {
|
|
390
|
-
throw new Error(`[EDD-9014] Entity "${config.entityType}": timeSeries.appendInput must not include ref-derived ` +
|
|
391
|
-
`"${fieldName}Id" — refs cannot be reassigned via .append(). Use .update() to change a ref.`);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
// ---------------------------------------------------------------------------
|
|
397
|
-
// Validate generatedId config (EDD-9008)
|
|
398
|
-
// ---------------------------------------------------------------------------
|
|
399
|
-
// The named field is auto-filled with a UUID at write time when omitted. It
|
|
400
|
-
// MUST exist in the model AND participate in the primary key (pk or sk
|
|
401
|
-
// composite) — generating an id that doesn't address the item would be a
|
|
402
|
-
// silent no-op. Mirrors the timeSeries.orderBy validation style.
|
|
403
|
-
if (config.generatedId !== undefined && config.generatedId !== null) {
|
|
404
|
-
const gid = config.generatedId;
|
|
405
|
-
const field = gid.field;
|
|
406
|
-
// EDD-9008: field must name a model field
|
|
407
|
-
if (!(field in modelFields)) {
|
|
408
|
-
throw new Error(`[EDD-9008] Entity "${config.entityType}": generatedId.field "${field}" does not name a model field. ` +
|
|
409
|
-
`Valid model fields: ${Object.keys(modelFields).sort().join(", ")}`);
|
|
410
|
-
}
|
|
411
|
-
// EDD-9008: field must participate in the primary key (pk or sk composite)
|
|
412
|
-
const primary = config.indexes.primary;
|
|
413
|
-
const pkSkComposites = new Set([...primary.pk.composite, ...primary.sk.composite]);
|
|
414
|
-
if (!pkSkComposites.has(field)) {
|
|
415
|
-
throw new Error(`[EDD-9008] Entity "${config.entityType}": generatedId.field "${field}" must participate in the ` +
|
|
416
|
-
`primary key (pk or sk composite). An auto-generated id that does not compose into the primary ` +
|
|
417
|
-
`key cannot address the item. Primary-key composites: ${[...pkSkComposites].sort().join(", ")}`);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
const resolvedRefs = config.refs
|
|
421
|
-
? Object.entries(config.refs).map(([fieldName, refValue]) => {
|
|
422
|
-
// Extract entity from shorthand or expanded form
|
|
423
|
-
const refEntity = refValue.entity;
|
|
424
|
-
// Validate the field exists in the model
|
|
425
|
-
const fieldSchema = modelFields[fieldName];
|
|
426
|
-
if (!fieldSchema) {
|
|
427
|
-
throw new Error(`Entity "${config.entityType}": refs config references field "${fieldName}" which does not exist in the model`);
|
|
428
|
-
}
|
|
429
|
-
// Check for ref annotation — either on the schema field or via DynamoModel.configure
|
|
430
|
-
if (!isRef(fieldSchema) && !isRefField(fieldName, config.model)) {
|
|
431
|
-
throw new Error(`Entity "${config.entityType}": refs config references field "${fieldName}" which does not have the DynamoModel.ref annotation. Add DynamoModel.ref to the model field or set { ref: true } in DynamoModel.configure.`);
|
|
432
|
-
}
|
|
433
|
-
// Resolve the identifier field from the referenced entity's model
|
|
434
|
-
// getIdentifierField handles both schema-level annotations and ConfiguredModel overrides
|
|
435
|
-
const idField = getIdentifierField(refEntity.model);
|
|
436
|
-
if (!idField) {
|
|
437
|
-
throw new Error(`Entity "${config.entityType}": ref field "${fieldName}" references entity "${refEntity.entityType}" which has no identifier field. Add DynamoModel.identifier to the model field or set { identifier: true } in DynamoModel.configure.`);
|
|
438
|
-
}
|
|
439
|
-
return {
|
|
440
|
-
fieldName,
|
|
441
|
-
idFieldName: `${fieldName}Id`,
|
|
442
|
-
identifierField: idField.name,
|
|
443
|
-
identifierSchema: idField.schema,
|
|
444
|
-
refEntity: refEntity,
|
|
445
|
-
refEntityType: refEntity.entityType,
|
|
446
|
-
};
|
|
447
|
-
})
|
|
448
|
-
: [];
|
|
449
|
-
const hasRefs = resolvedRefs.length > 0;
|
|
450
|
-
// ---------------------------------------------------------------------------
|
|
451
|
-
// Auto-generate cascade indexes from expanded ref configs
|
|
452
|
-
// ---------------------------------------------------------------------------
|
|
453
|
-
const cascadeIndexes = {};
|
|
454
|
-
if (config.refs) {
|
|
455
|
-
for (const [refFieldName, refValue] of Object.entries(config.refs)) {
|
|
456
|
-
const cascadeConfig = refValue.cascade;
|
|
457
|
-
if (!cascadeConfig)
|
|
458
|
-
continue;
|
|
459
|
-
const ref = resolvedRefs.find((r) => r.fieldName === refFieldName);
|
|
460
|
-
cascadeIndexes[`_cascade_${refFieldName}`] = {
|
|
461
|
-
index: cascadeConfig.index,
|
|
462
|
-
pk: { field: cascadeConfig.pk.field, composite: [ref.idFieldName] },
|
|
463
|
-
sk: { field: cascadeConfig.sk.field, composite: [...config.indexes.primary.pk.composite] },
|
|
464
|
-
};
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
let allIndexes = {
|
|
468
|
-
...config.indexes,
|
|
469
|
-
...cascadeIndexes,
|
|
470
|
-
};
|
|
471
|
-
// Build immutable fields set from ConfiguredModel (for update schema exclusion + upsert if_not_exists wrapping)
|
|
472
|
-
const immutableFields = new Set();
|
|
473
|
-
for (const [fieldName, attrConfig] of Object.entries(configuredAttributes)) {
|
|
474
|
-
if (attrConfig.immutable)
|
|
475
|
-
immutableFields.add(fieldName);
|
|
476
|
-
}
|
|
477
|
-
// Resolve identifier field name from model annotation
|
|
478
|
-
const resolvedIdentifier = getIdentifierField(config.model)?.name;
|
|
479
|
-
// Validate: no model field combines a transform schema with a ConfiguredModel
|
|
480
|
-
// storage override. The two configuration paths are mutually exclusive.
|
|
481
|
-
validateNoTransformOverride(modelFields, configuredAttributes);
|
|
482
|
-
// Resolve effective field encodings (annotation + ConfiguredModel override).
|
|
483
|
-
// Used by buildDerivedSchemas to substitute self date schemas with bidirectional
|
|
484
|
-
// transforms, and (below) by system-field generation to produce the right
|
|
485
|
-
// wire primitive when the field is library-managed.
|
|
486
|
-
const fieldEncodings = buildFieldEncodings(modelFields, configuredAttributes);
|
|
487
|
-
const generatedIdField = config.generatedId !== undefined && config.generatedId !== null
|
|
488
|
-
? config.generatedId.field
|
|
489
|
-
: undefined;
|
|
490
|
-
const generatedIdVersion = config.generatedId !== undefined && config.generatedId !== null
|
|
491
|
-
? (config.generatedId.version ?? "v4")
|
|
492
|
-
: undefined;
|
|
493
|
-
const schemas = buildDerivedSchemas(modelFields, allIndexes, systemFields, resolvedRefs, immutableFields, resolvedIdentifier, config.timeSeries, fieldEncodings, generatedIdField);
|
|
494
|
-
// schema and tableTag are injected via _configure() when the entity is registered
|
|
495
|
-
// on a Table and bound through DynamoClient.make(). They are captured by operation
|
|
496
|
-
// closures and resolved at runtime (inside Effects), not at definition time.
|
|
497
|
-
// Using definite assignment (!) since _configure is called before any operation executes.
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* Promote a pure `@effect-dynamodb/schema` {@link EntityDefinition} into a full
|
|
185
|
+
* operational runtime {@link Entity}, attaching CRUD/query operations. The
|
|
186
|
+
* already-derived data bundle (`def._data`) is reused, so this is a thin
|
|
187
|
+
* op-attach — no re-validation or re-derivation. The table binding the pure
|
|
188
|
+
* definition already received (via `Table.make`) is preserved.
|
|
189
|
+
*
|
|
190
|
+
* **Ref targets are promoted too.** Write-time ref hydration calls `.get()` on
|
|
191
|
+
* each ref target (to denormalise it), so a pure ref target must be operational
|
|
192
|
+
* or the write crashes with `ref.refEntity.get is not a function`. Each ref
|
|
193
|
+
* target is promoted one level: `.get` (a read) does not itself hydrate refs, so
|
|
194
|
+
* the target's *own* ref targets need not be promoted — which also sidesteps
|
|
195
|
+
* cyclic refs (A→B→A) without recursion. An entity that is itself bound and
|
|
196
|
+
* written goes through `fromDefinition` independently and gets its own refs
|
|
197
|
+
* promoted.
|
|
198
|
+
*
|
|
199
|
+
* Used by `DynamoClient.make()` to bind pure definitions; idempotent on an
|
|
200
|
+
* entity that is already operational.
|
|
201
|
+
*
|
|
202
|
+
* @internal
|
|
203
|
+
*/
|
|
204
|
+
export const fromDefinition = (def) => {
|
|
205
|
+
const resolvedRefs = def._data.resolvedRefs;
|
|
206
|
+
// Replace any pure ref target with an operational one so hydration's `.get`
|
|
207
|
+
// works. Already-operational targets (runtime-authored, or mixed refs once the
|
|
208
|
+
// shared AnyRefValue lands) pass through unchanged.
|
|
209
|
+
// Cast: a promoted ref target is a runtime `Entity`, not the pure
|
|
210
|
+
// `EntityDefinition` that `EntityDefinitionData.resolvedRefs` is typed against.
|
|
211
|
+
// Hydration only reads `.model`/`.entityType`/`.get` off it (the latter via the
|
|
212
|
+
// runtime `ResolvedRef` cast inside makeImpl), so this is sound.
|
|
213
|
+
const data = resolvedRefs.length > 0
|
|
214
|
+
? {
|
|
215
|
+
...def._data,
|
|
216
|
+
resolvedRefs: resolvedRefs.map((r) => isOperational(r.refEntity)
|
|
217
|
+
? r
|
|
218
|
+
: { ...r, refEntity: promoteRefTarget(r.refEntity) }),
|
|
219
|
+
}
|
|
220
|
+
: def._data;
|
|
221
|
+
const runtime = makeImpl(def._config, data);
|
|
222
|
+
carryConfigure(def, runtime);
|
|
223
|
+
return runtime;
|
|
224
|
+
};
|
|
225
|
+
/** @internal Promote a ref target to an operational entity WITHOUT promoting its
|
|
226
|
+
* own ref targets (one level — see {@link fromDefinition}). The target's table
|
|
227
|
+
* binding is carried over so `.get` can compose keys. */
|
|
228
|
+
const promoteRefTarget = (def) => {
|
|
229
|
+
const runtime = makeImpl(def._config, def._data);
|
|
230
|
+
carryConfigure(def, runtime);
|
|
231
|
+
return runtime;
|
|
232
|
+
};
|
|
233
|
+
const makeImpl = (config, precomputedData) => {
|
|
234
|
+
// Derivation (validation + schema/ref/sparse/rename resolution) is shared with
|
|
235
|
+
// the pure `@effect-dynamodb/schema` Entity.make via `buildEntityDefinition`,
|
|
236
|
+
// giving a single source of truth for the EDD-90xx rules. Promotion of a pure
|
|
237
|
+
// definition (see `fromDefinition`) passes the already-computed bundle as
|
|
238
|
+
// `precomputedData`, so derivation runs exactly once per entity.
|
|
239
|
+
// Cast: the runtime config's `refs` uses the runtime `AnyRefValue` (whose
|
|
240
|
+
// `entity` is the operational `Entity`), which is nominally distinct from the
|
|
241
|
+
// schema package's `AnyRefValue` (`entity: EntityDefinition`). buildEntityDefinition
|
|
242
|
+
// only reads `.model`/`.entityType`/identifier off ref targets, both of which
|
|
243
|
+
// the runtime Entity carries, so the cast is sound.
|
|
244
|
+
const data = precomputedData ?? buildEntityDefinition(config);
|
|
245
|
+
const { systemFields, schemas, hasRefs, immutableFields, resolvedIdentifier, generatedIdField, generatedIdVersion, entityType, entityVersion, sparseFields, hasSparseFields, renameToDynamo, renameFromDynamo, resolveDbName, rawModel, isSchemaClass, hasHiddenFields, } = data;
|
|
246
|
+
// resolvedRefs carries the actual ref-target entity objects; at runtime they
|
|
247
|
+
// are operational Entities (for runtime-authored refs) so write-time hydration
|
|
248
|
+
// can call their CRUD ops. The pure bundle widens refEntity to EntityDefinition.
|
|
249
|
+
const resolvedRefs = data.resolvedRefs;
|
|
250
|
+
// allIndexes is mutable: _injectIndex adds collection-owned GSIs after make().
|
|
251
|
+
let allIndexes = { ...data.initialIndexes };
|
|
252
|
+
// schema + tableTag are injected via _configure() when the entity is registered
|
|
253
|
+
// on a Table and bound through DynamoClient.make(); captured by operation closures.
|
|
498
254
|
let schema;
|
|
499
255
|
let tableTag;
|
|
500
|
-
const entityType = config.entityType;
|
|
501
|
-
const entityVersion = 1;
|
|
502
|
-
// Note: `fieldEncodings` is resolved earlier (above buildDerivedSchemas) and
|
|
503
|
-
// used to substitute self date schemas in derived schemas. System timestamp
|
|
504
|
-
// encodings are NOT in `fieldEncodings`; `generateTimestamp` produces wire
|
|
505
|
-
// primitives directly for non-colliding system fields.
|
|
506
|
-
// ---------------------------------------------------------------------------
|
|
507
|
-
// Sparse Map fields (storedAs: 'sparse') — validation + resolution
|
|
508
|
-
// ---------------------------------------------------------------------------
|
|
509
|
-
const sparseFields = getSparseFields(config.model);
|
|
510
|
-
const hasSparseFields = Object.keys(sparseFields).length > 0;
|
|
511
|
-
if (hasSparseFields) {
|
|
512
|
-
// EDD-9020: storedAs: 'sparse' is only valid on Schema.Record fields.
|
|
513
|
-
for (const fieldName of Object.keys(sparseFields)) {
|
|
514
|
-
const fieldSchema = modelFields[fieldName];
|
|
515
|
-
if (!fieldSchema) {
|
|
516
|
-
throw new Error(`[EDD-9020] Entity "${config.entityType}": sparse field "${fieldName}" does not exist on the model`);
|
|
517
|
-
}
|
|
518
|
-
const recordInfo = isRecordSchema(fieldSchema);
|
|
519
|
-
if (!recordInfo) {
|
|
520
|
-
throw new Error(`[EDD-9020] Entity "${config.entityType}": sparse field "${fieldName}" must be a Schema.Record. ` +
|
|
521
|
-
`Got a non-Record schema. Sparse storage flattens Record entries into per-key top-level attributes.`);
|
|
522
|
-
}
|
|
523
|
-
// EDD-9021: inner value must not be another Record (no nested sparse).
|
|
524
|
-
if (isRecordAst(recordInfo.valueAst)) {
|
|
525
|
-
throw new Error(`[EDD-9021] Entity "${config.entityType}": sparse field "${fieldName}" has a Record-typed value schema. ` +
|
|
526
|
-
`Nested sparse Records are not supported — use Schema.Struct, Schema.Number, etc. for the inner value.`);
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
// EDD-9022: sparse fields cannot participate in primary key, GSI composites,
|
|
530
|
-
// or unique constraints — keys aren't statically known at make() time.
|
|
531
|
-
const allComposites = new Set(allCompositeAttributes(allIndexes));
|
|
532
|
-
for (const fieldName of Object.keys(sparseFields)) {
|
|
533
|
-
if (allComposites.has(fieldName)) {
|
|
534
|
-
throw new Error(`[EDD-9022] Entity "${config.entityType}": sparse field "${fieldName}" cannot be a primary-key or GSI composite. ` +
|
|
535
|
-
`Composite values must be known at make() time; sparse-map keys are not.`);
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
if (config.unique) {
|
|
539
|
-
const sparseSet = new Set(Object.keys(sparseFields));
|
|
540
|
-
for (const [constraintName, constraintDef] of Object.entries(config.unique)) {
|
|
541
|
-
const fields = resolveUniqueFields(constraintDef);
|
|
542
|
-
for (const f of fields) {
|
|
543
|
-
if (sparseSet.has(f)) {
|
|
544
|
-
throw new Error(`[EDD-9022] Entity "${config.entityType}": unique constraint "${constraintName}" cannot reference sparse field "${f}".`);
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
// EDD-9023: sparse prefixes must be unique among themselves and must not
|
|
550
|
-
// collide with non-sparse top-level model field names.
|
|
551
|
-
const seenPrefix = new Map(); // prefix → fieldName
|
|
552
|
-
for (const [fieldName, sparse] of Object.entries(sparseFields)) {
|
|
553
|
-
const existing = seenPrefix.get(sparse.prefix);
|
|
554
|
-
if (existing) {
|
|
555
|
-
throw new Error(`[EDD-9023] Entity "${config.entityType}": sparse fields "${existing}" and "${fieldName}" share prefix "${sparse.prefix}". Prefixes must be distinct.`);
|
|
556
|
-
}
|
|
557
|
-
seenPrefix.set(sparse.prefix, fieldName);
|
|
558
|
-
// Prefix must not collide with another (non-sparse) model field name.
|
|
559
|
-
if (sparse.prefix !== fieldName && sparse.prefix in modelFields) {
|
|
560
|
-
throw new Error(`[EDD-9023] Entity "${config.entityType}": sparse field "${fieldName}" prefix "${sparse.prefix}" collides with non-sparse field "${sparse.prefix}".`);
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
// EDD-9005: validate every configured TTL eagerly at make() time. Rejects
|
|
565
|
-
// non-finite (infinite) durations and unparseable string forms here so the
|
|
566
|
-
// write path never observes an invalid TTL (it would otherwise surface as a
|
|
567
|
-
// defect mid-write). Bare numbers are rejected at the type level (footgun).
|
|
568
|
-
{
|
|
569
|
-
const configuredTtls = [];
|
|
570
|
-
if (typeof config.versioned === "object" && config.versioned?.ttl !== undefined) {
|
|
571
|
-
configuredTtls.push(config.versioned.ttl);
|
|
572
|
-
}
|
|
573
|
-
if (typeof config.softDelete === "object" && config.softDelete?.ttl !== undefined) {
|
|
574
|
-
configuredTtls.push(config.softDelete.ttl);
|
|
575
|
-
}
|
|
576
|
-
if (config.timeSeries?.ttl !== undefined)
|
|
577
|
-
configuredTtls.push(config.timeSeries.ttl);
|
|
578
|
-
if (config.unique) {
|
|
579
|
-
for (const def of Object.values(config.unique)) {
|
|
580
|
-
const uTtl = resolveUniqueTtl(def);
|
|
581
|
-
if (uTtl !== undefined)
|
|
582
|
-
configuredTtls.push(uTtl);
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
for (const ttl of configuredTtls)
|
|
586
|
-
normalizeTtlSeconds(ttl);
|
|
587
|
-
}
|
|
588
|
-
// ---------------------------------------------------------------------------
|
|
589
|
-
// Field renaming: domain name → DynamoDB attribute name (from ConfiguredModel)
|
|
590
|
-
// ---------------------------------------------------------------------------
|
|
591
|
-
const fieldRenames = {}; // domain → dynamo
|
|
592
|
-
for (const [domainName, attrConfig] of Object.entries(configuredAttributes)) {
|
|
593
|
-
if (attrConfig.field)
|
|
594
|
-
fieldRenames[domainName] = attrConfig.field;
|
|
595
|
-
}
|
|
596
|
-
const hasRenames = Object.keys(fieldRenames).length > 0;
|
|
597
|
-
/**
|
|
598
|
-
* Rename domain field names to DynamoDB attribute names.
|
|
599
|
-
* Called before toAttributeMap (put path).
|
|
600
|
-
*/
|
|
601
|
-
const renameToDynamo = (item) => {
|
|
602
|
-
if (!hasRenames)
|
|
603
|
-
return;
|
|
604
|
-
for (const [domain, dynamo] of Object.entries(fieldRenames)) {
|
|
605
|
-
if (domain in item) {
|
|
606
|
-
item[dynamo] = item[domain];
|
|
607
|
-
delete item[domain];
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
};
|
|
611
|
-
/**
|
|
612
|
-
* Rename DynamoDB attribute names back to domain field names.
|
|
613
|
-
* Called after fromAttributeMap (get path).
|
|
614
|
-
*/
|
|
615
|
-
const renameFromDynamo = (item) => {
|
|
616
|
-
if (!hasRenames)
|
|
617
|
-
return;
|
|
618
|
-
for (const [domain, dynamo] of Object.entries(fieldRenames)) {
|
|
619
|
-
if (dynamo in item) {
|
|
620
|
-
item[domain] = item[dynamo];
|
|
621
|
-
delete item[dynamo];
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
};
|
|
625
|
-
/** Resolve a domain field name to its DynamoDB attribute name. */
|
|
626
|
-
const resolveDbName = (domainName) => fieldRenames[domainName] ?? domainName;
|
|
627
256
|
/**
|
|
628
257
|
* Flatten sparse Map fields into per-entry top-level attributes.
|
|
629
258
|
* Called on the write path after rename + date serialization, before
|