graphddb 0.7.9 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +6 -6
  2. package/dist/cdc/index.d.ts +389 -4
  3. package/dist/cdc/index.js +4 -3
  4. package/dist/{chunk-ZPNRLOKA.js → chunk-GS4C5VGO.js} +4 -6
  5. package/dist/chunk-HNY2EJPV.js +1184 -0
  6. package/dist/{chunk-NYM7K2ST.js → chunk-I4LEJ4TF.js} +3812 -6724
  7. package/dist/{chunk-PFFPLD4B.js → chunk-L2NEDS7U.js} +725 -2112
  8. package/dist/chunk-L4QRCHRQ.js +278 -0
  9. package/dist/chunk-LGHSZIEE.js +187 -0
  10. package/dist/chunk-N4NWYNGZ.js +1987 -0
  11. package/dist/{chunk-PDUVTYC5.js → chunk-XTWXMOHD.js} +0 -1
  12. package/dist/cli.js +63 -252
  13. package/dist/index.d.ts +23 -1548
  14. package/dist/index.js +100 -1778
  15. package/dist/internal/index.d.ts +84 -0
  16. package/dist/internal/index.js +701 -0
  17. package/dist/{maintenance-view-adapter-BATUh_I8.d.ts → key-DR7_lpyk.d.ts} +538 -2975
  18. package/dist/linter/index.d.ts +39 -6
  19. package/dist/linter/index.js +22 -4
  20. package/dist/{registry-CXhP4TaE.d.ts → linter-C-vypgut.d.ts} +22 -22
  21. package/dist/prepared-artifact-BpPgkXEo.d.ts +281 -0
  22. package/dist/spec/index.d.ts +506 -4
  23. package/dist/spec/index.js +36 -17
  24. package/dist/testing/index.d.ts +2 -2
  25. package/dist/testing/index.js +4 -3
  26. package/dist/transform/index.d.ts +460 -1
  27. package/dist/transform/index.js +2085 -2
  28. package/dist/types-2PMXEn5x.d.ts +1205 -0
  29. package/dist/types-BXLzIcQD.d.ts +450 -0
  30. package/docs/cdc-projection.md +5 -5
  31. package/docs/class-hydration.md +1 -1
  32. package/docs/cqrs-contract.md +28 -20
  33. package/docs/design-patterns.md +5 -5
  34. package/docs/docs-generation.md +6 -6
  35. package/docs/middleware.md +15 -15
  36. package/docs/mutation-command-derivation.md +52 -42
  37. package/docs/prepared-statements.md +14 -14
  38. package/docs/python-bridge.md +113 -66
  39. package/docs/spec.md +153 -124
  40. package/docs/testing.md +9 -8
  41. package/package.json +20 -5
  42. package/dist/chunk-MMVHOUM4.js +0 -24
  43. package/dist/from-change-DanwjE5b.d.ts +0 -327
  44. package/dist/index-CtPJSMrc.d.ts +0 -934
  45. package/dist/relation-depth-Dg3yhl7S.d.ts +0 -36
@@ -4,10 +4,8 @@ import {
4
4
  createColumnMap,
5
5
  createDefaultLinter,
6
6
  isColumn,
7
- isKeyDefinition,
8
- resolveKey,
9
- segmentFieldNames
10
- } from "./chunk-PDUVTYC5.js";
7
+ isKeyDefinition
8
+ } from "./chunk-XTWXMOHD.js";
11
9
 
12
10
  // src/decorators/gsi.ts
13
11
  var GSI_MARKER = /* @__PURE__ */ Symbol("graphddb:gsi");
@@ -172,146 +170,6 @@ ${messages}`);
172
170
  }
173
171
  };
174
172
 
175
- // src/types/select-builder.ts
176
- var SELECT_BUILDER = /* @__PURE__ */ Symbol.for("graphddb.selectBuilder");
177
- var SELECT_SPEC = /* @__PURE__ */ Symbol.for("graphddb.selectSpec");
178
-
179
- // src/select/builder.ts
180
- var BuilderImpl = class _BuilderImpl {
181
- [SELECT_BUILDER] = true;
182
- /** The resolved spec, exposed (non-method) so normalizers can read it. */
183
- [SELECT_SPEC];
184
- constructor(spec) {
185
- this[SELECT_SPEC] = spec;
186
- }
187
- clone(patch) {
188
- return new _BuilderImpl({ ...this[SELECT_SPEC], ...patch });
189
- }
190
- filter(filter) {
191
- return this.clone({ filter });
192
- }
193
- limit(limit) {
194
- return this.clone({ limit });
195
- }
196
- after(cursor) {
197
- return this.clone({ after: cursor });
198
- }
199
- order(order) {
200
- return this.clone({ order });
201
- }
202
- };
203
- function isSelectBuilder(value) {
204
- return typeof value === "object" && value !== null && value[SELECT_BUILDER] === true;
205
- }
206
- function fromBuilder(value) {
207
- const spec = value[SELECT_SPEC];
208
- return {
209
- select: spec.select,
210
- filter: spec.filter,
211
- limit: spec.limit,
212
- after: spec.after,
213
- order: spec.order
214
- };
215
- }
216
- function normalizeSelectSpec(value) {
217
- if (isSelectBuilder(value)) {
218
- return fromBuilder(value);
219
- }
220
- const obj = value;
221
- return {
222
- select: obj.select,
223
- filter: obj.filter,
224
- limit: obj.limit,
225
- after: obj.after,
226
- order: obj.order
227
- };
228
- }
229
- function normalizeTopLevelSelect(value) {
230
- if (isSelectBuilder(value)) {
231
- return fromBuilder(value);
232
- }
233
- return { select: value ?? {} };
234
- }
235
- function buildProject(select) {
236
- return new BuilderImpl({
237
- [SELECT_BUILDER]: true,
238
- select
239
- });
240
- }
241
- function buildRelation(select) {
242
- return new BuilderImpl({
243
- [SELECT_BUILDER]: true,
244
- select
245
- });
246
- }
247
-
248
- // src/relation/detect.ts
249
- function isRelationSpec(value) {
250
- if (typeof value !== "object" || value === null) return false;
251
- return "select" in value || isSelectBuilder(value);
252
- }
253
- function isInlineSnapshotSpec(value) {
254
- return typeof value === "object" && value !== null && !isSelectBuilder(value) && value.inline === true && !("select" in value);
255
- }
256
- function detectRelationFields(select, metadata) {
257
- const result = [];
258
- for (const [fieldName, value] of Object.entries(select)) {
259
- if (isRelationSpec(value)) {
260
- const rel = metadata.relations.find((r) => r.propertyName === fieldName);
261
- if (rel) {
262
- result.push(rel);
263
- }
264
- }
265
- }
266
- return result;
267
- }
268
- function validateInlineSnapshotSelect(select, metadata, resolveTargetMeta) {
269
- for (const [fieldName, value] of Object.entries(select)) {
270
- if (isInlineSnapshotSpec(value)) {
271
- const rel = metadata.relations.find((r) => r.propertyName === fieldName);
272
- if (!rel) {
273
- throw new Error(
274
- `Inline read '{ ${fieldName}: { inline: true } }' is only valid on an embeddedSnapshot relation, but '${fieldName}' is not a relation on '${metadata.prefix}'. Use '${fieldName}: true' for a scalar / embedded field.`
275
- );
276
- }
277
- if (rel.options?.pattern !== "embeddedSnapshot") {
278
- throw new Error(
279
- `Inline read '{ ${fieldName}: { inline: true } }' is only valid on an embeddedSnapshot relation. '${fieldName}' is a plain '${rel.type}' relation (no 'pattern: embeddedSnapshot'); use '{ ${fieldName}: { select: \u2026 } }' to traverse to its child rows instead.`
280
- );
281
- }
282
- continue;
283
- }
284
- if (typeof value === "object" && value !== null && isRelationSpec(value)) {
285
- const rel = metadata.relations.find((r) => r.propertyName === fieldName);
286
- if (!rel) continue;
287
- const targetMeta = resolveTargetMeta(rel);
288
- const nested = normalizeSelectSpec(value).select;
289
- if (nested) {
290
- validateInlineSnapshotSelect(nested, targetMeta, resolveTargetMeta);
291
- }
292
- }
293
- }
294
- }
295
- function getImplicitKeyFields(select, metadata) {
296
- const relations = detectRelationFields(select, metadata);
297
- const needed = /* @__PURE__ */ new Set();
298
- for (const rel of relations) {
299
- if (rel.type === "refs" && rel.refs) {
300
- const listField = rel.refs.from;
301
- if (!(listField in select) || select[listField] !== true) {
302
- needed.add(listField);
303
- }
304
- continue;
305
- }
306
- for (const sourceField of Object.values(rel.keyBinding)) {
307
- if (!(sourceField in select) || select[sourceField] !== true) {
308
- needed.add(sourceField);
309
- }
310
- }
311
- }
312
- return [...needed];
313
- }
314
-
315
173
  // src/define/param.ts
316
174
  function isParamOptions(value) {
317
175
  return typeof value === "object" && value !== null;
@@ -456,6 +314,194 @@ async function batchWriteChunkWithRetry(docClient, tableName, entries, toCommand
456
314
  }
457
315
  }
458
316
 
317
+ // src/executor/batch-executor.ts
318
+ async function executeBatchGet(docClient, operation) {
319
+ if (operation.keys.length === 0) {
320
+ return { items: [] };
321
+ }
322
+ const chunks = chunkArray(operation.keys, BATCH_GET_MAX_KEYS);
323
+ const chunkResults = await Promise.all(
324
+ chunks.map(
325
+ (chunk) => batchGetChunkWithRetry(docClient, operation.tableName, {
326
+ Keys: chunk,
327
+ ...operation.projectionExpression ? { ProjectionExpression: operation.projectionExpression } : {},
328
+ ...operation.expressionAttributeNames ? { ExpressionAttributeNames: operation.expressionAttributeNames } : {},
329
+ ...operation.consistentRead ? { ConsistentRead: true } : {}
330
+ })
331
+ )
332
+ );
333
+ const allItems = [];
334
+ for (const chunkItems of chunkResults) {
335
+ allItems.push(...chunkItems);
336
+ }
337
+ return { items: allItems };
338
+ }
339
+
340
+ // src/executor/dynamo-executor.ts
341
+ var DynamoExecutor = class {
342
+ async execute(operation, _options) {
343
+ const docClient = await ClientManager.getDocumentClient();
344
+ if (operation.type === "GetItem") {
345
+ return executeGetItem(docClient, operation);
346
+ }
347
+ if (operation.type === "BatchGetItem") {
348
+ return this.batchGet(operation);
349
+ }
350
+ return executeQuery(docClient, operation);
351
+ }
352
+ async batchGet(input, _options) {
353
+ const docClient = await ClientManager.getDocumentClient();
354
+ return executeBatchGet(docClient, { type: "BatchGetItem", ...input });
355
+ }
356
+ async put(input, options) {
357
+ const docClient = await ClientManager.getDocumentClient();
358
+ const { PutCommand } = await loadLibDynamoDB();
359
+ const result = await docClient.send(
360
+ new PutCommand(
361
+ options?.returnOldImage ? { ...input, ReturnValues: "ALL_OLD" } : input
362
+ )
363
+ );
364
+ return toWriteResult(result);
365
+ }
366
+ async update(input, options) {
367
+ const docClient = await ClientManager.getDocumentClient();
368
+ const { UpdateCommand } = await loadLibDynamoDB();
369
+ const result = await docClient.send(
370
+ new UpdateCommand(
371
+ options?.returnOldImage ? { ...input, ReturnValues: "ALL_OLD" } : input
372
+ )
373
+ );
374
+ return toWriteResult(result);
375
+ }
376
+ async delete(input, options) {
377
+ const docClient = await ClientManager.getDocumentClient();
378
+ const { DeleteCommand } = await loadLibDynamoDB();
379
+ const result = await docClient.send(
380
+ new DeleteCommand(
381
+ options?.returnOldImage ? { ...input, ReturnValues: "ALL_OLD" } : input
382
+ )
383
+ );
384
+ return toWriteResult(result);
385
+ }
386
+ async batchWrite(tableName, items, _options) {
387
+ const docClient = await ClientManager.getDocumentClient();
388
+ for (const chunk of chunkArray(items, BATCH_WRITE_MAX_ITEMS)) {
389
+ await batchWriteChunkWithRetry(
390
+ docClient,
391
+ tableName,
392
+ chunk,
393
+ toBatchWriteCommandItem,
394
+ matchUnprocessed
395
+ );
396
+ }
397
+ }
398
+ async transactWrite(items, _options) {
399
+ if (items.length === 0) return;
400
+ const docClient = await ClientManager.getDocumentClient();
401
+ const { TransactWriteCommand } = await loadLibDynamoDB();
402
+ await docClient.send(
403
+ new TransactWriteCommand({ TransactItems: items })
404
+ );
405
+ }
406
+ };
407
+ function toWriteResult(result) {
408
+ const oldItem = result.Attributes;
409
+ return oldItem ? { oldItem } : {};
410
+ }
411
+ function toBatchWriteCommandItem(item) {
412
+ if (item.type === "put") {
413
+ return { PutRequest: { Item: item.item } };
414
+ }
415
+ return { DeleteRequest: { Key: item.key } };
416
+ }
417
+ function matchUnprocessed(unprocessed, items) {
418
+ return unprocessed.map((u) => {
419
+ if (u.PutRequest?.Item) {
420
+ const { PK, SK } = u.PutRequest.Item;
421
+ const match = items.find(
422
+ (it) => it.type === "put" && it.item.PK === PK && it.item.SK === SK
423
+ );
424
+ if (!match) {
425
+ throw new Error("Unable to match unprocessed BatchWrite put request");
426
+ }
427
+ return match;
428
+ }
429
+ if (u.DeleteRequest?.Key) {
430
+ const { PK, SK } = u.DeleteRequest.Key;
431
+ const match = items.find(
432
+ (it) => it.type === "delete" && it.key.PK === PK && it.key.SK === SK
433
+ );
434
+ if (!match) {
435
+ throw new Error("Unable to match unprocessed BatchWrite delete request");
436
+ }
437
+ return match;
438
+ }
439
+ throw new Error("Unsupported unprocessed BatchWrite request shape");
440
+ });
441
+ }
442
+ async function executeGetItem(docClient, operation) {
443
+ const { GetCommand } = await loadLibDynamoDB();
444
+ const cmd = new GetCommand({
445
+ TableName: operation.tableName,
446
+ Key: operation.keyCondition,
447
+ ...operation.projectionExpression ? { ProjectionExpression: operation.projectionExpression } : {},
448
+ ...operation.expressionAttributeNames ? { ExpressionAttributeNames: operation.expressionAttributeNames } : {},
449
+ ...operation.consistentRead ? { ConsistentRead: true } : {}
450
+ });
451
+ const result = await docClient.send(cmd);
452
+ if (result.Item) {
453
+ return { items: [result.Item] };
454
+ }
455
+ return { items: [] };
456
+ }
457
+ async function executeQuery(docClient, operation) {
458
+ const { QueryCommand } = await loadLibDynamoDB();
459
+ const keyEntries = Object.entries(operation.keyCondition);
460
+ const conditionParts = [];
461
+ const exprAttrNames = {
462
+ ...operation.expressionAttributeNames ?? {},
463
+ ...operation.filterExpressionAttributeNames ?? {}
464
+ };
465
+ const exprAttrValues = {
466
+ ...operation.filterExpressionAttributeValues ?? {}
467
+ };
468
+ for (let i = 0; i < keyEntries.length; i++) {
469
+ const [attrName, value] = keyEntries[i];
470
+ const nameAlias2 = `#k${i}`;
471
+ const valueAlias2 = `:k${i}`;
472
+ exprAttrNames[nameAlias2] = attrName;
473
+ exprAttrValues[valueAlias2] = value;
474
+ conditionParts.push(`${nameAlias2} = ${valueAlias2}`);
475
+ }
476
+ if (operation.rangeCondition) {
477
+ const rc = operation.rangeCondition;
478
+ const idx = keyEntries.length;
479
+ const nameAlias2 = `#k${idx}`;
480
+ const valueAlias2 = `:k${idx}`;
481
+ exprAttrNames[nameAlias2] = rc.key;
482
+ exprAttrValues[valueAlias2] = rc.value;
483
+ conditionParts.push(`begins_with(${nameAlias2}, ${valueAlias2})`);
484
+ }
485
+ const cmd = new QueryCommand({
486
+ TableName: operation.tableName,
487
+ KeyConditionExpression: conditionParts.join(" AND "),
488
+ ExpressionAttributeNames: exprAttrNames,
489
+ ExpressionAttributeValues: exprAttrValues,
490
+ ...operation.indexName ? { IndexName: operation.indexName } : {},
491
+ ...operation.filterExpression ? { FilterExpression: operation.filterExpression } : {},
492
+ ...operation.projectionExpression ? { ProjectionExpression: operation.projectionExpression } : {},
493
+ ...operation.scanIndexForward != null ? { ScanIndexForward: operation.scanIndexForward } : {},
494
+ ...operation.limit != null ? { Limit: operation.limit } : {},
495
+ ...operation.exclusiveStartKey ? { ExclusiveStartKey: operation.exclusiveStartKey } : {},
496
+ ...operation.consistentRead ? { ConsistentRead: true } : {}
497
+ });
498
+ const result = await docClient.send(cmd);
499
+ return {
500
+ items: result.Items ?? [],
501
+ lastEvaluatedKey: result.LastEvaluatedKey
502
+ };
503
+ }
504
+
459
505
  // src/executor/retry-policy.ts
460
506
  var DEFAULT_MAX_ATTEMPTS = 10;
461
507
  var DEFAULT_RETRY_POLICY = {
@@ -595,7 +641,7 @@ var RetryingExecutor = class {
595
641
  /**
596
642
  * @param inner the wrapped executor (the real {@link DynamoExecutor}).
597
643
  * @param getGlobalPolicy reads the globally-configured policy (or `null` for the
598
- * built-in default) at CALL time, so a `DDBModel.setRetryPolicy(...)` after the
644
+ * built-in default) at CALL time, so a `graphddb.config.retry(...)` after the
599
645
  * executor is constructed still takes effect.
600
646
  */
601
647
  constructor(inner, getGlobalPolicy) {
@@ -675,7 +721,7 @@ var ClientManager = class {
675
721
  /**
676
722
  * The globally-configured single-op retry policy (issue #111). `null` ⇒ the
677
723
  * always-on built-in {@link DEFAULT_RETRY_POLICY} applies (retry is enabled out
678
- * of the box). Set via {@link DDBModel.setRetryPolicy} / {@link setRetryPolicy};
724
+ * of the box). Set via `graphddb.config.retry` / {@link setRetryPolicy};
679
725
  * read at call time by the default {@link RetryingExecutor}.
680
726
  */
681
727
  static retryPolicy = null;
@@ -683,7 +729,7 @@ var ClientManager = class {
683
729
  * The host-runtime read-middleware registry (issue #50 / #138) — a process-wide
684
730
  * ordered list of {@link Middleware}, the same global-config + injectable-seam
685
731
  * pattern as {@link retryPolicy} / {@link executor}. Registered via
686
- * {@link DDBModel.use} / {@link use}, snapshotted per read, and NEVER serialized
732
+ * `graphddb.config.use` / {@link use}, snapshotted per read, and NEVER serialized
687
733
  * (it never touches the planner / spec / `operations.json`, so the bridge #48 is
688
734
  * unaffected). {@link reset} clears it.
689
735
  */
@@ -695,7 +741,7 @@ var ClientManager = class {
695
741
  static getClient() {
696
742
  if (!this.client) {
697
743
  throw new Error(
698
- "DynamoDB client is not configured. Call DDBModel.setClient(new DynamoDBClient({...})) first."
744
+ "DynamoDB client is not configured. Call graphddb.config.client(new DynamoDBClient({...})) first."
699
745
  );
700
746
  }
701
747
  return this.client;
@@ -757,12 +803,12 @@ var ClientManager = class {
757
803
  /**
758
804
  * Register a read {@link Middleware} (issue #50 / #138). Appended last, so its
759
805
  * `before*` hooks run last (FIFO) and its `afterFetch` / `onError` hooks run
760
- * first (LIFO). Mirrors {@link setRetryPolicy}; backs {@link DDBModel.use}.
806
+ * first (LIFO). Mirrors {@link setRetryPolicy}; backs `graphddb.config.use`.
761
807
  */
762
808
  static use(mw) {
763
809
  this.middleware.use(mw);
764
810
  }
765
- /** Remove every registered read middleware (teardown / tests). Backs {@link DDBModel.clearMiddleware}. */
811
+ /** Remove every registered read middleware (teardown / tests). Backs `graphddb.config.clearMiddleware`. */
766
812
  static clearMiddleware() {
767
813
  this.middleware.clear();
768
814
  }
@@ -785,194 +831,6 @@ var ClientManager = class {
785
831
  }
786
832
  };
787
833
 
788
- // src/executor/batch-executor.ts
789
- async function executeBatchGet(docClient, operation) {
790
- if (operation.keys.length === 0) {
791
- return { items: [] };
792
- }
793
- const chunks = chunkArray(operation.keys, BATCH_GET_MAX_KEYS);
794
- const chunkResults = await Promise.all(
795
- chunks.map(
796
- (chunk) => batchGetChunkWithRetry(docClient, operation.tableName, {
797
- Keys: chunk,
798
- ...operation.projectionExpression ? { ProjectionExpression: operation.projectionExpression } : {},
799
- ...operation.expressionAttributeNames ? { ExpressionAttributeNames: operation.expressionAttributeNames } : {},
800
- ...operation.consistentRead ? { ConsistentRead: true } : {}
801
- })
802
- )
803
- );
804
- const allItems = [];
805
- for (const chunkItems of chunkResults) {
806
- allItems.push(...chunkItems);
807
- }
808
- return { items: allItems };
809
- }
810
-
811
- // src/executor/dynamo-executor.ts
812
- var DynamoExecutor = class {
813
- async execute(operation, _options) {
814
- const docClient = await ClientManager.getDocumentClient();
815
- if (operation.type === "GetItem") {
816
- return executeGetItem(docClient, operation);
817
- }
818
- if (operation.type === "BatchGetItem") {
819
- return this.batchGet(operation);
820
- }
821
- return executeQuery(docClient, operation);
822
- }
823
- async batchGet(input, _options) {
824
- const docClient = await ClientManager.getDocumentClient();
825
- return executeBatchGet(docClient, { type: "BatchGetItem", ...input });
826
- }
827
- async put(input, options) {
828
- const docClient = await ClientManager.getDocumentClient();
829
- const { PutCommand } = await loadLibDynamoDB();
830
- const result = await docClient.send(
831
- new PutCommand(
832
- options?.returnOldImage ? { ...input, ReturnValues: "ALL_OLD" } : input
833
- )
834
- );
835
- return toWriteResult(result);
836
- }
837
- async update(input, options) {
838
- const docClient = await ClientManager.getDocumentClient();
839
- const { UpdateCommand } = await loadLibDynamoDB();
840
- const result = await docClient.send(
841
- new UpdateCommand(
842
- options?.returnOldImage ? { ...input, ReturnValues: "ALL_OLD" } : input
843
- )
844
- );
845
- return toWriteResult(result);
846
- }
847
- async delete(input, options) {
848
- const docClient = await ClientManager.getDocumentClient();
849
- const { DeleteCommand } = await loadLibDynamoDB();
850
- const result = await docClient.send(
851
- new DeleteCommand(
852
- options?.returnOldImage ? { ...input, ReturnValues: "ALL_OLD" } : input
853
- )
854
- );
855
- return toWriteResult(result);
856
- }
857
- async batchWrite(tableName, items, _options) {
858
- const docClient = await ClientManager.getDocumentClient();
859
- for (const chunk of chunkArray(items, BATCH_WRITE_MAX_ITEMS)) {
860
- await batchWriteChunkWithRetry(
861
- docClient,
862
- tableName,
863
- chunk,
864
- toBatchWriteCommandItem,
865
- matchUnprocessed
866
- );
867
- }
868
- }
869
- async transactWrite(items, _options) {
870
- if (items.length === 0) return;
871
- const docClient = await ClientManager.getDocumentClient();
872
- const { TransactWriteCommand } = await loadLibDynamoDB();
873
- await docClient.send(
874
- new TransactWriteCommand({ TransactItems: items })
875
- );
876
- }
877
- };
878
- function toWriteResult(result) {
879
- const oldItem = result.Attributes;
880
- return oldItem ? { oldItem } : {};
881
- }
882
- function toBatchWriteCommandItem(item) {
883
- if (item.type === "put") {
884
- return { PutRequest: { Item: item.item } };
885
- }
886
- return { DeleteRequest: { Key: item.key } };
887
- }
888
- function matchUnprocessed(unprocessed, items) {
889
- return unprocessed.map((u) => {
890
- if (u.PutRequest?.Item) {
891
- const { PK, SK } = u.PutRequest.Item;
892
- const match = items.find(
893
- (it) => it.type === "put" && it.item.PK === PK && it.item.SK === SK
894
- );
895
- if (!match) {
896
- throw new Error("Unable to match unprocessed BatchWrite put request");
897
- }
898
- return match;
899
- }
900
- if (u.DeleteRequest?.Key) {
901
- const { PK, SK } = u.DeleteRequest.Key;
902
- const match = items.find(
903
- (it) => it.type === "delete" && it.key.PK === PK && it.key.SK === SK
904
- );
905
- if (!match) {
906
- throw new Error("Unable to match unprocessed BatchWrite delete request");
907
- }
908
- return match;
909
- }
910
- throw new Error("Unsupported unprocessed BatchWrite request shape");
911
- });
912
- }
913
- async function executeGetItem(docClient, operation) {
914
- const { GetCommand } = await loadLibDynamoDB();
915
- const cmd = new GetCommand({
916
- TableName: operation.tableName,
917
- Key: operation.keyCondition,
918
- ...operation.projectionExpression ? { ProjectionExpression: operation.projectionExpression } : {},
919
- ...operation.expressionAttributeNames ? { ExpressionAttributeNames: operation.expressionAttributeNames } : {},
920
- ...operation.consistentRead ? { ConsistentRead: true } : {}
921
- });
922
- const result = await docClient.send(cmd);
923
- if (result.Item) {
924
- return { items: [result.Item] };
925
- }
926
- return { items: [] };
927
- }
928
- async function executeQuery(docClient, operation) {
929
- const { QueryCommand } = await loadLibDynamoDB();
930
- const keyEntries = Object.entries(operation.keyCondition);
931
- const conditionParts = [];
932
- const exprAttrNames = {
933
- ...operation.expressionAttributeNames ?? {},
934
- ...operation.filterExpressionAttributeNames ?? {}
935
- };
936
- const exprAttrValues = {
937
- ...operation.filterExpressionAttributeValues ?? {}
938
- };
939
- for (let i = 0; i < keyEntries.length; i++) {
940
- const [attrName, value] = keyEntries[i];
941
- const nameAlias2 = `#k${i}`;
942
- const valueAlias2 = `:k${i}`;
943
- exprAttrNames[nameAlias2] = attrName;
944
- exprAttrValues[valueAlias2] = value;
945
- conditionParts.push(`${nameAlias2} = ${valueAlias2}`);
946
- }
947
- if (operation.rangeCondition) {
948
- const rc = operation.rangeCondition;
949
- const idx = keyEntries.length;
950
- const nameAlias2 = `#k${idx}`;
951
- const valueAlias2 = `:k${idx}`;
952
- exprAttrNames[nameAlias2] = rc.key;
953
- exprAttrValues[valueAlias2] = rc.value;
954
- conditionParts.push(`begins_with(${nameAlias2}, ${valueAlias2})`);
955
- }
956
- const cmd = new QueryCommand({
957
- TableName: operation.tableName,
958
- KeyConditionExpression: conditionParts.join(" AND "),
959
- ExpressionAttributeNames: exprAttrNames,
960
- ExpressionAttributeValues: exprAttrValues,
961
- ...operation.indexName ? { IndexName: operation.indexName } : {},
962
- ...operation.filterExpression ? { FilterExpression: operation.filterExpression } : {},
963
- ...operation.projectionExpression ? { ProjectionExpression: operation.projectionExpression } : {},
964
- ...operation.scanIndexForward != null ? { ScanIndexForward: operation.scanIndexForward } : {},
965
- ...operation.limit != null ? { Limit: operation.limit } : {},
966
- ...operation.exclusiveStartKey ? { ExclusiveStartKey: operation.exclusiveStartKey } : {},
967
- ...operation.consistentRead ? { ConsistentRead: true } : {}
968
- });
969
- const result = await docClient.send(cmd);
970
- return {
971
- items: result.Items ?? [],
972
- lastEvaluatedKey: result.LastEvaluatedKey
973
- };
974
- }
975
-
976
834
  // src/select/cond.ts
977
835
  var RAW_COND = /* @__PURE__ */ Symbol.for("graphddb.rawCond");
978
836
  function isRawCondition(value) {
@@ -1038,17 +896,6 @@ function serializeRawCondition(raw, renderSlot) {
1038
896
  }
1039
897
  return { expression: out, names, values };
1040
898
  }
1041
- function collectRawConditionParams(raw) {
1042
- const out = [];
1043
- let paramIndex = 0;
1044
- for (const part of raw.parts) {
1045
- if (isColumn(part)) continue;
1046
- if (isParam(part)) {
1047
- out.push({ name: condParamName(paramIndex++), param: part });
1048
- }
1049
- }
1050
- return out;
1051
- }
1052
899
 
1053
900
  // src/expression/condition-expression.ts
1054
901
  var CONDITION_OPERATOR_KEYS = /* @__PURE__ */ new Set([
@@ -1298,69 +1145,6 @@ function serializeFieldValue(value, fieldMeta) {
1298
1145
  return value;
1299
1146
  }
1300
1147
 
1301
- // src/expression/update-expression.ts
1302
- function isPlainObject(value) {
1303
- return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date);
1304
- }
1305
- function flattenChanges(obj, parentPath, embeddedFieldNames) {
1306
- const sets = [];
1307
- const removes = [];
1308
- for (const [fieldName, value] of Object.entries(obj)) {
1309
- const currentPath = [...parentPath, fieldName];
1310
- if (value === void 0) {
1311
- removes.push({ path: currentPath });
1312
- } else if (embeddedFieldNames.has(currentPath[0]) && isPlainObject(value)) {
1313
- const nested = flattenChanges(value, currentPath, embeddedFieldNames);
1314
- sets.push(...nested.sets);
1315
- removes.push(...nested.removes);
1316
- } else {
1317
- sets.push({ path: currentPath, value });
1318
- }
1319
- }
1320
- return { sets, removes };
1321
- }
1322
- function buildUpdateExpression(changes, embeddedFieldNames) {
1323
- const { sets, removes } = flattenChanges(changes, [], embeddedFieldNames);
1324
- if (sets.length === 0 && removes.length === 0) {
1325
- throw new Error("No changes provided for update.");
1326
- }
1327
- const names = {};
1328
- const values = {};
1329
- let valueCounter = 0;
1330
- const setClauses = [];
1331
- for (const op of sets) {
1332
- const pathExpr = op.path.map((segment) => {
1333
- const nameKey = `#${segment}`;
1334
- names[nameKey] = segment;
1335
- return nameKey;
1336
- }).join(".");
1337
- const valueKey = `:val${valueCounter++}`;
1338
- values[valueKey] = op.value;
1339
- setClauses.push(`${pathExpr} = ${valueKey}`);
1340
- }
1341
- const removeClauses = [];
1342
- for (const op of removes) {
1343
- const pathExpr = op.path.map((segment) => {
1344
- const nameKey = `#${segment}`;
1345
- names[nameKey] = segment;
1346
- return nameKey;
1347
- }).join(".");
1348
- removeClauses.push(pathExpr);
1349
- }
1350
- const parts = [];
1351
- if (setClauses.length > 0) {
1352
- parts.push(`SET ${setClauses.join(", ")}`);
1353
- }
1354
- if (removeClauses.length > 0) {
1355
- parts.push(`REMOVE ${removeClauses.join(", ")}`);
1356
- }
1357
- return {
1358
- expression: parts.join(" "),
1359
- names,
1360
- values
1361
- };
1362
- }
1363
-
1364
1148
  // src/metadata/segments.ts
1365
1149
  var SEGMENT_DELIMITER = "#";
1366
1150
  function segmentComplete(segment, values) {
@@ -1500,34 +1284,6 @@ function missingGsiFields(gsi2, available) {
1500
1284
  return gsi2.inputFieldNames.filter((f) => available[f] === void 0);
1501
1285
  }
1502
1286
 
1503
- // src/hydrator/hidden-key.ts
1504
- var GRAPHDDB_KEY = /* @__PURE__ */ Symbol("graphddb:key");
1505
- function attachHiddenKey(result, raw) {
1506
- const pk = raw.PK;
1507
- if (typeof pk !== "string") {
1508
- throw new Error(
1509
- "Cannot attach updatable key: raw item is missing a string `PK` attribute."
1510
- );
1511
- }
1512
- const sk = raw.SK;
1513
- const value = {
1514
- PK: pk,
1515
- SK: typeof sk === "string" ? sk : ""
1516
- };
1517
- Object.defineProperty(result, GRAPHDDB_KEY, {
1518
- value,
1519
- enumerable: false,
1520
- writable: false,
1521
- configurable: false
1522
- });
1523
- return result;
1524
- }
1525
- function readHiddenKey(entity) {
1526
- const value = entity[GRAPHDDB_KEY];
1527
- if (value === void 0) return void 0;
1528
- return value;
1529
- }
1530
-
1531
1287
  // src/capture/registry.ts
1532
1288
  var ChangeCaptureRegistryImpl = class {
1533
1289
  subscribers = /* @__PURE__ */ new Set();
@@ -1869,102 +1625,187 @@ function ctxModelFor(modelClass) {
1869
1625
  return resolved;
1870
1626
  }
1871
1627
 
1872
- // src/operations/put.ts
1873
- function buildPutInput(modelClass, item, options) {
1874
- const meta = MetadataRegistry.get(modelClass);
1875
- if (!meta.primaryKey) {
1628
+ // src/expression/update-expression.ts
1629
+ function isPlainObject(value) {
1630
+ return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date);
1631
+ }
1632
+ function flattenChanges(obj, parentPath, embeddedFieldNames) {
1633
+ const sets = [];
1634
+ const removes = [];
1635
+ for (const [fieldName, value] of Object.entries(obj)) {
1636
+ const currentPath = [...parentPath, fieldName];
1637
+ if (value === void 0) {
1638
+ removes.push({ path: currentPath });
1639
+ } else if (embeddedFieldNames.has(currentPath[0]) && isPlainObject(value)) {
1640
+ const nested = flattenChanges(value, currentPath, embeddedFieldNames);
1641
+ sets.push(...nested.sets);
1642
+ removes.push(...nested.removes);
1643
+ } else {
1644
+ sets.push({ path: currentPath, value });
1645
+ }
1646
+ }
1647
+ return { sets, removes };
1648
+ }
1649
+ function buildUpdateExpression(changes, embeddedFieldNames) {
1650
+ const { sets, removes } = flattenChanges(changes, [], embeddedFieldNames);
1651
+ if (sets.length === 0 && removes.length === 0) {
1652
+ throw new Error("No changes provided for update.");
1653
+ }
1654
+ const names = {};
1655
+ const values = {};
1656
+ let valueCounter = 0;
1657
+ const setClauses = [];
1658
+ for (const op of sets) {
1659
+ const pathExpr = op.path.map((segment) => {
1660
+ const nameKey = `#${segment}`;
1661
+ names[nameKey] = segment;
1662
+ return nameKey;
1663
+ }).join(".");
1664
+ const valueKey = `:val${valueCounter++}`;
1665
+ values[valueKey] = op.value;
1666
+ setClauses.push(`${pathExpr} = ${valueKey}`);
1667
+ }
1668
+ const removeClauses = [];
1669
+ for (const op of removes) {
1670
+ const pathExpr = op.path.map((segment) => {
1671
+ const nameKey = `#${segment}`;
1672
+ names[nameKey] = segment;
1673
+ return nameKey;
1674
+ }).join(".");
1675
+ removeClauses.push(pathExpr);
1676
+ }
1677
+ const parts = [];
1678
+ if (setClauses.length > 0) {
1679
+ parts.push(`SET ${setClauses.join(", ")}`);
1680
+ }
1681
+ if (removeClauses.length > 0) {
1682
+ parts.push(`REMOVE ${removeClauses.join(", ")}`);
1683
+ }
1684
+ return {
1685
+ expression: parts.join(" "),
1686
+ names,
1687
+ values
1688
+ };
1689
+ }
1690
+
1691
+ // src/hydrator/hidden-key.ts
1692
+ var GRAPHDDB_KEY = /* @__PURE__ */ Symbol("graphddb:key");
1693
+ function attachHiddenKey(result, raw) {
1694
+ const pk = raw.PK;
1695
+ if (typeof pk !== "string") {
1876
1696
  throw new Error(
1877
- `No primary key defined for '${modelClass.name}'. Ensure the model has a static \`keys = key(...)\` definition.`
1697
+ "Cannot attach updatable key: raw item is missing a string `PK` attribute."
1878
1698
  );
1879
1699
  }
1880
- const tableName = TableMapping.resolve(meta.tableName);
1881
- const serialized = {};
1882
- for (const fieldMeta of meta.fields) {
1883
- const value = item[fieldMeta.propertyName];
1884
- if (value !== void 0) {
1885
- serialized[fieldMeta.propertyName] = serializeFieldValue(value, fieldMeta);
1886
- }
1700
+ const sk = raw.SK;
1701
+ const value = {
1702
+ PK: pk,
1703
+ SK: typeof sk === "string" ? sk : ""
1704
+ };
1705
+ Object.defineProperty(result, GRAPHDDB_KEY, {
1706
+ value,
1707
+ enumerable: false,
1708
+ writable: false,
1709
+ configurable: false
1710
+ });
1711
+ return result;
1712
+ }
1713
+ function readHiddenKey(entity) {
1714
+ const value = entity[GRAPHDDB_KEY];
1715
+ if (value === void 0) return void 0;
1716
+ return value;
1717
+ }
1718
+
1719
+ // src/operations/update.ts
1720
+ function resolveUpdateKey(modelClass, primaryKey, entity, fieldMap) {
1721
+ const hidden = readHiddenKey(entity);
1722
+ if (hidden) {
1723
+ return { pk: hidden.PK, sk: hidden.SK };
1724
+ }
1725
+ const missing = primaryKey.inputFieldNames.filter(
1726
+ (f) => entity[f] === void 0
1727
+ );
1728
+ if (missing.length > 0) {
1729
+ const fieldList = missing.join(", ");
1730
+ throw new Error(
1731
+ `Cannot resolve update key for '${modelClass.name}': missing key field(s) [${fieldList}]. Provide an explicit key (update({ ${fieldList} }, changes)), select all key fields, or query with { updatable: true }.`
1732
+ );
1887
1733
  }
1888
1734
  const keyInput = {};
1889
- for (const fieldName of meta.primaryKey.inputFieldNames) {
1890
- keyInput[fieldName] = serialized[fieldName] ?? item[fieldName];
1735
+ for (const fieldName of primaryKey.inputFieldNames) {
1736
+ let value = entity[fieldName];
1737
+ const fieldMeta = fieldMap.get(fieldName);
1738
+ if (fieldMeta && value !== void 0) {
1739
+ value = serializeFieldValue(value, fieldMeta);
1740
+ }
1741
+ keyInput[fieldName] = value;
1891
1742
  }
1892
1743
  const { pk, sk } = resolveSegmentedKey(
1893
- meta.primaryKey.segmented,
1744
+ primaryKey.segmented,
1894
1745
  keyInput,
1895
1746
  `${modelClass.name} primary key`
1896
1747
  );
1897
- const dynamoItem = {
1898
- PK: pk,
1899
- SK: sk ?? "",
1900
- ...serialized
1901
- };
1902
- for (const emb of meta.embeddedFields) {
1903
- const value = item[emb.propertyName];
1904
- if (value !== void 0) {
1905
- dynamoItem[emb.propertyName] = value;
1906
- }
1907
- }
1748
+ return { pk, sk: sk ?? "" };
1749
+ }
1750
+ function buildGsiRederiveSets(modelClass, meta, entity, serializedChanges, fieldMap, names, values, options) {
1751
+ const changedFields = new Set(Object.keys(serializedChanges));
1752
+ const affected = affectedGsis(meta, changedFields);
1753
+ if (affected.length === 0) return [];
1908
1754
  const available = {};
1909
- for (const fieldName of meta.fields.map((f) => f.propertyName)) {
1910
- available[fieldName] = serialized[fieldName] ?? item[fieldName];
1755
+ for (const [fieldName, raw] of Object.entries(entity)) {
1756
+ if (raw === void 0) continue;
1757
+ const fm = fieldMap.get(fieldName);
1758
+ available[fieldName] = fm ? serializeFieldValue(raw, fm) : raw;
1911
1759
  }
1912
- for (const gsi2 of meta.gsiDefinitions) {
1913
- const { pkAttr, pk: gsiPk, skAttr, sk: gsiSk } = deriveGsiKey(
1760
+ Object.assign(available, serializedChanges);
1761
+ const clauses = [];
1762
+ let i = 0;
1763
+ for (const gsi2 of affected) {
1764
+ const missing = missingGsiFields(gsi2, available);
1765
+ if (missing.length > 0) {
1766
+ if (options?.rederive === "read-modify-write") continue;
1767
+ const changed = gsi2.inputFieldNames.filter((f) => changedFields.has(f));
1768
+ throw new Error(
1769
+ `Cannot update '${modelClass.name}': updating ${fmtFields(changed)} affects index '${gsi2.indexName}' (also depends on ${fmtFields(missing)}); provide ${missing.length > 1 ? "them" : "it"}, or use { rederive: 'read-modify-write' }.`
1770
+ );
1771
+ }
1772
+ const { pkAttr, pk, skAttr, sk } = deriveGsiKey(
1914
1773
  gsi2,
1915
1774
  available,
1916
1775
  `${modelClass.name} GSI '${gsi2.indexName}'`
1917
1776
  );
1918
- dynamoItem[pkAttr] = gsiPk;
1919
- dynamoItem[skAttr] = gsiSk;
1920
- }
1921
- const putInput = {
1922
- TableName: tableName,
1923
- Item: dynamoItem
1924
- };
1925
- if (options?.condition) {
1926
- const condResult = buildConditionExpression(options.condition);
1927
- putInput.ConditionExpression = condResult.expression;
1928
- if (Object.keys(condResult.names).length > 0) {
1929
- putInput.ExpressionAttributeNames = condResult.names;
1930
- }
1931
- if (Object.keys(condResult.values).length > 0) {
1932
- putInput.ExpressionAttributeValues = condResult.values;
1933
- }
1777
+ const pkN = `#gsi${i}pk`;
1778
+ const pkV = `:gsi${i}pk`;
1779
+ names[pkN] = pkAttr;
1780
+ values[pkV] = pk;
1781
+ clauses.push(`${pkN} = ${pkV}`);
1782
+ const skN = `#gsi${i}sk`;
1783
+ const skV = `:gsi${i}sk`;
1784
+ names[skN] = skAttr;
1785
+ values[skV] = sk;
1786
+ clauses.push(`${skN} = ${skV}`);
1787
+ i++;
1934
1788
  }
1935
- return putInput;
1789
+ return clauses;
1936
1790
  }
1937
- async function executePut(modelClass, item, options) {
1938
- const mw = writeRuntimeFor(options);
1939
- if (mw) {
1940
- await executeSingleWriteWithHooks({ kind: "put", modelClass, item, options }, mw);
1941
- return;
1942
- }
1943
- await executePutCore(modelClass, item, options);
1791
+ function fmtFields(fields) {
1792
+ return fields.map((f) => `'${f}'`).join(", ");
1944
1793
  }
1945
- async function executePutCore(modelClass, item, options, persistVia, forceOldImage) {
1946
- const putInput = buildPutInput(modelClass, item, options);
1947
- const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass) || forceOldImage === true;
1948
- const send = (input) => ClientManager.getExecutor().put(input, {
1949
- ...wantsOld ? { returnOldImage: true } : {},
1950
- ...options?.retry !== void 0 ? { retry: options.retry } : {}
1951
- });
1952
- const result = persistVia ? await persistVia(putInput, send) : await send(putInput);
1953
- if (ChangeCaptureRegistry.hasSubscribers()) {
1954
- const oldItem = result.oldItem;
1955
- captureWrite({
1956
- table: putInput.TableName,
1957
- model: modelClass.name,
1958
- op: "put",
1959
- keys: { pk: String(putInput.Item.PK), sk: String(putInput.Item.SK ?? "") },
1960
- newItem: putInput.Item,
1961
- ...oldItem ? { oldItem } : {}
1962
- });
1794
+ function appendSetClauses(expression, clauses) {
1795
+ if (clauses.length === 0) return expression;
1796
+ const extra = clauses.join(", ");
1797
+ if (/(^|\s)SET\s/.test(expression)) {
1798
+ const removeIdx = expression.indexOf(" REMOVE ");
1799
+ if (removeIdx === -1) {
1800
+ return `${expression}, ${extra}`;
1801
+ }
1802
+ const setPart = expression.slice(0, removeIdx);
1803
+ const rest = expression.slice(removeIdx);
1804
+ return `${setPart}, ${extra}${rest}`;
1963
1805
  }
1806
+ return `SET ${extra} ${expression}`.trimEnd();
1964
1807
  }
1965
-
1966
- // src/operations/delete.ts
1967
- function buildDeleteInput(modelClass, keyObj, options) {
1808
+ function buildUpdateInput(modelClass, entity, changes, options) {
1968
1809
  const meta = MetadataRegistry.get(modelClass);
1969
1810
  if (!meta.primaryKey) {
1970
1811
  throw new Error(
@@ -1975,259 +1816,12 @@ function buildDeleteInput(modelClass, keyObj, options) {
1975
1816
  const fieldMap = new Map(
1976
1817
  meta.fields.map((f) => [f.propertyName, f])
1977
1818
  );
1978
- const keyInput = {};
1979
- for (const fieldName of meta.primaryKey.inputFieldNames) {
1980
- let value = keyObj[fieldName];
1819
+ for (const fieldName of Object.keys(changes)) {
1981
1820
  const fieldMeta = fieldMap.get(fieldName);
1982
- if (fieldMeta && value !== void 0) {
1983
- value = serializeFieldValue(value, fieldMeta);
1984
- }
1985
- keyInput[fieldName] = value;
1986
- }
1987
- const { pk, sk } = resolveSegmentedKey(
1988
- meta.primaryKey.segmented,
1989
- keyInput,
1990
- `${modelClass.name} primary key`
1991
- );
1992
- const deleteInput = {
1993
- TableName: tableName,
1994
- Key: {
1995
- PK: pk,
1996
- SK: sk ?? ""
1997
- }
1998
- };
1999
- if (options?.condition) {
2000
- const condResult = buildConditionExpression(options.condition);
2001
- deleteInput.ConditionExpression = condResult.expression;
2002
- if (Object.keys(condResult.names).length > 0) {
2003
- deleteInput.ExpressionAttributeNames = condResult.names;
2004
- }
2005
- if (Object.keys(condResult.values).length > 0) {
2006
- deleteInput.ExpressionAttributeValues = condResult.values;
2007
- }
2008
- }
2009
- return deleteInput;
2010
- }
2011
- async function executeDelete(modelClass, keyObj, options) {
2012
- const mw = writeRuntimeFor(options);
2013
- if (mw) {
2014
- await executeSingleWriteWithHooks({ kind: "delete", modelClass, key: keyObj, options }, mw);
2015
- return;
2016
- }
2017
- await executeDeleteCore(modelClass, keyObj, options);
2018
- }
2019
- async function executeDeleteCore(modelClass, keyObj, options, persistVia, forceOldImage) {
2020
- const deleteInput = buildDeleteInput(modelClass, keyObj, options);
2021
- const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass) || forceOldImage === true;
2022
- const send = (input) => ClientManager.getExecutor().delete(input, {
2023
- ...wantsOld ? { returnOldImage: true } : {},
2024
- ...options?.retry !== void 0 ? { retry: options.retry } : {}
2025
- });
2026
- const result = persistVia ? await persistVia(deleteInput, send) : await send(deleteInput);
2027
- if (ChangeCaptureRegistry.hasSubscribers()) {
2028
- const oldItem = result.oldItem;
2029
- captureWrite({
2030
- table: deleteInput.TableName,
2031
- model: modelClass.name,
2032
- op: "delete",
2033
- keys: { pk: String(deleteInput.Key.PK), sk: String(deleteInput.Key.SK ?? "") },
2034
- ...oldItem ? { oldItem } : {}
2035
- });
2036
- }
2037
- }
2038
-
2039
- // src/operations/write-hooks.ts
2040
- function writeRuntimeFor(options) {
2041
- const mw = buildWriteRuntime(options?.context);
2042
- return mw.active ? mw : void 0;
2043
- }
2044
- async function executeSingleWriteWithHooks(req, mw) {
2045
- const input = {};
2046
- if (req.item !== void 0) input.item = { ...req.item };
2047
- if (req.key !== void 0) input.key = { ...req.key };
2048
- if (req.changes !== void 0) input.changes = { ...req.changes };
2049
- if (req.options !== void 0) input.options = { ...req.options };
2050
- const ctx = mw.writeCtx(req.kind, ctxModelFor(req.modelClass), input);
2051
- try {
2052
- await mw.runWriteBefore(ctx);
2053
- const change2 = await persistRewrittenWrite(req.modelClass, ctx, mw);
2054
- await mw.runWriteAfter(ctx, change2);
2055
- } catch (err) {
2056
- await mw.runWriteError(ctx, err);
2057
- }
2058
- }
2059
- async function persistRewrittenWrite(modelClass, ctx, mw) {
2060
- const opts = ctx.input.options;
2061
- const origin = { model: ctx.model, kind: ctx.kind };
2062
- const forceOldImage = mw.hasWriteAfter;
2063
- const cap = {};
2064
- if (ctx.kind === "put") {
2065
- await executePutCore(
2066
- modelClass,
2067
- ctx.input.item ?? {},
2068
- opts,
2069
- (input, send) => runPersist(mw, { Put: input }, origin, cap, (it) => {
2070
- const put = it.Put;
2071
- cap.newImage = put.Item;
2072
- return send(put);
2073
- }),
2074
- forceOldImage
2075
- );
2076
- return change(cap, true);
2077
- }
2078
- if (ctx.kind === "update") {
2079
- await executeUpdateCore(
2080
- modelClass,
2081
- ctx.input.key ?? {},
2082
- ctx.input.changes ?? {},
2083
- opts,
2084
- (input, send) => runPersist(mw, { Update: input }, origin, cap, (it) => {
2085
- const upd = it.Update;
2086
- const old = cap.result?.oldItem;
2087
- cap.newImage = { ...old ?? upd.Key, ...ctx.input.changes ?? {} };
2088
- return send(upd);
2089
- }),
2090
- forceOldImage
2091
- );
2092
- return change(cap, true);
2093
- }
2094
- await executeDeleteCore(
2095
- modelClass,
2096
- ctx.input.key ?? {},
2097
- opts,
2098
- (input, send) => runPersist(
2099
- mw,
2100
- { Delete: input },
2101
- origin,
2102
- cap,
2103
- (it) => send(it.Delete)
2104
- ),
2105
- forceOldImage
2106
- );
2107
- return change(cap, false);
2108
- }
2109
- function change(cap, withNew) {
2110
- const out = {};
2111
- const oldImage = cap.result?.oldItem;
2112
- if (oldImage !== void 0) out.oldImage = oldImage;
2113
- if (withNew && cap.newImage !== void 0) {
2114
- out.newImage = cap.newImage;
2115
- }
2116
- return out;
2117
- }
2118
- async function runPersist(mw, item, origin, cap, send) {
2119
- const ctx = mw.persistCtx([item], [origin]);
2120
- const result = await mw.runPersist(ctx, async (items) => send(items[0]));
2121
- cap.result = result;
2122
- return result;
2123
- }
2124
-
2125
- // src/operations/update.ts
2126
- function resolveUpdateKey(modelClass, primaryKey, entity, fieldMap) {
2127
- const hidden = readHiddenKey(entity);
2128
- if (hidden) {
2129
- return { pk: hidden.PK, sk: hidden.SK };
2130
- }
2131
- const missing = primaryKey.inputFieldNames.filter(
2132
- (f) => entity[f] === void 0
2133
- );
2134
- if (missing.length > 0) {
2135
- const fieldList = missing.join(", ");
2136
- throw new Error(
2137
- `Cannot resolve update key for '${modelClass.name}': missing key field(s) [${fieldList}]. Provide an explicit key (update({ ${fieldList} }, changes)), select all key fields, or query with { updatable: true }.`
2138
- );
2139
- }
2140
- const keyInput = {};
2141
- for (const fieldName of primaryKey.inputFieldNames) {
2142
- let value = entity[fieldName];
2143
- const fieldMeta = fieldMap.get(fieldName);
2144
- if (fieldMeta && value !== void 0) {
2145
- value = serializeFieldValue(value, fieldMeta);
2146
- }
2147
- keyInput[fieldName] = value;
2148
- }
2149
- const { pk, sk } = resolveSegmentedKey(
2150
- primaryKey.segmented,
2151
- keyInput,
2152
- `${modelClass.name} primary key`
2153
- );
2154
- return { pk, sk: sk ?? "" };
2155
- }
2156
- function buildGsiRederiveSets(modelClass, meta, entity, serializedChanges, fieldMap, names, values, options) {
2157
- const changedFields = new Set(Object.keys(serializedChanges));
2158
- const affected = affectedGsis(meta, changedFields);
2159
- if (affected.length === 0) return [];
2160
- const available = {};
2161
- for (const [fieldName, raw] of Object.entries(entity)) {
2162
- if (raw === void 0) continue;
2163
- const fm = fieldMap.get(fieldName);
2164
- available[fieldName] = fm ? serializeFieldValue(raw, fm) : raw;
2165
- }
2166
- Object.assign(available, serializedChanges);
2167
- const clauses = [];
2168
- let i = 0;
2169
- for (const gsi2 of affected) {
2170
- const missing = missingGsiFields(gsi2, available);
2171
- if (missing.length > 0) {
2172
- if (options?.rederive === "read-modify-write") continue;
2173
- const changed = gsi2.inputFieldNames.filter((f) => changedFields.has(f));
2174
- throw new Error(
2175
- `Cannot update '${modelClass.name}': updating ${fmtFields(changed)} affects index '${gsi2.indexName}' (also depends on ${fmtFields(missing)}); provide ${missing.length > 1 ? "them" : "it"}, or use { rederive: 'read-modify-write' }.`
2176
- );
2177
- }
2178
- const { pkAttr, pk, skAttr, sk } = deriveGsiKey(
2179
- gsi2,
2180
- available,
2181
- `${modelClass.name} GSI '${gsi2.indexName}'`
2182
- );
2183
- const pkN = `#gsi${i}pk`;
2184
- const pkV = `:gsi${i}pk`;
2185
- names[pkN] = pkAttr;
2186
- values[pkV] = pk;
2187
- clauses.push(`${pkN} = ${pkV}`);
2188
- const skN = `#gsi${i}sk`;
2189
- const skV = `:gsi${i}sk`;
2190
- names[skN] = skAttr;
2191
- values[skV] = sk;
2192
- clauses.push(`${skN} = ${skV}`);
2193
- i++;
2194
- }
2195
- return clauses;
2196
- }
2197
- function fmtFields(fields) {
2198
- return fields.map((f) => `'${f}'`).join(", ");
2199
- }
2200
- function appendSetClauses(expression, clauses) {
2201
- if (clauses.length === 0) return expression;
2202
- const extra = clauses.join(", ");
2203
- if (/(^|\s)SET\s/.test(expression)) {
2204
- const removeIdx = expression.indexOf(" REMOVE ");
2205
- if (removeIdx === -1) {
2206
- return `${expression}, ${extra}`;
2207
- }
2208
- const setPart = expression.slice(0, removeIdx);
2209
- const rest = expression.slice(removeIdx);
2210
- return `${setPart}, ${extra}${rest}`;
2211
- }
2212
- return `SET ${extra} ${expression}`.trimEnd();
2213
- }
2214
- function buildUpdateInput(modelClass, entity, changes, options) {
2215
- const meta = MetadataRegistry.get(modelClass);
2216
- if (!meta.primaryKey) {
2217
- throw new Error(
2218
- `No primary key defined for '${modelClass.name}'. Ensure the model has a static \`keys = key(...)\` definition.`
2219
- );
2220
- }
2221
- const tableName = TableMapping.resolve(meta.tableName);
2222
- const fieldMap = new Map(
2223
- meta.fields.map((f) => [f.propertyName, f])
2224
- );
2225
- for (const fieldName of Object.keys(changes)) {
2226
- const fieldMeta = fieldMap.get(fieldName);
2227
- if (fieldMeta?.options?.readonly) {
2228
- throw new Error(
2229
- `Cannot update readonly field '${fieldName}' on '${modelClass.name}'. Readonly fields can only be set during put.`
2230
- );
1821
+ if (fieldMeta?.options?.readonly) {
1822
+ throw new Error(
1823
+ `Cannot update readonly field '${fieldName}' on '${modelClass.name}'. Readonly fields can only be set during put.`
1824
+ );
2231
1825
  }
2232
1826
  }
2233
1827
  const { pk, sk } = resolveUpdateKey(
@@ -2400,1391 +1994,448 @@ async function executeUpdateCore(modelClass, entity, changes, options, persistVi
2400
1994
  }
2401
1995
  }
2402
1996
 
2403
- // src/runtime/same-key-collapse.ts
2404
- function collapseSameKey(items, view) {
2405
- const groups = /* @__PURE__ */ new Map();
2406
- for (const item of items) {
2407
- const sig = view.keySignature(item);
2408
- const bucket = groups.get(sig);
2409
- if (bucket === void 0) groups.set(sig, [item]);
2410
- else bucket.push(item);
2411
- }
2412
- const replacement = /* @__PURE__ */ new Map();
2413
- const dropped = /* @__PURE__ */ new Set();
2414
- for (const bucket of groups.values()) {
2415
- if (bucket.length === 1) continue;
2416
- const kinds = new Set(bucket.map((it) => view.kind(it)));
2417
- if (kinds.size === 2 && kinds.has("Delete") && kinds.has("Put")) {
2418
- for (const it of bucket) dropped.add(it);
2419
- continue;
2420
- }
2421
- if (kinds.size === 2 && kinds.has("Put") && kinds.has("Update")) {
2422
- throw new Error(
2423
- `Contract runtime: a derived counter (\`increment\`) resolves to the SAME item as the entity write at runtime (the call-time key values made an aliased target coincide with the written row). A Put+Update on one transaction item is unsupported \u2014 DynamoDB rejects touching one key twice, and collapsing it would silently drop the increment. A \`w.increment(...)\` must target a DIFFERENT row (e.g. a parent aggregate's counter); to maintain a counter ON the written entity, write the field as part of the entity itself, not via a derived counter onto its own row.`
2424
- );
2425
- }
2426
- if (kinds.size === 1 && kinds.has("Update")) {
2427
- replacement.set(bucket[0], view.mergeAdds(bucket));
2428
- for (const it of bucket.slice(1)) dropped.add(it);
2429
- continue;
2430
- }
2431
- if (kinds.size === 1 && (kinds.has("Put") || kinds.has("Delete"))) {
2432
- for (const it of bucket.slice(1)) dropped.add(it);
2433
- continue;
2434
- }
1997
+ // src/operations/delete.ts
1998
+ function buildDeleteInput(modelClass, keyObj, options) {
1999
+ const meta = MetadataRegistry.get(modelClass);
2000
+ if (!meta.primaryKey) {
2435
2001
  throw new Error(
2436
- `Contract runtime: ${bucket.length} transaction items resolve to the same physical row with an unsupported op combination (${[...kinds].sort().join("+")}). A TransactWriteItems may not touch one key twice; this collision is not a known collapse shape (self-row Put/Delete de-dup, swap no-op, or symmetric counter ADDs), so it is rejected rather than silently collapsed.`
2437
- );
2438
- }
2439
- const out = [];
2440
- for (const item of items) {
2441
- if (dropped.has(item)) continue;
2442
- out.push(replacement.get(item) ?? item);
2443
- }
2444
- return out;
2445
- }
2446
-
2447
- // src/runtime/transaction-commit.ts
2448
- var MAX_TRANSACT_ITEMS = 25;
2449
- var execItemCollapseView = {
2450
- kind: execItemKind,
2451
- keySignature: execItemKeySignature,
2452
- mergeAdds: mergeAddUpdates
2453
- };
2454
- function collapseSameKeyItems(items) {
2455
- return collapseSameKey(items, execItemCollapseView);
2456
- }
2457
- async function commitTransaction(items, options = {}) {
2458
- const collapsed = collapseSameKeyItems(items);
2459
- if (collapsed.length === 0) return collapsed;
2460
- if (collapsed.length > MAX_TRANSACT_ITEMS) {
2461
- throw options.limitError ? options.limitError(collapsed.length) : new Error(
2462
- `Transaction exceeds DynamoDB limit of ${MAX_TRANSACT_ITEMS} items`
2002
+ `No primary key defined for '${modelClass.name}'. Ensure the model has a static \`keys = key(...)\` definition.`
2463
2003
  );
2464
2004
  }
2465
- const sendBatch = (items2) => ClientManager.getExecutor().transactWrite(
2466
- items2,
2467
- options.retry !== void 0 ? { retry: options.retry } : void 0
2005
+ const tableName = TableMapping.resolve(meta.tableName);
2006
+ const fieldMap = new Map(
2007
+ meta.fields.map((f) => [f.propertyName, f])
2468
2008
  );
2469
- let committed = collapsed;
2470
- if (options.middleware) {
2471
- const { runtime, origins, transaction } = options.middleware;
2472
- const persistCtx = runtime.persistCtx(collapsed, origins, transaction);
2473
- await runtime.runPersist(persistCtx, async (items2) => sendBatch(items2));
2474
- committed = persistCtx.items;
2475
- } else {
2476
- await sendBatch(collapsed);
2477
- }
2478
- captureTransactItems(committed, options.modelBySignature ?? EMPTY_MODEL_MAP);
2479
- return committed;
2480
- }
2481
- var EMPTY_MODEL_MAP = /* @__PURE__ */ new Map();
2482
- function captureTransactItems(items, modelBySignature) {
2483
- if (!ChangeCaptureRegistry.hasSubscribers()) return;
2484
- for (const item of items) {
2485
- if ("ConditionCheck" in item) continue;
2486
- captureWrite(captureMetaForItem(item, modelBySignature));
2487
- }
2488
- }
2489
- function captureMetaForItem(item, modelBySignature) {
2490
- const model = modelBySignature.get(execItemKeySignature(item));
2491
- const labelled = model !== void 0 ? { model } : {};
2492
- if ("Put" in item) {
2493
- const Item = item.Put.Item;
2494
- return {
2495
- table: item.Put.TableName,
2496
- ...labelled,
2497
- op: "put",
2498
- keys: { pk: String(Item.PK), sk: String(Item.SK ?? "") },
2499
- newItem: Item
2500
- };
2501
- }
2502
- if ("Update" in item) {
2503
- return {
2504
- table: item.Update.TableName,
2505
- ...labelled,
2506
- op: "update",
2507
- keys: { pk: String(item.Update.Key.PK), sk: String(item.Update.Key.SK ?? "") }
2508
- };
2009
+ const keyInput = {};
2010
+ for (const fieldName of meta.primaryKey.inputFieldNames) {
2011
+ let value = keyObj[fieldName];
2012
+ const fieldMeta = fieldMap.get(fieldName);
2013
+ if (fieldMeta && value !== void 0) {
2014
+ value = serializeFieldValue(value, fieldMeta);
2015
+ }
2016
+ keyInput[fieldName] = value;
2509
2017
  }
2510
- return {
2511
- table: item.Delete.TableName,
2512
- ...labelled,
2513
- op: "delete",
2514
- keys: { pk: String(item.Delete.Key.PK), sk: String(item.Delete.Key.SK ?? "") }
2018
+ const { pk, sk } = resolveSegmentedKey(
2019
+ meta.primaryKey.segmented,
2020
+ keyInput,
2021
+ `${modelClass.name} primary key`
2022
+ );
2023
+ const deleteInput = {
2024
+ TableName: tableName,
2025
+ Key: {
2026
+ PK: pk,
2027
+ SK: sk ?? ""
2028
+ }
2515
2029
  };
2516
- }
2517
- function execItemKeySignature(item) {
2518
- const part = (rec) => `${String(rec?.PK ?? "")}#${String(rec?.SK ?? "")}`;
2519
- if ("Put" in item) return `${item.Put.TableName}#${part(item.Put.Item)}`;
2520
- if ("Update" in item) return `${item.Update.TableName}#${part(item.Update.Key)}`;
2521
- if ("Delete" in item) return `${item.Delete.TableName}#${part(item.Delete.Key)}`;
2522
- return `${item.ConditionCheck.TableName}#${part(item.ConditionCheck.Key)}`;
2523
- }
2524
- function execItemKind(item) {
2525
- if ("Put" in item) return "Put";
2526
- if ("Update" in item) return "Update";
2527
- if ("Delete" in item) return "Delete";
2528
- return "ConditionCheck";
2529
- }
2530
- function mergeAddUpdates(updates) {
2531
- const first = updates[0];
2532
- if (!("Update" in first)) {
2533
- throw new Error("Contract runtime: mergeAddUpdates received a non-Update item.");
2534
- }
2535
- const deltas = /* @__PURE__ */ new Map();
2536
- for (const it of updates) {
2537
- if (!("Update" in it)) {
2538
- throw new Error("Contract runtime: cannot merge a non-Update item into an ADD.");
2030
+ if (options?.condition) {
2031
+ const condResult = buildConditionExpression(options.condition);
2032
+ deleteInput.ConditionExpression = condResult.expression;
2033
+ if (Object.keys(condResult.names).length > 0) {
2034
+ deleteInput.ExpressionAttributeNames = condResult.names;
2539
2035
  }
2540
- const names2 = it.Update.ExpressionAttributeNames ?? {};
2541
- const values2 = it.Update.ExpressionAttributeValues ?? {};
2542
- for (const [namePlaceholder, attr] of Object.entries(names2)) {
2543
- const valuePlaceholder = `:${namePlaceholder.slice(1)}`;
2544
- const delta = values2[valuePlaceholder];
2545
- if (typeof delta !== "number") {
2546
- throw new Error(
2547
- `Contract runtime: cannot merge same-key counter updates \u2014 an ADD delta on '${attr}' is not numeric.`
2548
- );
2549
- }
2550
- deltas.set(attr, (deltas.get(attr) ?? 0) + delta);
2036
+ if (Object.keys(condResult.values).length > 0) {
2037
+ deleteInput.ExpressionAttributeValues = condResult.values;
2551
2038
  }
2552
2039
  }
2553
- const names = {};
2554
- const values = {};
2555
- const clauses = [];
2556
- let i = 0;
2557
- for (const [attr, delta] of deltas) {
2558
- names[`#a${i}`] = attr;
2559
- values[`:a${i}`] = delta;
2560
- clauses.push(`#a${i} :a${i}`);
2561
- i++;
2562
- }
2563
- const merged = {
2564
- TableName: first.Update.TableName,
2565
- Key: first.Update.Key,
2566
- UpdateExpression: `ADD ${clauses.join(", ")}`,
2567
- ExpressionAttributeNames: names,
2568
- ExpressionAttributeValues: values
2569
- };
2570
- return { Update: merged };
2571
- }
2572
-
2573
- // src/operations/transaction.ts
2574
- var MODEL_CLASS_KEY = /* @__PURE__ */ Symbol.for("graphddb.modelClass");
2575
- function attachModelClass(modelStatic, modelClass) {
2576
- Object.defineProperty(modelStatic, MODEL_CLASS_KEY, {
2577
- value: modelClass,
2578
- enumerable: false,
2579
- configurable: false,
2580
- writable: false
2581
- });
2582
- return modelStatic;
2040
+ return deleteInput;
2583
2041
  }
2584
- function resolveModelClass(model) {
2585
- const modelClass = model[MODEL_CLASS_KEY];
2586
- if (!modelClass) {
2587
- throw new Error(
2588
- "Invalid model reference. Use ModelStatic from DDBModel.asModel()."
2589
- );
2042
+ async function executeDelete(modelClass, keyObj, options) {
2043
+ const mw = writeRuntimeFor(options);
2044
+ if (mw) {
2045
+ await executeSingleWriteWithHooks({ kind: "delete", modelClass, key: keyObj, options }, mw);
2046
+ return;
2590
2047
  }
2591
- return modelClass;
2048
+ await executeDeleteCore(modelClass, keyObj, options);
2592
2049
  }
2593
- var TransactionContext = class {
2594
- items = [];
2595
- captureMeta = [];
2596
- /**
2597
- * The logical write ops, parallel to {@link items} but EXCLUDING `conditionCheck`
2598
- * entries (those are read-only assertions, recorded only in `items`). Used by the
2599
- * write-hook path (#139) to run W1 on each logical op and recompose the batch.
2600
- * A `null` entry marks an `items` slot that has no logical op (a ConditionCheck),
2601
- * so the eager and logical arrays can be re-aligned at commit.
2602
- */
2603
- logicalOps = [];
2604
- get itemCount() {
2605
- return this.items.length;
2606
- }
2607
- put(model, item, options) {
2608
- this.assertWithinLimit();
2609
- const modelClass = resolveModelClass(model);
2610
- const putInput = buildPutInput(modelClass, item, options);
2611
- this.items.push({ Put: putInput });
2612
- this.logicalOps.push({ kind: "put", modelClass, item, options });
2613
- this.captureMeta.push({
2614
- modelName: modelClass.name,
2615
- op: "put",
2616
- table: putInput.TableName,
2617
- keys: { pk: String(putInput.Item.PK), sk: String(putInput.Item.SK ?? "") },
2618
- newItem: putInput.Item
2619
- });
2620
- }
2621
- update(model, entity, changes, options) {
2622
- this.assertWithinLimit();
2623
- const modelClass = resolveModelClass(model);
2624
- const updateInput = buildUpdateInput(modelClass, entity, changes, options);
2625
- this.items.push({ Update: updateInput });
2626
- this.logicalOps.push({ kind: "update", modelClass, key: entity, changes, options });
2627
- this.captureMeta.push({
2628
- modelName: modelClass.name,
2629
- op: "update",
2630
- table: updateInput.TableName,
2631
- keys: { pk: String(updateInput.Key.PK), sk: String(updateInput.Key.SK ?? "") }
2632
- });
2633
- }
2634
- delete(model, key, options) {
2635
- this.assertWithinLimit();
2636
- const modelClass = resolveModelClass(model);
2637
- const deleteInput = buildDeleteInput(modelClass, key, options);
2638
- this.items.push({ Delete: deleteInput });
2639
- this.logicalOps.push({ kind: "delete", modelClass, key, options });
2640
- this.captureMeta.push({
2641
- modelName: modelClass.name,
2642
- op: "delete",
2050
+ async function executeDeleteCore(modelClass, keyObj, options, persistVia, forceOldImage) {
2051
+ const deleteInput = buildDeleteInput(modelClass, keyObj, options);
2052
+ const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass) || forceOldImage === true;
2053
+ const send = (input) => ClientManager.getExecutor().delete(input, {
2054
+ ...wantsOld ? { returnOldImage: true } : {},
2055
+ ...options?.retry !== void 0 ? { retry: options.retry } : {}
2056
+ });
2057
+ const result = persistVia ? await persistVia(deleteInput, send) : await send(deleteInput);
2058
+ if (ChangeCaptureRegistry.hasSubscribers()) {
2059
+ const oldItem = result.oldItem;
2060
+ captureWrite({
2643
2061
  table: deleteInput.TableName,
2644
- keys: { pk: String(deleteInput.Key.PK), sk: String(deleteInput.Key.SK ?? "") }
2645
- });
2646
- }
2647
- /**
2648
- * Add a read-only `ConditionCheck` assertion on a keyed item (issue #81). The
2649
- * item is **not** mutated; the `options.condition` (e.g.
2650
- * `{ attributeExists: 'PK' }` to require the row exists) is asserted, and a
2651
- * failed assertion cancels the **whole** `TransactWriteItems` atomically. This
2652
- * is the foundation for referential-integrity derivation (proposal: `requires
2653
- * <Entity> exists`). A ConditionCheck emits no change-capture record (it writes
2654
- * nothing).
2655
- *
2656
- * @throws if `options.condition` is missing — a ConditionCheck without an
2657
- * assertion is meaningless.
2658
- */
2659
- conditionCheck(model, key, options) {
2660
- if (!options || !options.condition) {
2661
- throw new Error(
2662
- "TransactionContext.conditionCheck requires a `condition` (the read-only assertion it makes); e.g. `{ condition: { attributeExists: 'PK' } }`."
2663
- );
2664
- }
2665
- this.assertWithinLimit();
2666
- const modelClass = resolveModelClass(model);
2667
- const { TableName, Key } = buildDeleteInput(modelClass, key);
2668
- const cond2 = buildConditionExpression(options.condition);
2669
- const check = {
2670
- TableName,
2671
- Key,
2672
- ConditionExpression: cond2.expression
2673
- };
2674
- if (Object.keys(cond2.names).length > 0) {
2675
- check.ExpressionAttributeNames = cond2.names;
2676
- }
2677
- if (Object.keys(cond2.values).length > 0) {
2678
- check.ExpressionAttributeValues = cond2.values;
2679
- }
2680
- this.items.push({ ConditionCheck: check });
2681
- this.logicalOps.push(null);
2682
- }
2683
- /** @internal */
2684
- getTransactItems() {
2685
- return this.items;
2686
- }
2687
- /** @internal — the recorded logical write ops (issue #139 W1), `null` per ConditionCheck slot. */
2688
- getLogicalOps() {
2689
- return this.logicalOps;
2690
- }
2691
- /** @internal — capture descriptors for the write-capture seam (issue #72). */
2692
- getCaptureMeta() {
2693
- return this.captureMeta;
2694
- }
2695
- assertWithinLimit() {
2696
- if (this.items.length >= MAX_TRANSACT_ITEMS) {
2697
- throw new Error(
2698
- `Transaction exceeds DynamoDB limit of ${MAX_TRANSACT_ITEMS} items`
2699
- );
2700
- }
2701
- }
2702
- };
2703
- async function executeTransaction(fn, options) {
2704
- const tx = new TransactionContext();
2705
- await fn(tx);
2706
- let transactItems = tx.getTransactItems();
2707
- if (transactItems.length === 0) {
2708
- return;
2709
- }
2710
- const writeMw = buildWriteRuntime(options?.context);
2711
- if (writeMw.active) {
2712
- const txId = { id: /* @__PURE__ */ Symbol("graphddb.transaction") };
2713
- const logical = tx.getLogicalOps();
2714
- const items = [...transactItems];
2715
- const origins = [];
2716
- const writeCtxs = [];
2717
- const modelBySignature2 = /* @__PURE__ */ new Map();
2718
- for (let i = 0; i < logical.length; i++) {
2719
- const op = logical[i];
2720
- if (op === null) continue;
2721
- const input = {};
2722
- if (op.item !== void 0) input.item = { ...op.item };
2723
- if (op.key !== void 0) input.key = { ...op.key };
2724
- if (op.changes !== void 0) input.changes = { ...op.changes };
2725
- if (op.options !== void 0) input.options = { ...op.options };
2726
- const ctx = writeMw.writeCtx(op.kind, ctxModelFor(op.modelClass), input, txId);
2727
- await writeMw.runWriteBefore(ctx);
2728
- const built = buildLogicalItem(op.modelClass, ctx);
2729
- items[i] = built;
2730
- modelBySignature2.set(execItemKeySignature(built), op.modelClass.name);
2731
- origins.push({ model: ctx.model, kind: ctx.kind });
2732
- writeCtxs.push(ctx);
2733
- }
2734
- transactItems = items;
2735
- await commitTransaction(transactItems, {
2736
- modelBySignature: modelBySignature2,
2737
- middleware: { runtime: writeMw, origins, transaction: txId }
2062
+ model: modelClass.name,
2063
+ op: "delete",
2064
+ keys: { pk: String(deleteInput.Key.PK), sk: String(deleteInput.Key.SK ?? "") },
2065
+ ...oldItem ? { oldItem } : {}
2738
2066
  });
2739
- for (let j = writeCtxs.length - 1; j >= 0; j--) {
2740
- await writeMw.runWriteAfter(writeCtxs[j], {});
2741
- }
2742
- return;
2743
2067
  }
2744
- const modelBySignature = /* @__PURE__ */ new Map();
2745
- for (const meta of tx.getCaptureMeta()) {
2746
- if (meta.modelName) {
2747
- modelBySignature.set(
2748
- `${meta.table}#${meta.keys.pk}#${meta.keys.sk}`,
2749
- meta.modelName
2750
- );
2751
- }
2752
- }
2753
- await commitTransaction(transactItems, {
2754
- modelBySignature
2755
- });
2756
- }
2757
- function buildLogicalItem(modelClass, ctx) {
2758
- const opts = ctx.input.options;
2759
- if (ctx.kind === "put") {
2760
- return { Put: buildPutInput(modelClass, ctx.input.item ?? {}, opts) };
2761
- }
2762
- if (ctx.kind === "update") {
2763
- return {
2764
- Update: buildUpdateInput(modelClass, ctx.input.key ?? {}, ctx.input.changes ?? {}, opts)
2765
- };
2766
- }
2767
- return { Delete: buildDeleteInput(modelClass, ctx.input.key ?? {}, opts) };
2768
2068
  }
2769
2069
 
2770
- // src/hydrator/hydrator.ts
2771
- function hydrate(rawItems, select, metadata, updatable = false) {
2772
- return rawItems.map((item) => hydrateItem(item, select, metadata, updatable));
2773
- }
2774
- function hydrateItem(raw, select, metadata, updatable = false) {
2775
- const fieldMap = new Map(
2776
- metadata.fields.map((f) => [f.propertyName, f])
2777
- );
2778
- const embeddedMap = new Map(
2779
- metadata.embeddedFields.map((e) => [e.propertyName, e])
2780
- );
2781
- const result = {};
2782
- for (const [fieldName, selectValue] of Object.entries(select)) {
2783
- if (selectValue === true) {
2784
- const value = raw[fieldName];
2785
- if (value !== void 0) {
2786
- const fieldMeta = fieldMap.get(fieldName);
2787
- result[fieldName] = deserializeValue(value, fieldMeta);
2788
- }
2789
- } else if (isInlineSnapshotSpec(selectValue)) {
2790
- const value = raw[fieldName];
2791
- if (value !== void 0) {
2792
- result[fieldName] = value;
2793
- }
2794
- } else if (typeof selectValue === "object" && selectValue !== null) {
2795
- if ("select" in selectValue || isSelectBuilder(selectValue)) {
2796
- continue;
2797
- }
2798
- const rawEmb = raw[fieldName];
2799
- if (rawEmb && typeof rawEmb === "object") {
2800
- result[fieldName] = hydrateEmbedded(
2801
- rawEmb,
2802
- selectValue,
2803
- metadata,
2804
- fieldName
2805
- );
2806
- }
2807
- }
2808
- }
2809
- if (updatable) {
2810
- attachHiddenKey(result, raw);
2811
- }
2812
- return result;
2813
- }
2814
- function hydrateEmbedded(raw, select, _metadata, _fieldName) {
2815
- const result = {};
2816
- for (const [key, selectValue] of Object.entries(select)) {
2817
- if (selectValue === true) {
2818
- if (raw[key] !== void 0) {
2819
- result[key] = raw[key];
2820
- }
2821
- } else if (typeof selectValue === "object" && selectValue !== null && !("select" in selectValue)) {
2822
- const nested = raw[key];
2823
- if (nested && typeof nested === "object") {
2824
- result[key] = hydrateEmbedded(
2825
- nested,
2826
- selectValue,
2827
- _metadata,
2828
- key
2829
- );
2830
- }
2831
- }
2832
- }
2833
- return result;
2834
- }
2835
- function deserializeValue(value, fieldMeta) {
2836
- if (!fieldMeta) return value;
2837
- if (fieldMeta.options?.deserialize) {
2838
- return fieldMeta.options.deserialize(value);
2839
- }
2840
- if (fieldMeta.options?.format === "datetime" && typeof value === "string") {
2841
- return new Date(value);
2842
- }
2843
- if (fieldMeta.options?.format === "date" && typeof value === "string") {
2844
- return /* @__PURE__ */ new Date(value + "T00:00:00.000Z");
2845
- }
2846
- return value;
2070
+ // src/operations/write-hooks.ts
2071
+ function writeRuntimeFor(options) {
2072
+ const mw = buildWriteRuntime(options?.context);
2073
+ return mw.active ? mw : void 0;
2847
2074
  }
2848
-
2849
- // src/define/entity-writes.ts
2850
- var MAINTAIN_TRIGGER_RE = /^[^.\s$[\]*]+\.(?:created|updated|removed)$/;
2851
- function maintainTrigger(value) {
2852
- if (typeof value !== "string" || !MAINTAIN_TRIGGER_RE.test(value)) {
2853
- throw new Error(
2854
- `maintainTrigger: a maintenance trigger must be an \`Entity.event\` string where event is one of \`created\` / \`updated\` / \`removed\` (e.g. \`"Post.created"\`), but received ${JSON.stringify(value)}.`
2855
- );
2075
+ async function executeSingleWriteWithHooks(req, mw) {
2076
+ const input = {};
2077
+ if (req.item !== void 0) input.item = { ...req.item };
2078
+ if (req.key !== void 0) input.key = { ...req.key };
2079
+ if (req.changes !== void 0) input.changes = { ...req.changes };
2080
+ if (req.options !== void 0) input.options = { ...req.options };
2081
+ const ctx = mw.writeCtx(req.kind, ctxModelFor(req.modelClass), input);
2082
+ try {
2083
+ await mw.runWriteBefore(ctx);
2084
+ const change2 = await persistRewrittenWrite(req.modelClass, ctx, mw);
2085
+ await mw.runWriteAfter(ctx, change2);
2086
+ } catch (err) {
2087
+ await mw.runWriteError(ctx, err);
2856
2088
  }
2857
- return value;
2858
2089
  }
2859
- function isMaintainTrigger(value) {
2860
- return typeof value === "string" && MAINTAIN_TRIGGER_RE.test(value);
2861
- }
2862
- function makeProjectionTransform(path, op, args) {
2863
- if (op === "preview") {
2864
- const [n] = args;
2865
- if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
2866
- throw new Error(
2867
- `transform: the \`preview\` op requires a positive integer length bound (e.g. \`preview('$.body', 200)\`), but received ${JSON.stringify(n)}.`
2868
- );
2869
- }
2870
- return { kind: "transform", path, op, args: [n] };
2090
+ async function persistRewrittenWrite(modelClass, ctx, mw) {
2091
+ const opts = ctx.input.options;
2092
+ const origin = { model: ctx.model, kind: ctx.kind };
2093
+ const forceOldImage = mw.hasWriteAfter;
2094
+ const cap = {};
2095
+ if (ctx.kind === "put") {
2096
+ await executePutCore(
2097
+ modelClass,
2098
+ ctx.input.item ?? {},
2099
+ opts,
2100
+ (input, send) => runPersist(mw, { Put: input }, origin, cap, (it) => {
2101
+ const put = it.Put;
2102
+ cap.newImage = put.Item;
2103
+ return send(put);
2104
+ }),
2105
+ forceOldImage
2106
+ );
2107
+ return change(cap, true);
2871
2108
  }
2872
- if (op === "identity") {
2873
- if (args.length > 0) {
2874
- throw new Error(
2875
- `transform: the \`identity\` op takes no arguments, but received ${JSON.stringify(args)}.`
2876
- );
2877
- }
2878
- return { kind: "transform", path, op, args: [] };
2109
+ if (ctx.kind === "update") {
2110
+ await executeUpdateCore(
2111
+ modelClass,
2112
+ ctx.input.key ?? {},
2113
+ ctx.input.changes ?? {},
2114
+ opts,
2115
+ (input, send) => runPersist(mw, { Update: input }, origin, cap, (it) => {
2116
+ const upd = it.Update;
2117
+ const old = cap.result?.oldItem;
2118
+ cap.newImage = { ...old ?? upd.Key, ...ctx.input.changes ?? {} };
2119
+ return send(upd);
2120
+ }),
2121
+ forceOldImage
2122
+ );
2123
+ return change(cap, true);
2879
2124
  }
2880
- throw new Error(
2881
- `transform: unknown projection op ${JSON.stringify(op)} (expected \`identity\` or \`preview\`).`
2125
+ await executeDeleteCore(
2126
+ modelClass,
2127
+ ctx.input.key ?? {},
2128
+ opts,
2129
+ (input, send) => runPersist(
2130
+ mw,
2131
+ { Delete: input },
2132
+ origin,
2133
+ cap,
2134
+ (it) => send(it.Delete)
2135
+ ),
2136
+ forceOldImage
2882
2137
  );
2138
+ return change(cap, false);
2883
2139
  }
2884
- function pathOf(value) {
2885
- return typeof value === "string" ? value : value.path;
2886
- }
2887
- function preview(path, n) {
2888
- return makeProjectionTransform(pathOf(path), "preview", [n]);
2889
- }
2890
- function identity(path) {
2891
- return makeProjectionTransform(pathOf(path), "identity", []);
2892
- }
2893
- var LIFECYCLE_CONTRACT_MARKER = /* @__PURE__ */ Symbol(
2894
- "graphddb:lifecycleContract"
2895
- );
2896
- function isLifecycleContract(value) {
2897
- return typeof value === "object" && value !== null && value[LIFECYCLE_CONTRACT_MARKER] === true;
2898
- }
2899
- var ENTITY_WRITES_MARKER = /* @__PURE__ */ Symbol("graphddb:entityWrites");
2900
- function isEntityWritesDefinition(value) {
2901
- return typeof value === "object" && value !== null && value[ENTITY_WRITES_MARKER] === true;
2902
- }
2903
- function freezeEffects(effects) {
2140
+ function change(cap, withNew) {
2904
2141
  const out = {};
2905
- if (effects.requires !== void 0) out.requires = effects.requires;
2906
- if (effects.unique !== void 0) out.unique = effects.unique;
2907
- if (effects.edges !== void 0) out.edges = effects.edges;
2908
- if (effects.derive !== void 0) out.derive = effects.derive;
2909
- if (effects.emits !== void 0) out.emits = effects.emits;
2910
- if (effects.idempotency !== void 0) out.idempotency = effects.idempotency;
2142
+ const oldImage = cap.result?.oldItem;
2143
+ if (oldImage !== void 0) out.oldImage = oldImage;
2144
+ if (withNew && cap.newImage !== void 0) {
2145
+ out.newImage = cap.newImage;
2146
+ }
2911
2147
  return out;
2912
2148
  }
2913
- var recorder = {
2914
- lifecycle(effects = {}) {
2915
- return {
2916
- [LIFECYCLE_CONTRACT_MARKER]: true,
2917
- effects: freezeEffects(effects)
2918
- };
2919
- },
2920
- exists(targetFactory, keys) {
2921
- return { kind: "requires", targetFactory, keys };
2922
- },
2923
- unique(spec) {
2924
- return { kind: "unique", name: spec.name, scope: spec.scope, fields: spec.fields };
2925
- },
2926
- putEdge(targetFactory, relationProperty) {
2927
- return { kind: "putEdge", targetFactory, relationProperty };
2928
- },
2929
- deleteEdge(targetFactory, relationProperty) {
2930
- return { kind: "deleteEdge", targetFactory, relationProperty };
2931
- },
2932
- dualEdge(forward, inverse) {
2933
- return {
2934
- kind: "putEdge",
2935
- targetFactory: forward.target,
2936
- relationProperty: forward.relationProperty,
2937
- inverse: {
2938
- adjacencyFactory: inverse.adjacency,
2939
- targetFactory: inverse.target,
2940
- relationProperty: inverse.relationProperty
2941
- }
2942
- };
2943
- },
2944
- increment(targetFactory, keys, attribute, amount) {
2945
- return { kind: "derive", targetFactory, keys, attribute, amount };
2946
- },
2947
- transform(path, op, ...args) {
2948
- return makeProjectionTransform(path, op, args);
2949
- },
2950
- event(name, payload) {
2951
- return { kind: "event", name, payload };
2952
- },
2953
- idempotentBy(token) {
2954
- return { kind: "idempotency", token };
2955
- }
2956
- };
2957
- function entityWrites(builder) {
2958
- const shape = builder(recorder);
2959
- for (const phase of ["create", "update", "remove"]) {
2960
- const contract = shape[phase];
2961
- if (contract !== void 0 && !isLifecycleContract(contract)) {
2962
- throw new Error(
2963
- `entityWrites: the '${phase}' lifecycle must be built with \`w.lifecycle({...})\` (it carries the \xA72 effect arrays), but received ${JSON.stringify(contract)}.`
2964
- );
2965
- }
2966
- }
2967
- if (shape.create === void 0 && shape.update === void 0 && shape.remove === void 0) {
2149
+ async function runPersist(mw, item, origin, cap, send) {
2150
+ const ctx = mw.persistCtx([item], [origin]);
2151
+ const result = await mw.runPersist(ctx, async (items) => send(items[0]));
2152
+ cap.result = result;
2153
+ return result;
2154
+ }
2155
+
2156
+ // src/operations/put.ts
2157
+ function buildPutInput(modelClass, item, options) {
2158
+ const meta = MetadataRegistry.get(modelClass);
2159
+ if (!meta.primaryKey) {
2968
2160
  throw new Error(
2969
- "entityWrites(...) must declare at least one lifecycle (`create` / `update` / `remove`); an empty save contract declares nothing."
2161
+ `No primary key defined for '${modelClass.name}'. Ensure the model has a static \`keys = key(...)\` definition.`
2970
2162
  );
2971
2163
  }
2972
- return {
2973
- [ENTITY_WRITES_MARKER]: true,
2974
- ...shape.create !== void 0 ? { create: shape.create } : {},
2975
- ...shape.update !== void 0 ? { update: shape.update } : {},
2976
- ...shape.remove !== void 0 ? { remove: shape.remove } : {}
2977
- };
2978
- }
2979
- function getEntityWrites(modelClass) {
2980
- for (const name of Object.getOwnPropertyNames(modelClass)) {
2981
- let value;
2982
- try {
2983
- value = modelClass[name];
2984
- } catch {
2985
- continue;
2164
+ const tableName = TableMapping.resolve(meta.tableName);
2165
+ const serialized = {};
2166
+ for (const fieldMeta of meta.fields) {
2167
+ const value = item[fieldMeta.propertyName];
2168
+ if (value !== void 0) {
2169
+ serialized[fieldMeta.propertyName] = serializeFieldValue(value, fieldMeta);
2986
2170
  }
2987
- if (isEntityWritesDefinition(value)) return value;
2988
2171
  }
2989
- return void 0;
2990
- }
2991
- function lifecyclePhaseForIntent(intent) {
2992
- return intent;
2993
- }
2994
-
2995
- // src/relation/maintenance-view-adapter.ts
2996
- function logicalNameOf(meta) {
2997
- return meta.prefix.endsWith("#") ? meta.prefix.slice(0, -1) : meta.prefix;
2998
- }
2999
- function resolveClass(factory) {
3000
- return factory();
3001
- }
3002
- function sourceLogicalName(sourceClass) {
3003
- const meta = MetadataRegistry.get(sourceClass);
3004
- return logicalNameOf(meta);
3005
- }
3006
- function triggersFor(sourceLogical, on) {
3007
- return on.map((ev) => `${sourceLogical}.${ev}`);
3008
- }
3009
- function assertNoCrossSliceCollision(viewName, decls) {
3010
- const projectionOwner = /* @__PURE__ */ new Map();
3011
- const collectionOwner = /* @__PURE__ */ new Map();
3012
- let keyFieldShape;
3013
- decls.forEach((d, idx) => {
3014
- for (const attr of Object.keys(d.project)) {
3015
- const prev = projectionOwner.get(attr);
3016
- if (prev !== void 0 && prev !== idx) {
3017
- throw new Error(
3018
- `View '${viewName}': projection target '${attr}' is maintained by more than one \`@maintainedFrom\` declaration. A view field must be filled by exactly one source \u2014 the declaration order carries no meaning, so this is ambiguous. Remove '${attr}' from one of the declarations (or split it onto a distinct view field).`
3019
- );
3020
- }
3021
- projectionOwner.set(attr, idx);
3022
- }
3023
- if (d.collectionField !== void 0) {
3024
- const prev = collectionOwner.get(d.collectionField);
3025
- if (prev !== void 0 && prev !== idx) {
3026
- throw new Error(
3027
- `View '${viewName}': collection field '${d.collectionField}' is maintained by more than one \`@maintainedFrom\` declaration. A maintained collection has exactly one source \u2014 split the second source onto a distinct collection field.`
3028
- );
3029
- }
3030
- collectionOwner.set(d.collectionField, idx);
3031
- }
3032
- const shape = Object.keys(d.keyBind).slice().sort().join(",");
3033
- if (keyFieldShape === void 0) {
3034
- keyFieldShape = shape;
3035
- } else if (keyFieldShape !== shape) {
3036
- throw new Error(
3037
- `View '${viewName}': \`@maintainedFrom\` declarations bind different view-key field SETS ([${keyFieldShape}] vs [${shape}]). All sources must resolve the SAME view row identity \u2014 they must bind the same set of view-key fields (the source field a given key reads from may differ per source, but the key SHAPE may not). A divergent key shape would split the row.`
3038
- );
3039
- }
3040
- });
3041
- }
3042
- function viewFromMaintainedFrom(viewClass, meta, kind) {
3043
- const viewName = viewClass.name ?? logicalNameOf(meta);
3044
- const decls = meta.maintainedFrom ?? [];
3045
- const pattern = kind === "sparseView" ? "sparseView" : "materializedView";
3046
- for (const d of decls) {
3047
- if (kind === "sparseView" && !d.when) {
3048
- throw new Error(
3049
- `View '${viewName}': \`kind: 'sparseView'\` requires every \`@maintainedFrom\` to declare a \`when\` membership predicate (e.g. \`whenMember(source.status, 'eq', 'active')\`) \u2014 the predicate decides PUT (holds) vs DELETE (flips false).`
3050
- );
3051
- }
3052
- if (kind !== "sparseView" && d.when) {
3053
- throw new Error(
3054
- `View '${viewName}': a \`@maintainedFrom\` declares a \`when\` membership predicate but the view \`kind\` is '${kind}'. Declare \`kind: 'sparseView'\` to maintain a predicate-gated put/delete row.`
3055
- );
3056
- }
2172
+ const keyInput = {};
2173
+ for (const fieldName of meta.primaryKey.inputFieldNames) {
2174
+ keyInput[fieldName] = serialized[fieldName] ?? item[fieldName];
3057
2175
  }
3058
- assertNoCrossSliceCollision(
3059
- viewName,
3060
- decls.map((d) => ({
3061
- keyBind: d.keyBind,
3062
- project: d.project,
3063
- collectionField: d.collection?.field
3064
- }))
2176
+ const { pk, sk } = resolveSegmentedKey(
2177
+ meta.primaryKey.segmented,
2178
+ keyInput,
2179
+ `${modelClass.name} primary key`
3065
2180
  );
3066
- let updateMode;
3067
- let consistency;
3068
- for (const d of decls) {
3069
- if (d.updateMode !== void 0) {
3070
- if (updateMode !== void 0 && updateMode !== d.updateMode) {
3071
- throw new Error(
3072
- `View '${viewName}': conflicting \`updateMode\` across \`@maintainedFrom\` declarations ('${updateMode}' vs '${d.updateMode}'). A view row has a single write path \u2014 declare the same \`updateMode\` on every source.`
3073
- );
3074
- }
3075
- updateMode = d.updateMode;
3076
- }
3077
- if (d.consistency !== void 0) {
3078
- if (consistency !== void 0 && consistency !== d.consistency) {
3079
- throw new Error(
3080
- `View '${viewName}': conflicting \`consistency\` across \`@maintainedFrom\` declarations ('${consistency}' vs '${d.consistency}').`
3081
- );
3082
- }
3083
- consistency = d.consistency;
3084
- }
3085
- }
3086
- const slices = decls.map((d) => sliceFromDeclaration(d));
3087
- return {
3088
- name: viewName,
3089
- viewClass,
3090
- pattern,
3091
- slices,
3092
- ...updateMode !== void 0 ? { updateMode } : {},
3093
- ...consistency !== void 0 ? { consistency } : {}
3094
- };
3095
- }
3096
- function sliceFromDeclaration(d) {
3097
- const sourceClass = resolveClass(d.sourceFactory);
3098
- const sourceLogical = sourceLogicalName(sourceClass);
3099
- const collection = d.collection ? {
3100
- field: d.collection.field,
3101
- ...d.collection.maxItems !== void 0 ? { maxItems: d.collection.maxItems } : {},
3102
- ...d.collection.orderBy !== void 0 ? { orderBy: d.collection.orderBy, orderDir: d.collection.order ?? "DESC" } : {}
3103
- } : void 0;
3104
- return {
3105
- sourceClass,
3106
- maintainedOn: triggersFor(sourceLogical, d.on),
3107
- keys: d.keyBind,
3108
- project: d.project,
3109
- ...collection ? { collection } : {},
3110
- ...d.when ? { predicate: d.when } : {}
2181
+ const dynamoItem = {
2182
+ PK: pk,
2183
+ SK: sk ?? "",
2184
+ ...serialized
3111
2185
  };
3112
- }
3113
- function versionedViews(ownerClass, ownerMeta) {
3114
- const ownerLogical = logicalNameOf(ownerMeta);
3115
- const rels = [];
3116
- for (const rel of ownerMeta.relations) {
3117
- const v = rel.options?.versioned;
3118
- if (!v) continue;
3119
- const targetClass = resolveClass(rel.targetFactory);
3120
- const targetMeta = MetadataRegistry.get(targetClass);
3121
- if (!targetMeta.primaryKey) {
3122
- throw new Error(
3123
- `Versioned relation '${rel.propertyName}' on '${ownerLogical}': the target model '${targetClass.name}' has no \`key\` \u2014 a versioned latest/history row needs a key to resolve which row to overwrite / append.`
3124
- );
2186
+ for (const emb of meta.embeddedFields) {
2187
+ const value = item[emb.propertyName];
2188
+ if (value !== void 0) {
2189
+ dynamoItem[emb.propertyName] = value;
3125
2190
  }
3126
- const keyFields = [
3127
- ...segmentFieldNames(targetMeta.primaryKey.segmented.pkSegments),
3128
- ...segmentFieldNames(targetMeta.primaryKey.segmented.skSegments)
3129
- ];
3130
- rels.push({
3131
- propertyName: rel.propertyName,
3132
- mode: v.mode,
3133
- on: v.on,
3134
- project: v.project,
3135
- ...v.updateMode !== void 0 ? { updateMode: v.updateMode } : {},
3136
- ...v.consistency !== void 0 ? { consistency: v.consistency } : {},
3137
- targetClass,
3138
- targetMeta,
3139
- keyFields
3140
- });
3141
2191
  }
3142
- assertVersionedDiscriminator(ownerLogical, rels);
3143
- const out = [];
3144
- for (const rel of rels) {
3145
- const keys = {};
3146
- for (const f of rel.keyFields) keys[f] = `$.entity.${f}`;
3147
- const targetName = rel.targetClass.name ?? logicalNameOf(rel.targetMeta);
3148
- out.push({
3149
- name: targetName,
3150
- viewClass: rel.targetClass,
3151
- pattern: "materializedView",
3152
- slices: [
3153
- {
3154
- sourceClass: ownerClass,
3155
- maintainedOn: triggersFor(ownerLogical, rel.on),
3156
- keys,
3157
- project: rel.project
3158
- }
3159
- ],
3160
- ...rel.updateMode !== void 0 ? { updateMode: rel.updateMode } : {},
3161
- ...rel.consistency !== void 0 ? { consistency: rel.consistency } : {}
3162
- });
2192
+ const available = {};
2193
+ for (const fieldName of meta.fields.map((f) => f.propertyName)) {
2194
+ available[fieldName] = serialized[fieldName] ?? item[fieldName];
3163
2195
  }
3164
- return out;
3165
- }
3166
- function assertVersionedDiscriminator(ownerLogical, rels) {
3167
- const latests = rels.filter((r) => r.mode === "latest");
3168
- const histories = rels.filter((r) => r.mode === "history");
3169
- if (latests.length === 0 || histories.length === 0) return;
3170
- for (const latest of latests) {
3171
- const latestKeyFields = new Set(latest.keyFields);
3172
- for (const history of histories) {
3173
- const discriminators = history.keyFields.filter((f) => !latestKeyFields.has(f));
3174
- if (discriminators.length === 0) {
3175
- const latestName = latest.targetClass.name ?? "<latest>";
3176
- const historyName = history.targetClass.name ?? "<history>";
3177
- throw new Error(
3178
- `Versioned relation pair on '${ownerLogical}': the \`versionedHistory\` target '${historyName}' (\`${history.propertyName}\`) is keyed by [${history.keyFields.map((f) => `'${f}'`).join(", ")}], which carries NO version discriminator beyond the \`versionedLatest\` target '${latestName}' (\`${latest.propertyName}\`) keyed by [${[...latestKeyFields].map((f) => `'${f}'`).join(", ")}]. The history row must be keyed by a per-version field (e.g. add a \`version\` segment to '${historyName}'s \`@key\`) so each revision is a NEW row; otherwise every revision overwrites one row (a latest pointer, not an append-only history).`
3179
- );
3180
- }
3181
- }
2196
+ for (const gsi2 of meta.gsiDefinitions) {
2197
+ const { pkAttr, pk: gsiPk, skAttr, sk: gsiSk } = deriveGsiKey(
2198
+ gsi2,
2199
+ available,
2200
+ `${modelClass.name} GSI '${gsi2.indexName}'`
2201
+ );
2202
+ dynamoItem[pkAttr] = gsiPk;
2203
+ dynamoItem[skAttr] = gsiSk;
3182
2204
  }
3183
- }
3184
- function collectViewDefinitions(registry) {
3185
- const resolved = registry ?? MetadataRegistry.getAll();
3186
- const out = [];
3187
- for (const [klass, meta] of resolved) {
3188
- const kind = meta.kind ?? "entity";
3189
- if (kind === "materializedView" || kind === "sparseView") {
3190
- out.push(viewFromMaintainedFrom(klass, meta, kind));
2205
+ const putInput = {
2206
+ TableName: tableName,
2207
+ Item: dynamoItem
2208
+ };
2209
+ if (options?.condition) {
2210
+ const condResult = buildConditionExpression(options.condition);
2211
+ putInput.ConditionExpression = condResult.expression;
2212
+ if (Object.keys(condResult.names).length > 0) {
2213
+ putInput.ExpressionAttributeNames = condResult.names;
3191
2214
  }
3192
- if (meta.relations?.some((r) => r.options?.versioned)) {
3193
- out.push(...versionedViews(klass, meta));
2215
+ if (Object.keys(condResult.values).length > 0) {
2216
+ putInput.ExpressionAttributeValues = condResult.values;
3194
2217
  }
3195
2218
  }
3196
- return out;
3197
- }
3198
-
3199
- // src/relation/maintenance-graph.ts
3200
- var COUNT_DELTA_FOR_EVENT = {
3201
- created: 1,
3202
- removed: -1,
3203
- updated: 0
3204
- };
3205
- var MAINTAIN_EVENTS = ["created", "updated", "removed"];
3206
- function isPathRooted(value) {
3207
- return value.startsWith("$.input.") || value.startsWith("$.entity.");
3208
- }
3209
- function sourceAttributeOf(path) {
3210
- const stripped = path.replace(/^\$\.(input|entity)\./, "");
3211
- return stripped.split(".")[0] ?? stripped;
3212
- }
3213
- function normalizeProjectionEntry(value) {
3214
- if (typeof value === "string") {
3215
- const path = isPathRooted(value) ? value : `$.entity.${value}`;
3216
- return identity(path);
3217
- }
3218
- return value;
3219
- }
3220
- function buildProjectionMap(projection, ownerEntity, relationProperty) {
3221
- const entries = projection ? Object.entries(projection) : [];
3222
- if (entries.length === 0) {
3223
- throw new Error(
3224
- `Relation '${relationProperty}' on '${ownerEntity}' declares maintenance (\`write.maintainedOn\`) but no \`projection\`: a maintained snapshot / collection with no captured attributes projects nothing. Declare a \`projection\` map (e.g. \`{ postId: 'postId', textPreview: preview('body', 120) }\`).`
3225
- );
3226
- }
3227
- const out = {};
3228
- for (const [attr, value] of entries) {
3229
- out[attr] = normalizeProjectionEntry(value);
3230
- }
3231
- return out;
2219
+ return putInput;
3232
2220
  }
3233
- function buildDestinationKeys(keyBinding) {
3234
- const keys = {};
3235
- const sourceFields = [];
3236
- for (const [sourceField, ownerField] of Object.entries(keyBinding)) {
3237
- keys[ownerField] = `$.entity.${sourceField}`;
3238
- sourceFields.push(sourceField);
2221
+ async function executePut(modelClass, item, options) {
2222
+ const mw = writeRuntimeFor(options);
2223
+ if (mw) {
2224
+ await executeSingleWriteWithHooks({ kind: "put", modelClass, item, options }, mw);
2225
+ return;
3239
2226
  }
3240
- return { keys, sourceFields };
2227
+ await executePutCore(modelClass, item, options);
3241
2228
  }
3242
- function sourcePayloadAttributes(meta) {
3243
- const attrs = /* @__PURE__ */ new Set();
3244
- for (const f of meta.fields) attrs.add(f.propertyName);
3245
- if (meta.primaryKey) {
3246
- for (const f of segmentFieldNames(meta.primaryKey.segmented.pkSegments)) attrs.add(f);
3247
- for (const f of segmentFieldNames(meta.primaryKey.segmented.skSegments)) attrs.add(f);
3248
- }
3249
- for (const gsi2 of meta.gsiDefinitions) {
3250
- for (const f of segmentFieldNames(gsi2.segmented.pkSegments)) attrs.add(f);
3251
- for (const f of segmentFieldNames(gsi2.segmented.skSegments)) attrs.add(f);
3252
- }
3253
- return attrs;
3254
- }
3255
- function assertMaintenanceRoundTrips(args) {
3256
- const {
3257
- ownerEntity,
3258
- relationProperty,
3259
- ownerMeta,
3260
- sourceEntity,
3261
- sourceMeta,
3262
- destinationKeyFields,
3263
- keyBindingSourceFields,
3264
- projection
3265
- } = args;
3266
- const sourceAttrs = sourcePayloadAttributes(sourceMeta);
3267
- try {
3268
- resolveKey([...destinationKeyFields], ownerMeta);
3269
- } catch (cause) {
3270
- throw new Error(
3271
- `Maintenance declaration for relation '${relationProperty}' on '${ownerEntity}' binds destination-key fields [${destinationKeyFields.map((f) => `'${f}'`).join(", ")}], which cover no access-pattern partition of the maintained entity '${ownerEntity}'. The maintained snapshot / collection row could not be resolved, so the snapshot key would not point at a real target row \u2014 bind the partition-key field(s) of the index the maintained row lives on. (${cause.message})`
3272
- );
2229
+ async function executePutCore(modelClass, item, options, persistVia, forceOldImage) {
2230
+ const putInput = buildPutInput(modelClass, item, options);
2231
+ const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass) || forceOldImage === true;
2232
+ const send = (input) => ClientManager.getExecutor().put(input, {
2233
+ ...wantsOld ? { returnOldImage: true } : {},
2234
+ ...options?.retry !== void 0 ? { retry: options.retry } : {}
2235
+ });
2236
+ const result = persistVia ? await persistVia(putInput, send) : await send(putInput);
2237
+ if (ChangeCaptureRegistry.hasSubscribers()) {
2238
+ const oldItem = result.oldItem;
2239
+ captureWrite({
2240
+ table: putInput.TableName,
2241
+ model: modelClass.name,
2242
+ op: "put",
2243
+ keys: { pk: String(putInput.Item.PK), sk: String(putInput.Item.SK ?? "") },
2244
+ newItem: putInput.Item,
2245
+ ...oldItem ? { oldItem } : {}
2246
+ });
3273
2247
  }
3274
- for (const field of keyBindingSourceFields) {
3275
- if (!sourceAttrs.has(field)) {
3276
- throw new Error(
3277
- `Maintenance key binding for relation '${relationProperty}' on '${ownerEntity}' reads source field '${field}' to resolve the destination row, but the source entity '${sourceEntity}' carries no attribute '${field}' on its payload (available: ${[...sourceAttrs].sort().map((f) => `'${f}'`).join(", ")}). The destination key could not be built from the source payload \u2014 reject.`
3278
- );
3279
- }
2248
+ }
2249
+
2250
+ // src/runtime/same-key-collapse.ts
2251
+ function collapseSameKey(items, view) {
2252
+ const groups = /* @__PURE__ */ new Map();
2253
+ for (const item of items) {
2254
+ const sig = view.keySignature(item);
2255
+ const bucket = groups.get(sig);
2256
+ if (bucket === void 0) groups.set(sig, [item]);
2257
+ else bucket.push(item);
3280
2258
  }
3281
- for (const [attr, transform] of Object.entries(projection)) {
3282
- if (!isPathRooted(transform.path)) {
3283
- throw new Error(
3284
- `Maintenance projection for relation '${relationProperty}' on '${ownerEntity}' captures attribute '${attr}' from '${transform.path}', which is not a payload-rooted source path (\`$.input.*\` / \`$.entity.*\`). The maintenance graph projects only from the source payload (payload \u540C\u68B1) \u2014 there is no fetch / re-projection.`
3285
- );
2259
+ const replacement = /* @__PURE__ */ new Map();
2260
+ const dropped = /* @__PURE__ */ new Set();
2261
+ for (const bucket of groups.values()) {
2262
+ if (bucket.length === 1) continue;
2263
+ const kinds = new Set(bucket.map((it) => view.kind(it)));
2264
+ if (kinds.size === 2 && kinds.has("Delete") && kinds.has("Put")) {
2265
+ for (const it of bucket) dropped.add(it);
2266
+ continue;
3286
2267
  }
3287
- const srcAttr = sourceAttributeOf(transform.path);
3288
- if (!sourceAttrs.has(srcAttr)) {
2268
+ if (kinds.size === 2 && kinds.has("Put") && kinds.has("Update")) {
3289
2269
  throw new Error(
3290
- `Maintenance projection for relation '${relationProperty}' on '${ownerEntity}' captures '${attr}' from '${transform.path}', but the source entity '${sourceEntity}' carries no attribute '${srcAttr}' on its payload (available: ${[...sourceAttrs].sort().map((f) => `'${f}'`).join(", ")}). Projection is payload \u540C\u68B1 only (no fetch / re-projection): an attribute the source row does not carry cannot be projected \u2014 reject.`
2270
+ `Contract runtime: a derived counter (\`increment\`) resolves to the SAME item as the entity write at runtime (the call-time key values made an aliased target coincide with the written row). A Put+Update on one transaction item is unsupported \u2014 DynamoDB rejects touching one key twice, and collapsing it would silently drop the increment. A \`w.increment(...)\` must target a DIFFERENT row (e.g. a parent aggregate's counter); to maintain a counter ON the written entity, write the field as part of the entity itself, not via a derived counter onto its own row.`
3291
2271
  );
3292
2272
  }
3293
- }
3294
- }
3295
- function assertCounterRoundTrips(args) {
3296
- const {
3297
- ownerEntity,
3298
- aggregateField,
3299
- ownerMeta,
3300
- sourceEntity,
3301
- sourceMeta,
3302
- destinationKeyFields,
3303
- keyBindingSourceFields,
3304
- valueSourceFields
3305
- } = args;
3306
- const sourceAttrs = sourcePayloadAttributes(sourceMeta);
3307
- try {
3308
- resolveKey([...destinationKeyFields], ownerMeta);
3309
- } catch (cause) {
3310
- throw new Error(
3311
- `Counter aggregate '${aggregateField}' on '${ownerEntity}' binds destination-key fields [${destinationKeyFields.map((f) => `'${f}'`).join(", ")}], which cover no access-pattern partition of the maintained entity '${ownerEntity}'. The counter row could not be resolved \u2014 bind the partition-key field(s) of the index the counter row lives on. (${cause.message})`
3312
- );
3313
- }
3314
- for (const field of keyBindingSourceFields) {
3315
- if (!sourceAttrs.has(field)) {
3316
- throw new Error(
3317
- `Counter aggregate '${aggregateField}' on '${ownerEntity}' reads source field '${field}' to resolve the counter row, but the source entity '${sourceEntity}' carries no attribute '${field}' on its payload (available: ${[...sourceAttrs].sort().map((f) => `'${f}'`).join(", ")}). The destination key could not be built from the source payload \u2014 reject.`
3318
- );
2273
+ if (kinds.size === 1 && kinds.has("Update")) {
2274
+ replacement.set(bucket[0], view.mergeAdds(bucket));
2275
+ for (const it of bucket.slice(1)) dropped.add(it);
2276
+ continue;
3319
2277
  }
3320
- }
3321
- for (const field of valueSourceFields) {
3322
- if (!sourceAttrs.has(field)) {
3323
- throw new Error(
3324
- `Counter aggregate '${aggregateField}' on '${ownerEntity}' tracks \`max('${field}')\`, but the source entity '${sourceEntity}' carries no attribute '${field}' on its payload (available: ${[...sourceAttrs].sort().map((f) => `'${f}'`).join(", ")}). A counter aggregates only the source payload (payload \u540C\u68B1) \u2014 reject.`
3325
- );
2278
+ if (kinds.size === 1 && (kinds.has("Put") || kinds.has("Delete"))) {
2279
+ for (const it of bucket.slice(1)) dropped.add(it);
2280
+ continue;
3326
2281
  }
2282
+ throw new Error(
2283
+ `Contract runtime: ${bucket.length} transaction items resolve to the same physical row with an unsupported op combination (${[...kinds].sort().join("+")}). A TransactWriteItems may not touch one key twice; this collision is not a known collapse shape (self-row Put/Delete de-dup, swap no-op, or symmetric counter ADDs), so it is rejected rather than silently collapsed.`
2284
+ );
3327
2285
  }
3328
- }
3329
- function logicalNameOf2(meta) {
3330
- return meta.prefix.endsWith("#") ? meta.prefix.slice(0, -1) : meta.prefix;
3331
- }
3332
- function resolveEntityByName(name, registry) {
3333
- for (const [klass, meta] of registry) {
3334
- if (logicalNameOf2(meta) === name) return klass;
2286
+ const out = [];
2287
+ for (const item of items) {
2288
+ if (dropped.has(item)) continue;
2289
+ out.push(replacement.get(item) ?? item);
3335
2290
  }
3336
- return void 0;
2291
+ return out;
3337
2292
  }
3338
- function parseTrigger(raw) {
3339
- const idx = raw.lastIndexOf(".");
3340
- return { entity: raw.slice(0, idx), event: raw.slice(idx + 1) };
3341
- }
3342
- function buildEffect(relation, ownerFactory, trigger, keys, project, sourceMeta, ownerEntity) {
3343
- const write = relation.options?.write;
3344
- const updateMode = write?.updateMode;
3345
- const consistency = write?.consistency;
3346
- const base = {
3347
- trigger,
3348
- targetFactory: ownerFactory,
3349
- keys,
3350
- project,
3351
- ...updateMode !== void 0 ? { updateMode } : {},
3352
- ...consistency !== void 0 ? { consistency } : {}
3353
- };
3354
- if (relation.type === "hasMany") {
3355
- const read = relation.options?.read;
3356
- const order = resolveCollectionOrder(relation, sourceMeta, ownerEntity, project);
3357
- const collection = {
3358
- field: relation.propertyName,
3359
- ...read?.maxItems !== void 0 ? { maxItems: read.maxItems } : {},
3360
- ...order !== void 0 ? { orderBy: order.orderBy, orderDir: order.orderDir } : {}
3361
- };
3362
- return { kind: "collection", ...base, collection };
3363
- }
3364
- return { kind: "snapshot", ...base };
3365
- }
3366
- function viewSliceProperty(viewName, sliceIndex, slice) {
3367
- return slice.collection ? `${viewName}#${slice.collection.field}` : `${viewName}#${sliceIndex}`;
3368
- }
3369
- function buildViewEffect(view, slice, trigger) {
3370
- const targetFactory = () => view.viewClass;
3371
- const base = {
3372
- trigger,
3373
- targetFactory,
3374
- keys: slice.keys,
3375
- project: slice.project,
3376
- ...view.updateMode !== void 0 ? { updateMode: view.updateMode } : {},
3377
- ...view.consistency !== void 0 ? { consistency: view.consistency } : {}
3378
- };
3379
- if (slice.predicate) {
3380
- return { kind: "membership", ...base, predicate: slice.predicate };
3381
- }
3382
- if (slice.collection) {
3383
- const collection = {
3384
- field: slice.collection.field,
3385
- ...slice.collection.maxItems !== void 0 ? { maxItems: slice.collection.maxItems } : {},
3386
- ...slice.collection.orderBy !== void 0 ? {
3387
- orderBy: slice.collection.orderBy,
3388
- orderDir: slice.collection.orderDir ?? "DESC"
3389
- } : {}
3390
- };
3391
- return { kind: "collection", ...base, collection };
3392
- }
3393
- return { kind: "snapshot", ...base };
2293
+
2294
+ // src/runtime/transaction-commit.ts
2295
+ var MAX_TRANSACT_ITEMS = 25;
2296
+ var execItemCollapseView = {
2297
+ kind: execItemKind,
2298
+ keySignature: execItemKeySignature,
2299
+ mergeAdds: mergeAddUpdates
2300
+ };
2301
+ function collapseSameKeyItems(items) {
2302
+ return collapseSameKey(items, execItemCollapseView);
3394
2303
  }
3395
- function resolveCollectionOrder(relation, sourceMeta, ownerEntity, project) {
3396
- const order = relation.options?.read?.order;
3397
- if (order === void 0) return void 0;
3398
- const skFields = sourceMeta.primaryKey ? segmentFieldNames(sourceMeta.primaryKey.segmented.skSegments) : [];
3399
- const orderField = skFields[skFields.length - 1];
3400
- if (orderField === void 0) {
3401
- throw new Error(
3402
- `Relation '${relation.propertyName}' on '${ownerEntity}' declares \`read.order: '${order}'\` for its maintained collection, but its source entity '${logicalNameOf2(sourceMeta)}' has no sort key to order by. A maintained collection orders by the source's sort-key field (the field the live read sorts on); declare a sort key on '${logicalNameOf2(sourceMeta)}' or drop \`read.order\`.`
2304
+ async function commitTransaction(items, options = {}) {
2305
+ const collapsed = collapseSameKeyItems(items);
2306
+ if (collapsed.length === 0) return collapsed;
2307
+ if (collapsed.length > MAX_TRANSACT_ITEMS) {
2308
+ throw options.limitError ? options.limitError(collapsed.length) : new Error(
2309
+ `Transaction exceeds DynamoDB limit of ${MAX_TRANSACT_ITEMS} items`
3403
2310
  );
3404
2311
  }
3405
- if (!Object.prototype.hasOwnProperty.call(project, orderField)) {
3406
- throw new Error(
3407
- `Relation '${relation.propertyName}' on '${ownerEntity}' declares \`read.order: '${order}'\` for its maintained collection, which orders by the source entity '${logicalNameOf2(sourceMeta)}'s sort-key field '${orderField}', but that field is not in the relation's \`projection\` (projected: ${Object.keys(project).map((a) => `'${a}'`).join(", ")}). The maintained collection sorts its projected items, so the order field must be projected \u2014 add '${orderField}' to the projection (e.g. \`{ ${orderField}: '${orderField}', \u2026 }\`) or drop \`read.order\`.`
3408
- );
2312
+ const sendBatch = (items2) => ClientManager.getExecutor().transactWrite(
2313
+ items2,
2314
+ options.retry !== void 0 ? { retry: options.retry } : void 0
2315
+ );
2316
+ let committed = collapsed;
2317
+ if (options.middleware) {
2318
+ const { runtime, origins, transaction } = options.middleware;
2319
+ const persistCtx = runtime.persistCtx(collapsed, origins, transaction);
2320
+ await runtime.runPersist(persistCtx, async (items2) => sendBatch(items2));
2321
+ committed = persistCtx.items;
2322
+ } else {
2323
+ await sendBatch(collapsed);
3409
2324
  }
3410
- return { orderBy: `$.entity.${orderField}`, orderDir: order };
3411
- }
3412
- function buildCounterEffect(aggregate, ownerFactory, trigger, event, keys) {
3413
- const write = aggregate.options.write;
3414
- const updateMode = write?.updateMode;
3415
- const consistency = write?.consistency;
3416
- const value = aggregate.options.value.op === "count" ? { op: "count" } : { op: "max", field: aggregate.options.value.field };
3417
- return {
3418
- kind: "counter",
3419
- trigger,
3420
- targetFactory: ownerFactory,
3421
- keys,
3422
- attribute: aggregate.propertyName,
3423
- value,
3424
- ...value.op === "count" ? { delta: COUNT_DELTA_FOR_EVENT[event] } : {},
3425
- ...updateMode !== void 0 ? { updateMode } : {},
3426
- ...consistency !== void 0 ? { consistency } : {}
3427
- };
3428
- }
3429
- function destinationRowKeyOf(ownerEntity, keys) {
3430
- const parts = Object.entries(keys).map(([field, path]) => `${field}=${path}`).sort();
3431
- return `${ownerEntity}#${parts.join("&")}`;
2325
+ captureTransactItems(committed, options.modelBySignature ?? EMPTY_MODEL_MAP);
2326
+ return committed;
3432
2327
  }
3433
- function assertNoCycle(items) {
3434
- const adjacency = /* @__PURE__ */ new Map();
2328
+ var EMPTY_MODEL_MAP = /* @__PURE__ */ new Map();
2329
+ function captureTransactItems(items, modelBySignature) {
2330
+ if (!ChangeCaptureRegistry.hasSubscribers()) return;
3435
2331
  for (const item of items) {
3436
- if (!adjacency.has(item.sourceEntity)) adjacency.set(item.sourceEntity, /* @__PURE__ */ new Set());
3437
- adjacency.get(item.sourceEntity).add(item.ownerEntity);
3438
- }
3439
- const WHITE = 0;
3440
- const GRAY = 1;
3441
- const BLACK = 2;
3442
- const color = /* @__PURE__ */ new Map();
3443
- for (const node of adjacency.keys()) color.set(node, WHITE);
3444
- for (const start of adjacency.keys()) {
3445
- if (color.get(start) !== WHITE) continue;
3446
- const stack = [];
3447
- const path = [];
3448
- color.set(start, GRAY);
3449
- path.push(start);
3450
- stack.push({ node: start, iter: (adjacency.get(start) ?? /* @__PURE__ */ new Set()).values() });
3451
- while (stack.length > 0) {
3452
- const top = stack[stack.length - 1];
3453
- const next = top.iter.next();
3454
- if (next.done) {
3455
- color.set(top.node, BLACK);
3456
- stack.pop();
3457
- path.pop();
3458
- continue;
3459
- }
3460
- const child = next.value;
3461
- const childColor = color.get(child) ?? WHITE;
3462
- if (childColor === GRAY) {
3463
- const from = path.indexOf(child);
3464
- const cycle = [...path.slice(from), child].join(" \u2192 ");
3465
- throw new Error(
3466
- `Maintenance dependency cycle detected: ${cycle}. A maintenance trigger chain that loops back on itself is an unbounded cascade \u2014 break the cycle (an entity's maintained shape must not, transitively, be triggered by its own maintenance).`
3467
- );
3468
- }
3469
- if (childColor === WHITE) {
3470
- color.set(child, GRAY);
3471
- path.push(child);
3472
- stack.push({ node: child, iter: (adjacency.get(child) ?? /* @__PURE__ */ new Set()).values() });
3473
- }
3474
- }
2332
+ if ("ConditionCheck" in item) continue;
2333
+ captureWrite(captureMetaForItem(item, modelBySignature));
3475
2334
  }
3476
2335
  }
3477
- function buildMaintenanceGraph(registry, views) {
3478
- const resolvedRegistry = registry ?? MetadataRegistry.getAll();
3479
- const resolvedViews = views ?? collectViewDefinitions(resolvedRegistry);
3480
- return buildMaintenanceGraphImpl(resolvedRegistry, resolvedViews);
3481
- }
3482
- function buildMaintenanceGraphImpl(registry, views) {
3483
- const items = [];
3484
- const owners = [...registry.entries()].map(([klass, meta]) => ({ klass, meta })).sort((a, b) => a.klass.name < b.klass.name ? -1 : a.klass.name > b.klass.name ? 1 : 0);
3485
- for (const { klass: ownerClass, meta: ownerMeta } of owners) {
3486
- const ownerEntity = logicalNameOf2(ownerMeta);
3487
- const relations = [...ownerMeta.relations].sort(
3488
- (a, b) => a.propertyName < b.propertyName ? -1 : a.propertyName > b.propertyName ? 1 : 0
3489
- );
3490
- for (const relation of relations) {
3491
- const maintainedOn = relation.options?.write?.maintainedOn;
3492
- if (!maintainedOn || maintainedOn.length === 0) continue;
3493
- const sourceClass = relation.targetFactory();
3494
- const sourceMeta = MetadataRegistry.get(sourceClass);
3495
- const relationTargetEntity = logicalNameOf2(sourceMeta);
3496
- const project = buildProjectionMap(
3497
- relation.options?.projection,
3498
- ownerEntity,
3499
- relation.propertyName
3500
- );
3501
- const { keys, sourceFields } = buildDestinationKeys(relation.keyBinding);
3502
- const destinationKeyFields = Object.keys(keys);
3503
- for (const raw of maintainedOn) {
3504
- if (!isMaintainTrigger(raw)) {
3505
- const { event } = parseTrigger(raw);
3506
- throw new Error(
3507
- `Maintenance trigger ${JSON.stringify(raw)} on relation '${relation.propertyName}' of '${ownerEntity}' is not a well-formed \`Entity.event\` trigger (event must be one of ${MAINTAIN_EVENTS.map((e) => `'${e}'`).join(" / ")}; got '${event}'). Fix the \`write.maintainedOn\` entry.`
3508
- );
3509
- }
3510
- const trigger = raw;
3511
- const { entity: triggerEntity } = parseTrigger(trigger);
3512
- if (!resolveEntityByName(triggerEntity, registry)) {
3513
- throw new Error(
3514
- `Maintenance trigger '${trigger}' on relation '${relation.propertyName}' of '${ownerEntity}' names source entity '${triggerEntity}', which is not a registered model. Declare a model named '${triggerEntity}' or fix the trigger's entity segment.`
3515
- );
3516
- }
3517
- if (triggerEntity !== relationTargetEntity) {
3518
- throw new Error(
3519
- `Maintenance trigger '${trigger}' on relation '${relation.propertyName}' of '${ownerEntity}' names source entity '${triggerEntity}', but the relation's target (the projected source) is '${relationTargetEntity}'. #124 projects only from the relation target's payload (payload \u540C\u68B1); a trigger on a different entity needs a write-time context fetch, which is Phase 2 \u2014 restrict \`maintainedOn\` to '${relationTargetEntity}.*' triggers.`
3520
- );
3521
- }
3522
- assertMaintenanceRoundTrips({
3523
- ownerEntity,
3524
- relationProperty: relation.propertyName,
3525
- ownerMeta,
3526
- sourceEntity: triggerEntity,
3527
- sourceMeta,
3528
- destinationKeyFields,
3529
- keyBindingSourceFields: sourceFields,
3530
- projection: project
3531
- });
3532
- const effect = buildEffect(
3533
- relation,
3534
- () => ownerClass,
3535
- trigger,
3536
- keys,
3537
- project,
3538
- sourceMeta,
3539
- ownerEntity
3540
- );
3541
- items.push({
3542
- trigger,
3543
- sourceEntity: triggerEntity,
3544
- sourceClass,
3545
- ownerEntity,
3546
- ownerClass,
3547
- relationProperty: relation.propertyName,
3548
- destinationRowKey: destinationRowKeyOf(ownerEntity, keys),
3549
- effect
3550
- });
3551
- }
3552
- }
3553
- const aggregates = [...ownerMeta.aggregates].sort(
3554
- (a, b) => a.propertyName < b.propertyName ? -1 : a.propertyName > b.propertyName ? 1 : 0
3555
- );
3556
- for (const agg of aggregates) {
3557
- const maintainedOn = agg.options.write?.maintainedOn;
3558
- if (!maintainedOn || maintainedOn.length === 0) continue;
3559
- const sourceClass = agg.targetFactory();
3560
- const sourceMeta = MetadataRegistry.get(sourceClass);
3561
- const aggSourceEntity = logicalNameOf2(sourceMeta);
3562
- const { keys, sourceFields } = buildDestinationKeys(agg.keyBinding);
3563
- const destinationKeyFields = Object.keys(keys);
3564
- const valueSourceFields = agg.options.value.op === "max" ? [agg.options.value.field] : [];
3565
- for (const raw of maintainedOn) {
3566
- if (!isMaintainTrigger(raw)) {
3567
- const { event: event2 } = parseTrigger(raw);
3568
- throw new Error(
3569
- `Maintenance trigger ${JSON.stringify(raw)} on \`@aggregate\` field '${agg.propertyName}' of '${ownerEntity}' is not a well-formed \`Entity.event\` trigger (event must be one of ${MAINTAIN_EVENTS.map((e) => `'${e}'`).join(" / ")}; got '${event2}'). Fix the \`write.maintainedOn\` entry.`
3570
- );
3571
- }
3572
- const trigger = raw;
3573
- const parsed = parseTrigger(trigger);
3574
- const triggerEntity = parsed.entity;
3575
- const event = parsed.event;
3576
- if (!resolveEntityByName(triggerEntity, registry)) {
3577
- throw new Error(
3578
- `Maintenance trigger '${trigger}' on \`@aggregate\` field '${agg.propertyName}' of '${ownerEntity}' names source entity '${triggerEntity}', which is not a registered model. Declare a model named '${triggerEntity}' or fix the trigger's entity segment.`
3579
- );
3580
- }
3581
- if (triggerEntity !== aggSourceEntity) {
3582
- throw new Error(
3583
- `Maintenance trigger '${trigger}' on \`@aggregate\` field '${agg.propertyName}' of '${ownerEntity}' names source entity '${triggerEntity}', but the aggregate's source (the entity being counted) is '${aggSourceEntity}'. A counter aggregates only the rows of its own source (payload \u540C\u68B1); a trigger on a different entity is Phase 2 \u2014 restrict \`maintainedOn\` to '${aggSourceEntity}.*' triggers.`
3584
- );
3585
- }
3586
- if (agg.options.value.op === "count" && COUNT_DELTA_FOR_EVENT[event] === 0) {
3587
- throw new Error(
3588
- `\`@aggregate\` field '${agg.propertyName}' of '${ownerEntity}' is a \`count()\` counter maintained on '${trigger}', but a 'updated' event does not change a row count (it adds 0). A \`count()\` counter is maintained on 'created' (+1) and 'removed' (-1); drop the 'updated' trigger.`
3589
- );
3590
- }
3591
- assertCounterRoundTrips({
3592
- ownerEntity,
3593
- aggregateField: agg.propertyName,
3594
- ownerMeta,
3595
- sourceEntity: triggerEntity,
3596
- sourceMeta,
3597
- destinationKeyFields,
3598
- keyBindingSourceFields: sourceFields,
3599
- valueSourceFields
3600
- });
3601
- const effect = buildCounterEffect(agg, () => ownerClass, trigger, event, keys);
3602
- items.push({
3603
- trigger,
3604
- sourceEntity: triggerEntity,
3605
- sourceClass,
3606
- ownerEntity,
3607
- ownerClass,
3608
- relationProperty: agg.propertyName,
3609
- destinationRowKey: destinationRowKeyOf(ownerEntity, keys),
3610
- effect
3611
- });
3612
- }
3613
- }
3614
- }
3615
- for (const view of [...views].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
3616
- const ownerClass = view.viewClass;
3617
- const ownerMeta = MetadataRegistry.get(ownerClass);
3618
- const ownerEntity = logicalNameOf2(ownerMeta);
3619
- view.slices.forEach((slice, sliceIndex) => {
3620
- const sourceClass = slice.sourceClass;
3621
- const sourceMeta = MetadataRegistry.get(sourceClass);
3622
- const sourceEntity = logicalNameOf2(sourceMeta);
3623
- const keys = slice.keys;
3624
- const destinationKeyFields = Object.keys(keys);
3625
- const keyBindingSourceFields = Object.values(keys).map((p) => sourceAttributeOf(p));
3626
- for (const trigger of slice.maintainedOn) {
3627
- const { entity: triggerEntity } = parseTrigger(trigger);
3628
- if (triggerEntity !== sourceEntity) {
3629
- throw new Error(
3630
- `defineView '${view.name}': source slice trigger '${trigger}' names entity '${triggerEntity}', but the slice's source entity is '${sourceEntity}'. A view source projects only from its own payload (payload \u540C\u68B1); a trigger on a different entity needs a write-time context fetch (RFC \xA74.A.6), which is a deferred follow-up \u2014 restrict the slice's \`maintainedOn\` to '${sourceEntity}.*' triggers.`
3631
- );
3632
- }
3633
- assertMaintenanceRoundTrips({
3634
- ownerEntity,
3635
- relationProperty: viewSliceProperty(view.name, sliceIndex, slice),
3636
- ownerMeta,
3637
- sourceEntity,
3638
- sourceMeta,
3639
- destinationKeyFields,
3640
- keyBindingSourceFields,
3641
- projection: slice.project
3642
- });
3643
- if (slice.predicate) {
3644
- if (!isPathRooted(slice.predicate.path)) {
3645
- throw new Error(
3646
- `defineView '${view.name}': sparse-view predicate path ${JSON.stringify(slice.predicate.path)} is not a payload-rooted source path (\`$.input.*\` / \`$.entity.*\`). The drain evaluates the predicate against the source image \u2014 use a source attribute path.`
3647
- );
3648
- }
3649
- const predAttr = sourceAttributeOf(slice.predicate.path);
3650
- if (!sourcePayloadAttributes(sourceMeta).has(predAttr)) {
3651
- throw new Error(
3652
- `defineView '${view.name}': sparse-view predicate reads source attribute '${predAttr}' (from '${slice.predicate.path}'), but the source entity '${sourceEntity}' carries no such attribute on its payload (available: ${[...sourcePayloadAttributes(sourceMeta)].sort().map((f) => `'${f}'`).join(", ")}). A membership predicate is payload \u540C\u68B1 only \u2014 reject.`
3653
- );
3654
- }
3655
- }
3656
- const effect = buildViewEffect(view, slice, trigger);
3657
- items.push({
3658
- trigger,
3659
- sourceEntity,
3660
- sourceClass,
3661
- ownerEntity,
3662
- ownerClass,
3663
- relationProperty: viewSliceProperty(view.name, sliceIndex, slice),
3664
- destinationRowKey: destinationRowKeyOf(ownerEntity, keys),
3665
- effect
3666
- });
3667
- }
3668
- });
2336
+ function captureMetaForItem(item, modelBySignature) {
2337
+ const model = modelBySignature.get(execItemKeySignature(item));
2338
+ const labelled = model !== void 0 ? { model } : {};
2339
+ if ("Put" in item) {
2340
+ const Item = item.Put.Item;
2341
+ return {
2342
+ table: item.Put.TableName,
2343
+ ...labelled,
2344
+ op: "put",
2345
+ keys: { pk: String(Item.PK), sk: String(Item.SK ?? "") },
2346
+ newItem: Item
2347
+ };
3669
2348
  }
3670
- assertNoCycle(items);
3671
- const byTrigger = /* @__PURE__ */ new Map();
3672
- const sameRow = /* @__PURE__ */ new Map();
3673
- for (const item of items) {
3674
- const triggerBucket = byTrigger.get(item.trigger);
3675
- if (triggerBucket) triggerBucket.push(item);
3676
- else byTrigger.set(item.trigger, [item]);
3677
- const rowKey = `${item.trigger}\0${item.destinationRowKey}`;
3678
- const rowBucket = sameRow.get(rowKey);
3679
- if (rowBucket) rowBucket.push(item);
3680
- else sameRow.set(rowKey, [item]);
3681
- }
3682
- const multiMaintainerTargets = /* @__PURE__ */ new Map();
3683
- for (const [rowKey, bucket] of sameRow) {
3684
- if (bucket.length > 1) multiMaintainerTargets.set(rowKey, bucket);
2349
+ if ("Update" in item) {
2350
+ return {
2351
+ table: item.Update.TableName,
2352
+ ...labelled,
2353
+ op: "update",
2354
+ keys: { pk: String(item.Update.Key.PK), sk: String(item.Update.Key.SK ?? "") }
2355
+ };
3685
2356
  }
3686
2357
  return {
3687
- items,
3688
- byTrigger,
3689
- effectsFor(trigger) {
3690
- return byTrigger.get(trigger) ?? [];
3691
- },
3692
- multiMaintainerTargets
2358
+ table: item.Delete.TableName,
2359
+ ...labelled,
2360
+ op: "delete",
2361
+ keys: { pk: String(item.Delete.Key.PK), sk: String(item.Delete.Key.SK ?? "") }
3693
2362
  };
3694
2363
  }
3695
-
3696
- // src/cdc/from-change.ts
3697
- function buildFullSelect(metadata) {
3698
- const select = {};
3699
- for (const field of metadata.fields) {
3700
- select[field.propertyName] = true;
3701
- }
3702
- return select;
2364
+ function execItemKeySignature(item) {
2365
+ const part = (rec) => `${String(rec?.PK ?? "")}#${String(rec?.SK ?? "")}`;
2366
+ if ("Put" in item) return `${item.Put.TableName}#${part(item.Put.Item)}`;
2367
+ if ("Update" in item) return `${item.Update.TableName}#${part(item.Update.Key)}`;
2368
+ if ("Delete" in item) return `${item.Delete.TableName}#${part(item.Delete.Key)}`;
2369
+ return `${item.ConditionCheck.TableName}#${part(item.ConditionCheck.Key)}`;
3703
2370
  }
3704
- function eventOwnedBy(event, metadata, modelName) {
3705
- if (event.model !== void 0) {
3706
- return event.model === modelName;
3707
- }
3708
- return metadata.prefix.length > 0 && event.keys.pk.startsWith(metadata.prefix);
2371
+ function execItemKind(item) {
2372
+ if ("Put" in item) return "Put";
2373
+ if ("Update" in item) return "Update";
2374
+ if ("Delete" in item) return "Delete";
2375
+ return "ConditionCheck";
3709
2376
  }
3710
- function hydrateImage(image, select, metadata, modelClass) {
3711
- if (image === void 0) return null;
3712
- const [hydrated] = hydrate([image], select, metadata);
3713
- const instance = new modelClass();
3714
- Object.assign(instance, hydrated);
3715
- for (const emb of metadata.embeddedFields) {
3716
- const rawEmb = image[emb.propertyName];
3717
- if (rawEmb !== void 0 && rawEmb !== null) {
3718
- instance[emb.propertyName] = rawEmb;
2377
+ function mergeAddUpdates(updates) {
2378
+ const first = updates[0];
2379
+ if (!("Update" in first)) {
2380
+ throw new Error("Contract runtime: mergeAddUpdates received a non-Update item.");
2381
+ }
2382
+ const deltas = /* @__PURE__ */ new Map();
2383
+ for (const it of updates) {
2384
+ if (!("Update" in it)) {
2385
+ throw new Error("Contract runtime: cannot merge a non-Update item into an ADD.");
2386
+ }
2387
+ const names2 = it.Update.ExpressionAttributeNames ?? {};
2388
+ const values2 = it.Update.ExpressionAttributeValues ?? {};
2389
+ for (const [namePlaceholder, attr] of Object.entries(names2)) {
2390
+ const valuePlaceholder = `:${namePlaceholder.slice(1)}`;
2391
+ const delta = values2[valuePlaceholder];
2392
+ if (typeof delta !== "number") {
2393
+ throw new Error(
2394
+ `Contract runtime: cannot merge same-key counter updates \u2014 an ADD delta on '${attr}' is not numeric.`
2395
+ );
2396
+ }
2397
+ deltas.set(attr, (deltas.get(attr) ?? 0) + delta);
3719
2398
  }
3720
2399
  }
3721
- return instance;
3722
- }
3723
- function parseChange(event, metadata, modelName, modelClass) {
3724
- if (!eventOwnedBy(event, metadata, modelName)) {
3725
- return [null, null];
2400
+ const names = {};
2401
+ const values = {};
2402
+ const clauses = [];
2403
+ let i = 0;
2404
+ for (const [attr, delta] of deltas) {
2405
+ names[`#a${i}`] = attr;
2406
+ values[`:a${i}`] = delta;
2407
+ clauses.push(`#a${i} :a${i}`);
2408
+ i++;
3726
2409
  }
3727
- const select = buildFullSelect(metadata);
3728
- const oldRecord = hydrateImage(event.oldImage, select, metadata, modelClass);
3729
- const newRecord = hydrateImage(event.newImage, select, metadata, modelClass);
3730
- return [oldRecord, newRecord];
2410
+ const merged = {
2411
+ TableName: first.Update.TableName,
2412
+ Key: first.Update.Key,
2413
+ UpdateExpression: `ADD ${clauses.join(", ")}`,
2414
+ ExpressionAttributeNames: names,
2415
+ ExpressionAttributeValues: values
2416
+ };
2417
+ return { Update: merged };
3731
2418
  }
3732
2419
 
3733
- // src/cdc/subscribe.ts
3734
- function resolveRoutableModels(handlers) {
3735
- const byName = /* @__PURE__ */ new Map();
3736
- for (const [cls, meta] of MetadataRegistry.getAll()) {
3737
- byName.set(cls.name, { cls, meta });
3738
- }
3739
- const routable = /* @__PURE__ */ new Map();
3740
- for (const modelName of Object.keys(handlers)) {
3741
- const entry = byName.get(modelName);
3742
- if (!entry) {
3743
- throw new Error(
3744
- `DDBModel.subscribe: no model named '${modelName}' is registered. Did you import the model module (so its @model decorator ran) and spell the handler key exactly as the class name?`
3745
- );
3746
- }
3747
- if (!entry.meta.cdcProjected) {
3748
- throw new Error(
3749
- `DDBModel.subscribe: model '${modelName}' is not @cdcProjected, so its change events cannot be parsed. Add @cdcProjected() to the model, or remove its handler.`
3750
- );
3751
- }
3752
- routable.set(modelName, {
3753
- modelClass: entry.cls,
3754
- metadata: entry.meta,
3755
- modelName
3756
- });
3757
- }
3758
- return routable;
2420
+ // src/operations/transaction.ts
2421
+ var MODEL_CLASS_KEY = /* @__PURE__ */ Symbol.for("graphddb.modelClass");
2422
+ function attachModelClass(modelStatic, modelClass) {
2423
+ Object.defineProperty(modelStatic, MODEL_CLASS_KEY, {
2424
+ value: modelClass,
2425
+ enumerable: false,
2426
+ configurable: false,
2427
+ writable: false
2428
+ });
2429
+ return modelStatic;
3759
2430
  }
3760
- async function dispatchEvent(event, routable, handlers) {
3761
- for (const { modelClass, metadata, modelName } of routable.values()) {
3762
- const [oldRecord, newRecord] = parseChange(
3763
- event,
3764
- metadata,
3765
- modelName,
3766
- modelClass
2431
+ function resolveModelClass(model) {
2432
+ const modelClass = model[MODEL_CLASS_KEY];
2433
+ if (!modelClass) {
2434
+ throw new Error(
2435
+ "Invalid model reference. Use ModelStatic from DDBModel.asModel()."
3767
2436
  );
3768
- if (oldRecord === null && newRecord === null) continue;
3769
- const handler = handlers[modelName];
3770
- await handler(oldRecord, newRecord);
3771
- return true;
3772
2437
  }
3773
- return false;
3774
- }
3775
- function buildSubscribeHandler(handlers) {
3776
- const routable = resolveRoutableModels(handlers);
3777
- return async (batch) => {
3778
- const batchItemFailures = [];
3779
- for (const event of batch.records) {
3780
- try {
3781
- await dispatchEvent(event, routable, handlers);
3782
- } catch {
3783
- batchItemFailures.push(event.sequenceNumber);
3784
- }
3785
- }
3786
- return { batchItemFailures };
3787
- };
2438
+ return modelClass;
3788
2439
  }
3789
2440
 
3790
2441
  export {
@@ -3793,34 +2444,16 @@ export {
3793
2444
  pkTemplate,
3794
2445
  skTemplate,
3795
2446
  resolveSegmentedKey,
3796
- SELECT_SPEC,
3797
- isSelectBuilder,
3798
- normalizeSelectSpec,
3799
- normalizeTopLevelSelect,
3800
- buildProject,
3801
- buildRelation,
3802
- isInlineSnapshotSpec,
3803
- detectRelationFields,
3804
- validateInlineSnapshotSelect,
3805
- getImplicitKeyFields,
3806
2447
  param,
3807
2448
  isParam,
3808
2449
  BATCH_GET_MAX_KEYS,
3809
- BATCH_WRITE_MAX_ITEMS,
3810
2450
  chunkArray,
3811
- DynamoExecutor,
3812
- DEFAULT_MAX_ATTEMPTS,
3813
- DEFAULT_RETRY_POLICY,
3814
- isRetryableError,
3815
- isRetryableTransactionCancellation,
3816
- RetryingExecutor,
3817
2451
  ClientManager,
3818
2452
  isRawCondition,
3819
2453
  cond,
3820
2454
  compileRawCondition,
3821
2455
  condParamName,
3822
2456
  serializeRawCondition,
3823
- collectRawConditionParams,
3824
2457
  CONDITION_OPERATOR_KEYS,
3825
2458
  resolveConditionTree,
3826
2459
  buildConditionExpression,
@@ -3831,7 +2464,6 @@ export {
3831
2464
  buildReadRuntime,
3832
2465
  buildWriteRuntime,
3833
2466
  ctxModelFor,
3834
- buildUpdateExpression,
3835
2467
  attachHiddenKey,
3836
2468
  buildUpdateInput,
3837
2469
  executeUpdate,
@@ -3840,27 +2472,8 @@ export {
3840
2472
  buildPutInput,
3841
2473
  executePut,
3842
2474
  MAX_TRANSACT_ITEMS,
3843
- collapseSameKeyItems,
3844
2475
  commitTransaction,
3845
2476
  execItemKeySignature,
3846
2477
  attachModelClass,
3847
- resolveModelClass,
3848
- TransactionContext,
3849
- executeTransaction,
3850
- hydrate,
3851
- maintainTrigger,
3852
- isMaintainTrigger,
3853
- preview,
3854
- identity,
3855
- LIFECYCLE_CONTRACT_MARKER,
3856
- isLifecycleContract,
3857
- ENTITY_WRITES_MARKER,
3858
- isEntityWritesDefinition,
3859
- entityWrites,
3860
- getEntityWrites,
3861
- lifecyclePhaseForIntent,
3862
- collectViewDefinitions,
3863
- buildMaintenanceGraph,
3864
- parseChange,
3865
- buildSubscribeHandler
2478
+ resolveModelClass
3866
2479
  };