graphddb 0.2.4 → 0.3.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.
@@ -4,17 +4,22 @@ import {
4
4
  ClientManager,
5
5
  MAX_TRANSACT_ITEMS,
6
6
  MetadataRegistry,
7
+ NO_MIDDLEWARE,
7
8
  TableMapping,
8
9
  attachHiddenKey,
9
10
  attachModelClass,
10
11
  buildConditionExpression,
11
12
  buildDeleteInput,
12
13
  buildPutInput,
14
+ buildReadRuntime,
13
15
  buildUpdateInput,
16
+ buildWriteRuntime,
14
17
  captureWrite,
18
+ chunkArray,
15
19
  commitTransaction,
16
20
  compileRawCondition,
17
21
  createColumnMap,
22
+ ctxModelFor,
18
23
  execItemKeySignature,
19
24
  executeDelete,
20
25
  executePut,
@@ -32,7 +37,7 @@ import {
32
37
  serializeFieldValue,
33
38
  serializeRawCondition,
34
39
  skTemplate
35
- } from "./chunk-GCPEUPAA.js";
40
+ } from "./chunk-6AIAHP3A.js";
36
41
 
37
42
  // src/spec/types.ts
38
43
  var SPEC_VERSION = "1.0";
@@ -810,23 +815,71 @@ async function executeListInternal(modelClass, key, selectSpec, options = {}) {
810
815
  updatable: options.updatable ?? false
811
816
  });
812
817
  const operation = executionPlan.operations[0];
813
- const result = await execute(
814
- operation,
815
- options.retry !== void 0 ? { retry: options.retry } : void 0
818
+ const mw = options.mw ?? NO_MIDDLEWARE;
819
+ const sendList = (op) => execute(op, options.retry !== void 0 ? { retry: options.retry } : void 0).then(
820
+ (r) => r
816
821
  );
817
- const rawItems = result.items;
822
+ let lastEvaluatedKey;
823
+ let rawItems;
824
+ if (mw.active) {
825
+ let captured;
826
+ const opModel = ctxModelFor(modelClass);
827
+ rawItems = await mw.runOp(
828
+ operation,
829
+ options.relationPath ?? [],
830
+ opModel,
831
+ async (op) => {
832
+ captured = await sendList(op);
833
+ return captured.items;
834
+ }
835
+ );
836
+ lastEvaluatedKey = captured?.lastEvaluatedKey;
837
+ } else {
838
+ const result = await sendList(operation);
839
+ rawItems = result.items;
840
+ lastEvaluatedKey = result.lastEvaluatedKey;
841
+ }
818
842
  const hydrated = hydrate(rawItems, select, metadata, options.updatable ?? false);
819
- const cursor = result.lastEvaluatedKey ? encodeCursor(result.lastEvaluatedKey) : null;
843
+ const cursor = lastEvaluatedKey ? encodeCursor(lastEvaluatedKey) : null;
820
844
  return { items: hydrated, rawItems, cursor };
821
845
  }
822
846
  async function executeList(modelClass, key, selectSpec, options = {}) {
823
- const { items, cursor } = await executeListInternal(
824
- modelClass,
825
- key,
826
- selectSpec,
827
- options
828
- );
829
- return { items, cursor };
847
+ const mw = buildReadRuntime(options.context);
848
+ if (!mw.active) {
849
+ const { items, cursor } = await executeListInternal(
850
+ modelClass,
851
+ key,
852
+ selectSpec,
853
+ options
854
+ );
855
+ return { items, cursor };
856
+ }
857
+ const ctxModel = ctxModelFor(modelClass);
858
+ const opts = { ...options };
859
+ const params = {
860
+ key: { ...key },
861
+ select: selectSpec && typeof selectSpec === "object" ? selectSpec : {},
862
+ options: opts
863
+ };
864
+ let reqCtx;
865
+ try {
866
+ reqCtx = await mw.runRequestBefore("list", ctxModel, params);
867
+ const { items, cursor } = await executeListInternal(
868
+ modelClass,
869
+ params.key,
870
+ params.select,
871
+ // Thread the SAME runtime + an empty (root) relation path so R2/R3 fire on
872
+ // the partition Query; carry the (possibly R1-mutated) per-call options.
873
+ { ...params.options, mw, relationPath: [] }
874
+ );
875
+ const result = { items, cursor };
876
+ return await mw.runRequestAfter(reqCtx, result);
877
+ } catch (err) {
878
+ return mw.runRequestError(
879
+ reqCtx ?? mw.requestCtx("list", ctxModel, params),
880
+ err
881
+ );
882
+ }
830
883
  }
831
884
 
832
885
  // src/planner/relation-planner.ts
@@ -1174,12 +1227,23 @@ async function fetchBelongsToHasOneBatch(rel, rawParentItems, selectSpec, target
1174
1227
  );
1175
1228
  const executor = ClientManager.getExecutor();
1176
1229
  const queryKeyToRaw = /* @__PURE__ */ new Map();
1230
+ const mw = options.mw ?? NO_MIDDLEWARE;
1231
+ const opPath = [...options.relationPath ?? [], rel.propertyName];
1232
+ const opModel = mw.active ? ctxModelFor(rel.targetFactory()) : void 0;
1177
1233
  for (const operation of batchOps) {
1178
- const batchResult = await executor.batchGet(
1234
+ const items = mw.active ? await mw.runOp(
1235
+ operation,
1236
+ opPath,
1237
+ opModel,
1238
+ (op) => executor.batchGet(
1239
+ op,
1240
+ options.retry !== void 0 ? { retry: options.retry } : void 0
1241
+ ).then((r) => r.items)
1242
+ ) : (await executor.batchGet(
1179
1243
  operation,
1180
1244
  options.retry !== void 0 ? { retry: options.retry } : void 0
1181
- );
1182
- for (const item of batchResult.items) {
1245
+ )).items;
1246
+ for (const item of items) {
1183
1247
  for (const queryKey of queryKeys) {
1184
1248
  if (!matchesQueryKey(item, queryKey)) {
1185
1249
  continue;
@@ -1265,9 +1329,11 @@ async function resolveRelations(parentItem, rawParentItem, select, metadata, opt
1265
1329
  const relations = detectRelationFields(select, metadata);
1266
1330
  const plan2 = options.plan ?? (currentPath === "$" ? buildRelationExecutionPlan(select, metadata) : void 0);
1267
1331
  const opts = plan2 === options.plan ? options : { ...options, plan: plan2 };
1332
+ const basePath = opts.relationPath ?? [];
1268
1333
  await runStaged(relations, currentPath, opts.plan, async (rel) => {
1269
1334
  const selectSpec = relationSpec(select[rel.propertyName]);
1270
1335
  const ownPath = relationResultPath(currentPath, rel);
1336
+ const relPath = [...basePath, rel.propertyName];
1271
1337
  if (rel.type === "hasMany") {
1272
1338
  const targetClass2 = rel.targetFactory();
1273
1339
  const queryKey = buildRelationQueryKey(rel, rawParentItem);
@@ -1279,7 +1345,9 @@ async function resolveRelations(parentItem, rawParentItem, select, metadata, opt
1279
1345
  order,
1280
1346
  filter: selectSpec.filter,
1281
1347
  updatable: opts.updatable,
1282
- retry: opts.retry
1348
+ retry: opts.retry,
1349
+ mw: opts.mw,
1350
+ relationPath: relPath
1283
1351
  });
1284
1352
  let items = listResult.items;
1285
1353
  const rawItems = listResult.rawItems;
@@ -1291,7 +1359,7 @@ async function resolveRelations(parentItem, rawParentItem, select, metadata, opt
1291
1359
  rawItems,
1292
1360
  selectSpec.select,
1293
1361
  targetMeta2,
1294
- opts,
1362
+ { ...opts, relationPath: relPath },
1295
1363
  currentDepth + 1,
1296
1364
  ownPath
1297
1365
  );
@@ -1320,6 +1388,36 @@ async function resolveRelations(parentItem, rawParentItem, select, metadata, opt
1320
1388
 
1321
1389
  // src/operations/query.ts
1322
1390
  async function executeQuery(modelClass, key, selectSpec, options) {
1391
+ const mw = buildReadRuntime(options?.context);
1392
+ if (!mw.active) {
1393
+ return executeQueryInner(modelClass, key, selectSpec, options, mw);
1394
+ }
1395
+ const ctxModel = ctxModelFor(modelClass);
1396
+ const opts = { ...options ?? {} };
1397
+ const params = {
1398
+ key: { ...key },
1399
+ select: selectSpec && typeof selectSpec === "object" ? selectSpec : {},
1400
+ options: opts
1401
+ };
1402
+ let reqCtx;
1403
+ try {
1404
+ reqCtx = await mw.runRequestBefore("query", ctxModel, params);
1405
+ const result = await executeQueryInner(
1406
+ modelClass,
1407
+ params.key,
1408
+ params.select,
1409
+ params.options,
1410
+ mw
1411
+ );
1412
+ return await mw.runRequestAfter(reqCtx, result);
1413
+ } catch (err) {
1414
+ return mw.runRequestError(
1415
+ reqCtx ?? mw.requestCtx("query", ctxModel, params),
1416
+ err
1417
+ );
1418
+ }
1419
+ }
1420
+ async function executeQueryInner(modelClass, key, selectSpec, options, mw) {
1323
1421
  const metadata = MetadataRegistry.get(modelClass);
1324
1422
  const queryFields = Object.keys(key);
1325
1423
  const resolved = resolveKey(queryFields, metadata);
@@ -1353,11 +1451,25 @@ async function executeQuery(modelClass, key, selectSpec, options) {
1353
1451
  )
1354
1452
  );
1355
1453
  const retry = options?.retry;
1356
- const parentPromise = execute(
1357
- operation,
1358
- retry !== void 0 ? { retry } : void 0
1359
- );
1360
- const relationPromise = canParallelizeRelations ? resolveRelations({}, key, select, metadata, { maxDepth, updatable, retry }, 1) : null;
1454
+ const sendRoot = () => execute(operation, retry !== void 0 ? { retry } : void 0);
1455
+ const parentPromise = mw.active ? (() => {
1456
+ const opModel = ctxModelFor(modelClass);
1457
+ return mw.runOp(operation, [], opModel, async (op) => {
1458
+ const r = await execute(
1459
+ op,
1460
+ retry !== void 0 ? { retry } : void 0
1461
+ );
1462
+ return r.items;
1463
+ }).then((items) => ({ items }));
1464
+ })() : sendRoot();
1465
+ const relationPromise = canParallelizeRelations ? resolveRelations(
1466
+ {},
1467
+ key,
1468
+ select,
1469
+ metadata,
1470
+ { maxDepth, updatable, retry, mw, relationPath: [] },
1471
+ 1
1472
+ ) : null;
1361
1473
  let result;
1362
1474
  try {
1363
1475
  result = await parentPromise;
@@ -1384,7 +1496,7 @@ async function executeQuery(modelClass, key, selectSpec, options) {
1384
1496
  rawItem,
1385
1497
  select,
1386
1498
  metadata,
1387
- { maxDepth, updatable, retry },
1499
+ { maxDepth, updatable, retry, mw, relationPath: [] },
1388
1500
  1
1389
1501
  );
1390
1502
  if (hydratedItem && updatable) {
@@ -1465,16 +1577,37 @@ function hydrateBatchItems(rawItems, metadata) {
1465
1577
  return merged;
1466
1578
  });
1467
1579
  }
1468
- async function executeBatchGetForTable(tableName, entries) {
1580
+ async function executeBatchGetForTable(tableName, entries, mw, opModel) {
1469
1581
  const groups = /* @__PURE__ */ new Map();
1470
1582
  if (entries.length === 0) {
1471
1583
  return groups;
1472
1584
  }
1473
1585
  const keys = entries.map((entry) => entry.dynamoKey);
1474
- const { items: rawItems } = await ClientManager.getExecutor().batchGet({
1475
- tableName,
1476
- keys
1477
- });
1586
+ const sendChunk = (op) => execute(op).then((r) => r.items);
1587
+ let rawItems;
1588
+ if (mw.active) {
1589
+ rawItems = [];
1590
+ for (const chunk of chunkArray(keys, BATCH_GET_MAX_KEYS)) {
1591
+ const operation = {
1592
+ type: "BatchGetItem",
1593
+ tableName,
1594
+ keys: chunk
1595
+ };
1596
+ const chunkItems = await mw.runOp(
1597
+ operation,
1598
+ [],
1599
+ opModel,
1600
+ (op) => sendChunk(op)
1601
+ );
1602
+ rawItems.push(...chunkItems);
1603
+ }
1604
+ } else {
1605
+ const { items } = await ClientManager.getExecutor().batchGet({
1606
+ tableName,
1607
+ keys
1608
+ });
1609
+ rawItems = items;
1610
+ }
1478
1611
  const entriesByModel = /* @__PURE__ */ new Map();
1479
1612
  for (const entry of entries) {
1480
1613
  const existing = entriesByModel.get(entry.modelClass) ?? [];
@@ -1495,12 +1628,42 @@ async function executeBatchGetForTable(tableName, entries) {
1495
1628
  }
1496
1629
  return groups;
1497
1630
  }
1498
- async function executeBatchGet(requests) {
1631
+ async function executeBatchGet(requests, options) {
1632
+ const mw = buildReadRuntime(options?.context);
1633
+ if (!mw.active || requests.length === 0) {
1634
+ return executeBatchGetInner(requests, mw);
1635
+ }
1636
+ const firstModel = ctxModelFor(resolveModelClass(requests[0].model));
1637
+ const reqList = [...requests];
1638
+ const params = {
1639
+ key: {},
1640
+ select: {},
1641
+ options: {},
1642
+ requests: reqList
1643
+ };
1644
+ let reqCtx;
1645
+ try {
1646
+ reqCtx = await mw.runRequestBefore("batchGet", firstModel, params);
1647
+ const effectiveRequests = params.requests;
1648
+ const result = await executeBatchGetInner(effectiveRequests, mw);
1649
+ return await mw.runRequestAfter(reqCtx, result);
1650
+ } catch (err) {
1651
+ return mw.runRequestError(
1652
+ reqCtx ?? mw.requestCtx("batchGet", firstModel, params),
1653
+ err
1654
+ );
1655
+ }
1656
+ }
1657
+ async function executeBatchGetInner(requests, mw) {
1499
1658
  const entriesByTable = /* @__PURE__ */ new Map();
1659
+ const firstModelClassByTable = /* @__PURE__ */ new Map();
1500
1660
  for (const request of requests) {
1501
1661
  const modelClass = resolveModelClass(request.model);
1502
1662
  const metadata = MetadataRegistry.get(modelClass);
1503
1663
  const tableName = TableMapping.resolve(metadata.tableName);
1664
+ if (!firstModelClassByTable.has(tableName)) {
1665
+ firstModelClassByTable.set(tableName, modelClass);
1666
+ }
1504
1667
  for (const keyObj of request.keys) {
1505
1668
  const entry = {
1506
1669
  modelClass,
@@ -1515,7 +1678,13 @@ async function executeBatchGet(requests) {
1515
1678
  const mergedGroups = /* @__PURE__ */ new Map();
1516
1679
  for (const [, entries] of entriesByTable) {
1517
1680
  const tableName = TableMapping.resolve(entries[0].metadata.tableName);
1518
- const tableGroups = await executeBatchGetForTable(tableName, entries);
1681
+ const opModel = mw.active ? ctxModelFor(firstModelClassByTable.get(tableName) ?? entries[0].modelClass) : void 0;
1682
+ const tableGroups = await executeBatchGetForTable(
1683
+ tableName,
1684
+ entries,
1685
+ mw,
1686
+ opModel
1687
+ );
1519
1688
  for (const [modelClass, items] of tableGroups) {
1520
1689
  const existing = mergedGroups.get(modelClass) ?? [];
1521
1690
  mergedGroups.set(modelClass, existing.concat(items));
@@ -1588,6 +1757,46 @@ async function executeBatchWrite(requests) {
1588
1757
  }
1589
1758
 
1590
1759
  // src/define/entity-writes.ts
1760
+ var MAINTAIN_TRIGGER_RE = /^[^.\s$[\]*]+\.(?:created|updated|removed)$/;
1761
+ function maintainTrigger(value) {
1762
+ if (typeof value !== "string" || !MAINTAIN_TRIGGER_RE.test(value)) {
1763
+ throw new Error(
1764
+ `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)}.`
1765
+ );
1766
+ }
1767
+ return value;
1768
+ }
1769
+ function isMaintainTrigger(value) {
1770
+ return typeof value === "string" && MAINTAIN_TRIGGER_RE.test(value);
1771
+ }
1772
+ function makeProjectionTransform(path, op, args) {
1773
+ if (op === "preview") {
1774
+ const [n] = args;
1775
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
1776
+ throw new Error(
1777
+ `transform: the \`preview\` op requires a positive integer length bound (e.g. \`preview('$.body', 200)\`), but received ${JSON.stringify(n)}.`
1778
+ );
1779
+ }
1780
+ return { kind: "transform", path, op, args: [n] };
1781
+ }
1782
+ if (op === "identity") {
1783
+ if (args.length > 0) {
1784
+ throw new Error(
1785
+ `transform: the \`identity\` op takes no arguments, but received ${JSON.stringify(args)}.`
1786
+ );
1787
+ }
1788
+ return { kind: "transform", path, op, args: [] };
1789
+ }
1790
+ throw new Error(
1791
+ `transform: unknown projection op ${JSON.stringify(op)} (expected \`identity\` or \`preview\`).`
1792
+ );
1793
+ }
1794
+ function preview(path, n) {
1795
+ return makeProjectionTransform(path, "preview", [n]);
1796
+ }
1797
+ function identity(path) {
1798
+ return makeProjectionTransform(path, "identity", []);
1799
+ }
1591
1800
  var LIFECYCLE_CONTRACT_MARKER = /* @__PURE__ */ Symbol(
1592
1801
  "graphddb:lifecycleContract"
1593
1802
  );
@@ -1630,6 +1839,9 @@ var recorder = {
1630
1839
  increment(targetFactory, keys, attribute, amount) {
1631
1840
  return { kind: "derive", targetFactory, keys, attribute, amount };
1632
1841
  },
1842
+ transform(path, op, ...args) {
1843
+ return makeProjectionTransform(path, op, args);
1844
+ },
1633
1845
  event(name, payload) {
1634
1846
  return { kind: "event", name, payload };
1635
1847
  },
@@ -1817,6 +2029,387 @@ function deriveModelEdgeWriteItems(modelClass, lifecycle) {
1817
2029
  return deriveEdgeWriteItems(modelClass, writes, lifecycle);
1818
2030
  }
1819
2031
 
2032
+ // src/relation/maintenance-graph.ts
2033
+ var COUNT_DELTA_FOR_EVENT = {
2034
+ created: 1,
2035
+ removed: -1,
2036
+ updated: 0
2037
+ };
2038
+ var MAINTAIN_EVENTS = ["created", "updated", "removed"];
2039
+ function isPathRooted(value) {
2040
+ return value.startsWith("$.input.") || value.startsWith("$.entity.");
2041
+ }
2042
+ function sourceAttributeOf(path) {
2043
+ const stripped = path.replace(/^\$\.(input|entity)\./, "");
2044
+ return stripped.split(".")[0] ?? stripped;
2045
+ }
2046
+ function normalizeProjectionEntry(value) {
2047
+ if (typeof value === "string") {
2048
+ const path = isPathRooted(value) ? value : `$.entity.${value}`;
2049
+ return identity(path);
2050
+ }
2051
+ return value;
2052
+ }
2053
+ function buildProjectionMap(projection, ownerEntity, relationProperty) {
2054
+ const entries = projection ? Object.entries(projection) : [];
2055
+ if (entries.length === 0) {
2056
+ throw new Error(
2057
+ `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) }\`).`
2058
+ );
2059
+ }
2060
+ const out = {};
2061
+ for (const [attr, value] of entries) {
2062
+ out[attr] = normalizeProjectionEntry(value);
2063
+ }
2064
+ return out;
2065
+ }
2066
+ function buildDestinationKeys(keyBinding) {
2067
+ const keys = {};
2068
+ const sourceFields = [];
2069
+ for (const [sourceField, ownerField] of Object.entries(keyBinding)) {
2070
+ keys[ownerField] = `$.entity.${sourceField}`;
2071
+ sourceFields.push(sourceField);
2072
+ }
2073
+ return { keys, sourceFields };
2074
+ }
2075
+ function sourcePayloadAttributes(meta) {
2076
+ const attrs = /* @__PURE__ */ new Set();
2077
+ for (const f of meta.fields) attrs.add(f.propertyName);
2078
+ if (meta.primaryKey) {
2079
+ for (const f of segmentFieldNames(meta.primaryKey.segmented.pkSegments)) attrs.add(f);
2080
+ for (const f of segmentFieldNames(meta.primaryKey.segmented.skSegments)) attrs.add(f);
2081
+ }
2082
+ for (const gsi of meta.gsiDefinitions) {
2083
+ for (const f of segmentFieldNames(gsi.segmented.pkSegments)) attrs.add(f);
2084
+ for (const f of segmentFieldNames(gsi.segmented.skSegments)) attrs.add(f);
2085
+ }
2086
+ return attrs;
2087
+ }
2088
+ function assertMaintenanceRoundTrips(args) {
2089
+ const {
2090
+ ownerEntity,
2091
+ relationProperty,
2092
+ ownerMeta,
2093
+ sourceEntity,
2094
+ sourceMeta,
2095
+ destinationKeyFields,
2096
+ keyBindingSourceFields,
2097
+ projection
2098
+ } = args;
2099
+ const sourceAttrs = sourcePayloadAttributes(sourceMeta);
2100
+ try {
2101
+ resolveKey([...destinationKeyFields], ownerMeta);
2102
+ } catch (cause) {
2103
+ throw new Error(
2104
+ `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})`
2105
+ );
2106
+ }
2107
+ for (const field of keyBindingSourceFields) {
2108
+ if (!sourceAttrs.has(field)) {
2109
+ throw new Error(
2110
+ `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.`
2111
+ );
2112
+ }
2113
+ }
2114
+ for (const [attr, transform] of Object.entries(projection)) {
2115
+ if (!isPathRooted(transform.path)) {
2116
+ throw new Error(
2117
+ `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.`
2118
+ );
2119
+ }
2120
+ const srcAttr = sourceAttributeOf(transform.path);
2121
+ if (!sourceAttrs.has(srcAttr)) {
2122
+ throw new Error(
2123
+ `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.`
2124
+ );
2125
+ }
2126
+ }
2127
+ }
2128
+ function assertCounterRoundTrips(args) {
2129
+ const {
2130
+ ownerEntity,
2131
+ aggregateField,
2132
+ ownerMeta,
2133
+ sourceEntity,
2134
+ sourceMeta,
2135
+ destinationKeyFields,
2136
+ keyBindingSourceFields,
2137
+ valueSourceFields
2138
+ } = args;
2139
+ const sourceAttrs = sourcePayloadAttributes(sourceMeta);
2140
+ try {
2141
+ resolveKey([...destinationKeyFields], ownerMeta);
2142
+ } catch (cause) {
2143
+ throw new Error(
2144
+ `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})`
2145
+ );
2146
+ }
2147
+ for (const field of keyBindingSourceFields) {
2148
+ if (!sourceAttrs.has(field)) {
2149
+ throw new Error(
2150
+ `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.`
2151
+ );
2152
+ }
2153
+ }
2154
+ for (const field of valueSourceFields) {
2155
+ if (!sourceAttrs.has(field)) {
2156
+ throw new Error(
2157
+ `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.`
2158
+ );
2159
+ }
2160
+ }
2161
+ }
2162
+ function logicalNameOf(meta) {
2163
+ return meta.prefix.endsWith("#") ? meta.prefix.slice(0, -1) : meta.prefix;
2164
+ }
2165
+ function resolveEntityByName(name, registry) {
2166
+ for (const [klass, meta] of registry) {
2167
+ if (logicalNameOf(meta) === name) return klass;
2168
+ }
2169
+ return void 0;
2170
+ }
2171
+ function parseTrigger(raw) {
2172
+ const idx = raw.lastIndexOf(".");
2173
+ return { entity: raw.slice(0, idx), event: raw.slice(idx + 1) };
2174
+ }
2175
+ function buildEffect(relation, ownerFactory, trigger, keys, project) {
2176
+ const write = relation.options?.write;
2177
+ const updateMode = write?.updateMode;
2178
+ const consistency = write?.consistency;
2179
+ const base = {
2180
+ trigger,
2181
+ targetFactory: ownerFactory,
2182
+ keys,
2183
+ project,
2184
+ ...updateMode !== void 0 ? { updateMode } : {},
2185
+ ...consistency !== void 0 ? { consistency } : {}
2186
+ };
2187
+ if (relation.type === "hasMany") {
2188
+ const read = relation.options?.read;
2189
+ const collection = {
2190
+ field: relation.propertyName,
2191
+ ...read?.maxItems !== void 0 ? { maxItems: read.maxItems } : {}
2192
+ };
2193
+ return { kind: "collection", ...base, collection };
2194
+ }
2195
+ return { kind: "snapshot", ...base };
2196
+ }
2197
+ function buildCounterEffect(aggregate, ownerFactory, trigger, event, keys) {
2198
+ const write = aggregate.options.write;
2199
+ const updateMode = write?.updateMode;
2200
+ const consistency = write?.consistency;
2201
+ const value = aggregate.options.value.op === "count" ? { op: "count" } : { op: "max", field: aggregate.options.value.field };
2202
+ return {
2203
+ kind: "counter",
2204
+ trigger,
2205
+ targetFactory: ownerFactory,
2206
+ keys,
2207
+ attribute: aggregate.propertyName,
2208
+ value,
2209
+ ...value.op === "count" ? { delta: COUNT_DELTA_FOR_EVENT[event] } : {},
2210
+ ...updateMode !== void 0 ? { updateMode } : {},
2211
+ ...consistency !== void 0 ? { consistency } : {}
2212
+ };
2213
+ }
2214
+ function destinationRowKeyOf(ownerEntity, keys) {
2215
+ const parts = Object.entries(keys).map(([field, path]) => `${field}=${path}`).sort();
2216
+ return `${ownerEntity}#${parts.join("&")}`;
2217
+ }
2218
+ function assertNoCycle(items) {
2219
+ const adjacency = /* @__PURE__ */ new Map();
2220
+ for (const item of items) {
2221
+ if (!adjacency.has(item.sourceEntity)) adjacency.set(item.sourceEntity, /* @__PURE__ */ new Set());
2222
+ adjacency.get(item.sourceEntity).add(item.ownerEntity);
2223
+ }
2224
+ const WHITE = 0;
2225
+ const GRAY = 1;
2226
+ const BLACK = 2;
2227
+ const color = /* @__PURE__ */ new Map();
2228
+ for (const node of adjacency.keys()) color.set(node, WHITE);
2229
+ for (const start of adjacency.keys()) {
2230
+ if (color.get(start) !== WHITE) continue;
2231
+ const stack = [];
2232
+ const path = [];
2233
+ color.set(start, GRAY);
2234
+ path.push(start);
2235
+ stack.push({ node: start, iter: (adjacency.get(start) ?? /* @__PURE__ */ new Set()).values() });
2236
+ while (stack.length > 0) {
2237
+ const top = stack[stack.length - 1];
2238
+ const next = top.iter.next();
2239
+ if (next.done) {
2240
+ color.set(top.node, BLACK);
2241
+ stack.pop();
2242
+ path.pop();
2243
+ continue;
2244
+ }
2245
+ const child = next.value;
2246
+ const childColor = color.get(child) ?? WHITE;
2247
+ if (childColor === GRAY) {
2248
+ const from2 = path.indexOf(child);
2249
+ const cycle = [...path.slice(from2), child].join(" \u2192 ");
2250
+ throw new Error(
2251
+ `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).`
2252
+ );
2253
+ }
2254
+ if (childColor === WHITE) {
2255
+ color.set(child, GRAY);
2256
+ path.push(child);
2257
+ stack.push({ node: child, iter: (adjacency.get(child) ?? /* @__PURE__ */ new Set()).values() });
2258
+ }
2259
+ }
2260
+ }
2261
+ }
2262
+ function buildMaintenanceGraph(registry = MetadataRegistry.getAll()) {
2263
+ const items = [];
2264
+ 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);
2265
+ for (const { klass: ownerClass, meta: ownerMeta } of owners) {
2266
+ const ownerEntity = logicalNameOf(ownerMeta);
2267
+ const relations = [...ownerMeta.relations].sort(
2268
+ (a, b) => a.propertyName < b.propertyName ? -1 : a.propertyName > b.propertyName ? 1 : 0
2269
+ );
2270
+ for (const relation of relations) {
2271
+ const maintainedOn = relation.options?.write?.maintainedOn;
2272
+ if (!maintainedOn || maintainedOn.length === 0) continue;
2273
+ const sourceClass = relation.targetFactory();
2274
+ const sourceMeta = MetadataRegistry.get(sourceClass);
2275
+ const relationTargetEntity = logicalNameOf(sourceMeta);
2276
+ const project = buildProjectionMap(
2277
+ relation.options?.projection,
2278
+ ownerEntity,
2279
+ relation.propertyName
2280
+ );
2281
+ const { keys, sourceFields } = buildDestinationKeys(relation.keyBinding);
2282
+ const destinationKeyFields = Object.keys(keys);
2283
+ for (const raw of maintainedOn) {
2284
+ if (!isMaintainTrigger(raw)) {
2285
+ const { event } = parseTrigger(raw);
2286
+ throw new Error(
2287
+ `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.`
2288
+ );
2289
+ }
2290
+ const trigger = raw;
2291
+ const { entity: triggerEntity } = parseTrigger(trigger);
2292
+ if (!resolveEntityByName(triggerEntity, registry)) {
2293
+ throw new Error(
2294
+ `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.`
2295
+ );
2296
+ }
2297
+ if (triggerEntity !== relationTargetEntity) {
2298
+ throw new Error(
2299
+ `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.`
2300
+ );
2301
+ }
2302
+ assertMaintenanceRoundTrips({
2303
+ ownerEntity,
2304
+ relationProperty: relation.propertyName,
2305
+ ownerMeta,
2306
+ sourceEntity: triggerEntity,
2307
+ sourceMeta,
2308
+ destinationKeyFields,
2309
+ keyBindingSourceFields: sourceFields,
2310
+ projection: project
2311
+ });
2312
+ const effect = buildEffect(relation, () => ownerClass, trigger, keys, project);
2313
+ items.push({
2314
+ trigger,
2315
+ sourceEntity: triggerEntity,
2316
+ sourceClass,
2317
+ ownerEntity,
2318
+ ownerClass,
2319
+ relationProperty: relation.propertyName,
2320
+ destinationRowKey: destinationRowKeyOf(ownerEntity, keys),
2321
+ effect
2322
+ });
2323
+ }
2324
+ }
2325
+ const aggregates = [...ownerMeta.aggregates].sort(
2326
+ (a, b) => a.propertyName < b.propertyName ? -1 : a.propertyName > b.propertyName ? 1 : 0
2327
+ );
2328
+ for (const agg of aggregates) {
2329
+ const maintainedOn = agg.options.write?.maintainedOn;
2330
+ if (!maintainedOn || maintainedOn.length === 0) continue;
2331
+ const sourceClass = agg.targetFactory();
2332
+ const sourceMeta = MetadataRegistry.get(sourceClass);
2333
+ const aggSourceEntity = logicalNameOf(sourceMeta);
2334
+ const { keys, sourceFields } = buildDestinationKeys(agg.keyBinding);
2335
+ const destinationKeyFields = Object.keys(keys);
2336
+ const valueSourceFields = agg.options.value.op === "max" ? [agg.options.value.field] : [];
2337
+ for (const raw of maintainedOn) {
2338
+ if (!isMaintainTrigger(raw)) {
2339
+ const { event: event2 } = parseTrigger(raw);
2340
+ throw new Error(
2341
+ `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.`
2342
+ );
2343
+ }
2344
+ const trigger = raw;
2345
+ const parsed = parseTrigger(trigger);
2346
+ const triggerEntity = parsed.entity;
2347
+ const event = parsed.event;
2348
+ if (!resolveEntityByName(triggerEntity, registry)) {
2349
+ throw new Error(
2350
+ `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.`
2351
+ );
2352
+ }
2353
+ if (triggerEntity !== aggSourceEntity) {
2354
+ throw new Error(
2355
+ `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.`
2356
+ );
2357
+ }
2358
+ if (agg.options.value.op === "count" && COUNT_DELTA_FOR_EVENT[event] === 0) {
2359
+ throw new Error(
2360
+ `\`@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.`
2361
+ );
2362
+ }
2363
+ assertCounterRoundTrips({
2364
+ ownerEntity,
2365
+ aggregateField: agg.propertyName,
2366
+ ownerMeta,
2367
+ sourceEntity: triggerEntity,
2368
+ sourceMeta,
2369
+ destinationKeyFields,
2370
+ keyBindingSourceFields: sourceFields,
2371
+ valueSourceFields
2372
+ });
2373
+ const effect = buildCounterEffect(agg, () => ownerClass, trigger, event, keys);
2374
+ items.push({
2375
+ trigger,
2376
+ sourceEntity: triggerEntity,
2377
+ sourceClass,
2378
+ ownerEntity,
2379
+ ownerClass,
2380
+ relationProperty: agg.propertyName,
2381
+ destinationRowKey: destinationRowKeyOf(ownerEntity, keys),
2382
+ effect
2383
+ });
2384
+ }
2385
+ }
2386
+ }
2387
+ assertNoCycle(items);
2388
+ const byTrigger = /* @__PURE__ */ new Map();
2389
+ const sameRow = /* @__PURE__ */ new Map();
2390
+ for (const item of items) {
2391
+ const triggerBucket = byTrigger.get(item.trigger);
2392
+ if (triggerBucket) triggerBucket.push(item);
2393
+ else byTrigger.set(item.trigger, [item]);
2394
+ const rowKey = `${item.trigger}\0${item.destinationRowKey}`;
2395
+ const rowBucket = sameRow.get(rowKey);
2396
+ if (rowBucket) rowBucket.push(item);
2397
+ else sameRow.set(rowKey, [item]);
2398
+ }
2399
+ const multiMaintainerTargets = /* @__PURE__ */ new Map();
2400
+ for (const [rowKey, bucket] of sameRow) {
2401
+ if (bucket.length > 1) multiMaintainerTargets.set(rowKey, bucket);
2402
+ }
2403
+ return {
2404
+ items,
2405
+ byTrigger,
2406
+ effectsFor(trigger) {
2407
+ return byTrigger.get(trigger) ?? [];
2408
+ },
2409
+ multiMaintainerTargets
2410
+ };
2411
+ }
2412
+
1820
2413
  // src/runtime/command-runtime.ts
1821
2414
  function resolveCommandMethod(contract, methodName) {
1822
2415
  const method = contract.methods[methodName];
@@ -2076,7 +2669,121 @@ function renderIdempotencyGuardItems(guard, key, params, contextLabel) {
2076
2669
  return { Put: put };
2077
2670
  });
2078
2671
  }
2079
- async function executeReferentialTransaction(writes, edgeItems, updateItems, guardItemsRaw, outboxItems, idempotencyItems, checks, methodName, modelBySignature = /* @__PURE__ */ new Map(), retry) {
2672
+ function applyProjectionTransform(op, args, value, contextLabel) {
2673
+ if (op === "identity") return value;
2674
+ if (op === "preview") {
2675
+ const n = args[0];
2676
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
2677
+ throw new Error(
2678
+ `Contract runtime: ${contextLabel} declares a \`preview\` projection with a non-positive-integer length bound (${JSON.stringify(n)}). A preview length must be a positive integer.`
2679
+ );
2680
+ }
2681
+ if (value === null || value === void 0) return value;
2682
+ return String(value).slice(0, n);
2683
+ }
2684
+ throw new Error(
2685
+ `Contract runtime: ${contextLabel} declares an unknown projection transform op '${String(op)}' (expected 'identity' or 'preview').`
2686
+ );
2687
+ }
2688
+ function buildMaintainProjection(maintain, key, params, contextLabel) {
2689
+ const out = {};
2690
+ for (const [attr, transform] of Object.entries(maintain.projection)) {
2691
+ const value = renderTemplate(
2692
+ `{${transform.inputField}}`,
2693
+ key,
2694
+ params,
2695
+ `${contextLabel} projection '${attr}'`
2696
+ );
2697
+ out[attr] = applyProjectionTransform(transform.op, transform.args, value, `${contextLabel} '${attr}'`);
2698
+ }
2699
+ return out;
2700
+ }
2701
+ function renderMaintainWriteItem(maintain, key, params, contextLabel, modelBySignature) {
2702
+ const ownerClass = maintain.entity.modelClass;
2703
+ const metadata = MetadataRegistry.get(ownerClass);
2704
+ if (!metadata.primaryKey) {
2705
+ throw new Error(
2706
+ `Contract runtime: ${contextLabel} maintains a snapshot / collection on '${maintain.entity.name}', which has no primary key.`
2707
+ );
2708
+ }
2709
+ const keyInput = {};
2710
+ for (const [ownerField, inputField] of Object.entries(maintain.keyBinding)) {
2711
+ keyInput[ownerField] = renderTemplate(
2712
+ `{${inputField}}`,
2713
+ key,
2714
+ params,
2715
+ `${contextLabel} maintain key`
2716
+ );
2717
+ }
2718
+ const { TableName, Key } = buildDeleteInput(
2719
+ ownerClass,
2720
+ keyInput
2721
+ );
2722
+ let updateInput;
2723
+ if (maintain.kind === "counter") {
2724
+ const counter = maintain.counter;
2725
+ if (counter === void 0) {
2726
+ throw new Error(
2727
+ `Contract runtime: ${contextLabel} is a 'counter' maintenance write with no \`counter\` payload \u2014 the compiler (#141) should have set it.`
2728
+ );
2729
+ }
2730
+ updateInput = {
2731
+ TableName,
2732
+ Key,
2733
+ UpdateExpression: "ADD #a0 :a0",
2734
+ ExpressionAttributeNames: { "#a0": counter.attribute },
2735
+ ExpressionAttributeValues: { ":a0": counter.delta }
2736
+ };
2737
+ const out2 = { Update: updateInput };
2738
+ modelBySignature?.set(execItemKeySignature(out2), ownerClass.name);
2739
+ return out2;
2740
+ }
2741
+ const projection = buildMaintainProjection(maintain, key, params, contextLabel);
2742
+ if (maintain.kind === "collection") {
2743
+ const field = maintain.collection?.field;
2744
+ if (field === void 0) {
2745
+ throw new Error(
2746
+ `Contract runtime: ${contextLabel} is a 'collection' maintenance write with no \`collection.field\` \u2014 the compiler (#125) should have set it.`
2747
+ );
2748
+ }
2749
+ updateInput = {
2750
+ TableName,
2751
+ Key,
2752
+ UpdateExpression: "SET #c = list_append(if_not_exists(#c, :empty), :item)",
2753
+ ExpressionAttributeNames: { "#c": field },
2754
+ ExpressionAttributeValues: { ":empty": [], ":item": [projection] }
2755
+ };
2756
+ } else {
2757
+ const setClauses = [];
2758
+ const names = {};
2759
+ const values = {};
2760
+ let i = 0;
2761
+ for (const [attr, value] of Object.entries(projection)) {
2762
+ const nameTok = `#m${i}`;
2763
+ const valTok = `:m${i}`;
2764
+ setClauses.push(`${nameTok} = ${valTok}`);
2765
+ names[nameTok] = attr;
2766
+ values[valTok] = value;
2767
+ i += 1;
2768
+ }
2769
+ if (setClauses.length === 0) {
2770
+ throw new Error(
2771
+ `Contract runtime: ${contextLabel} is a 'snapshot' maintenance write with an empty projection \u2014 nothing to mirror onto '${maintain.entity.name}'.`
2772
+ );
2773
+ }
2774
+ updateInput = {
2775
+ TableName,
2776
+ Key,
2777
+ UpdateExpression: `SET ${setClauses.join(", ")}`,
2778
+ ExpressionAttributeNames: names,
2779
+ ExpressionAttributeValues: values
2780
+ };
2781
+ }
2782
+ const out = { Update: updateInput };
2783
+ modelBySignature?.set(execItemKeySignature(out), ownerClass.name);
2784
+ return out;
2785
+ }
2786
+ async function executeReferentialTransaction(writes, edgeItems, updateItems, guardItemsRaw, outboxItems, idempotencyItems, maintainItems, checks, methodName, modelBySignature = /* @__PURE__ */ new Map(), retry, middleware) {
2080
2787
  const writeItems = writes.map((w) => {
2081
2788
  const options = w.condition !== void 0 ? { condition: w.condition } : void 0;
2082
2789
  let item;
@@ -2099,13 +2806,15 @@ async function executeReferentialTransaction(writes, edgeItems, updateItems, gua
2099
2806
  ...guardItemsRaw,
2100
2807
  ...outboxItems,
2101
2808
  ...idempotencyItems,
2809
+ ...maintainItems,
2102
2810
  ...checkItems
2103
2811
  ],
2104
2812
  {
2105
2813
  modelBySignature,
2106
2814
  ...retry !== void 0 ? { retry } : {},
2815
+ ...middleware !== void 0 ? { middleware } : {},
2107
2816
  limitError: (count) => new Error(
2108
- `Contract runtime: command method '${methodName}' composes ${count} items (writes + edges + derived updates + uniqueness guards + outbox events + idempotency guard + ConditionChecks) into one TransactWriteItems, but DynamoDB caps a transaction at ${MAX_TRANSACT_ITEMS} items and a transaction is atomic \u2014 it cannot be split. Reduce the fragment / effect count.`
2817
+ `Contract runtime: command method '${methodName}' composes ${count} items (writes + edges + derived updates + uniqueness guards + outbox events + idempotency guard + maintenance writes + ConditionChecks) into one TransactWriteItems, but DynamoDB caps a transaction at ${MAX_TRANSACT_ITEMS} items and a transaction is atomic \u2014 it cannot be split. Reduce the fragment / effect count.`
2109
2818
  )
2110
2819
  }
2111
2820
  );
@@ -2165,10 +2874,10 @@ async function executeCommandMethod(contract, methodName, keyOrKeys, params = {}
2165
2874
  `Contract runtime: command method '${methodName}' is a multi-fragment mutation (it composes ${method.ops.length} fragments into one atomic TransactWriteItems) and accepts a single key only. Call it once per target, not with a key array.`
2166
2875
  );
2167
2876
  }
2168
- const hasDerivedEffects2 = method.conditionChecks !== void 0 && method.conditionChecks.length > 0 || method.edgeWrites !== void 0 && method.edgeWrites.length > 0 || method.derivedUpdates !== void 0 && method.derivedUpdates.length > 0 || method.uniqueGuards !== void 0 && method.uniqueGuards.length > 0 || method.outboxEvents !== void 0 && method.outboxEvents.length > 0 || method.idempotencyGuard !== void 0;
2877
+ const hasDerivedEffects2 = method.conditionChecks !== void 0 && method.conditionChecks.length > 0 || method.edgeWrites !== void 0 && method.edgeWrites.length > 0 || method.derivedUpdates !== void 0 && method.derivedUpdates.length > 0 || method.uniqueGuards !== void 0 && method.uniqueGuards.length > 0 || method.outboxEvents !== void 0 && method.outboxEvents.length > 0 || method.maintainWrites !== void 0 && method.maintainWrites.length > 0 || method.idempotencyGuard !== void 0;
2169
2878
  if (hasDerivedEffects2) {
2170
2879
  throw new Error(
2171
- `Contract runtime: command method '${methodName}' carries derived effects (referential integrity / edge writes / derived counters / uniqueness guards / outbox events / idempotency guard \u2014 it composes its write + those items into one atomic TransactWriteItems) and accepts a single key only. Call it once per target, not with a key array.`
2880
+ `Contract runtime: command method '${methodName}' carries derived effects (referential integrity / edge writes / derived counters / uniqueness guards / outbox events / idempotency guard / maintenance writes \u2014 it composes its write + those items into one atomic TransactWriteItems) and accepts a single key only. Call it once per target, not with a key array.`
2172
2881
  );
2173
2882
  }
2174
2883
  const writes = keys.map(
@@ -2187,7 +2896,8 @@ async function executeCommandMethod(contract, methodName, keyOrKeys, params = {}
2187
2896
  const uniqueGuards = method.uniqueGuards ?? [];
2188
2897
  const outboxEvents = method.outboxEvents ?? [];
2189
2898
  const idempotencyGuard = method.idempotencyGuard;
2190
- const hasDerivedEffects = conditionChecks.length > 0 || edgeWrites2.length > 0 || derivedUpdates.length > 0 || uniqueGuards.length > 0 || outboxEvents.length > 0 || idempotencyGuard !== void 0;
2899
+ const maintainWrites = method.maintainWrites ?? [];
2900
+ const hasDerivedEffects = conditionChecks.length > 0 || edgeWrites2.length > 0 || derivedUpdates.length > 0 || uniqueGuards.length > 0 || outboxEvents.length > 0 || maintainWrites.length > 0 || idempotencyGuard !== void 0;
2191
2901
  const isMultiFragment = method.ops !== void 0 && method.ops.length > 1;
2192
2902
  if (isMultiFragment || hasDerivedEffects) {
2193
2903
  const ops = method.ops ?? [method.op];
@@ -2226,6 +2936,15 @@ async function executeCommandMethod(contract, methodName, keyOrKeys, params = {}
2226
2936
  params,
2227
2937
  `command method '${methodName}' (idempotency)`
2228
2938
  ) : [];
2939
+ const maintainItems = maintainWrites.map(
2940
+ (m, i) => renderMaintainWriteItem(
2941
+ m,
2942
+ key,
2943
+ params,
2944
+ `command method '${methodName}' (maintain #${i})`,
2945
+ modelBySignature
2946
+ )
2947
+ );
2229
2948
  const checks = conditionChecks.map((check, i) => ({
2230
2949
  check: renderConditionCheck(
2231
2950
  check,
@@ -2241,6 +2960,7 @@ async function executeCommandMethod(contract, methodName, keyOrKeys, params = {}
2241
2960
  guardItems,
2242
2961
  outboxItems,
2243
2962
  idempotencyItems,
2963
+ maintainItems,
2244
2964
  checks,
2245
2965
  methodName,
2246
2966
  modelBySignature
@@ -2269,6 +2989,7 @@ function renderCompiledOp(op, ctx) {
2269
2989
  const guardItems = [];
2270
2990
  const outboxItems = [];
2271
2991
  const idempotencyItems = [];
2992
+ const maintainItems = [];
2272
2993
  const checkItems = [];
2273
2994
  for (const edge of fragment.edgeWrites ?? []) {
2274
2995
  edgeItems.push(...renderEdgeWriteItems(edge, op.params, op.params, `${ctx} edge`, modelBySignature));
@@ -2287,10 +3008,15 @@ function renderCompiledOp(op, ctx) {
2287
3008
  ...renderIdempotencyGuardItems(fragment.idempotencyGuard, op.params, op.params, `${ctx} idempotency`)
2288
3009
  );
2289
3010
  }
3011
+ for (const m of fragment.maintainWrites ?? []) {
3012
+ maintainItems.push(
3013
+ renderMaintainWriteItem(m, op.params, op.params, `${ctx} maintain`, modelBySignature)
3014
+ );
3015
+ }
2290
3016
  for (const c of fragment.conditionChecks ?? []) {
2291
3017
  checkItems.push({ check: renderConditionCheck(c, op.params, op.params, `${ctx} requires`) });
2292
3018
  }
2293
- const hasDerivedEffects = edgeItems.length > 0 || updateItems.length > 0 || guardItems.length > 0 || outboxItems.length > 0 || idempotencyItems.length > 0 || checkItems.length > 0;
3019
+ const hasDerivedEffects = edgeItems.length > 0 || updateItems.length > 0 || guardItems.length > 0 || outboxItems.length > 0 || idempotencyItems.length > 0 || maintainItems.length > 0 || checkItems.length > 0;
2294
3020
  return {
2295
3021
  write,
2296
3022
  edgeItems,
@@ -2298,6 +3024,7 @@ function renderCompiledOp(op, ctx) {
2298
3024
  guardItems,
2299
3025
  outboxItems,
2300
3026
  idempotencyItems,
3027
+ maintainItems,
2301
3028
  checkItems,
2302
3029
  modelBySignature,
2303
3030
  hasDerivedEffects
@@ -2317,7 +3044,24 @@ function readBackForCompiledOp(op, write) {
2317
3044
  }
2318
3045
  async function executeCompiledWriteOp(ops, options) {
2319
3046
  const label = options.label;
2320
- const renderedOps = ops.map((op, i) => renderCompiledOp(op, `${label} (op #${i})`));
3047
+ const writeMw = buildWriteRuntime(options.context);
3048
+ const writeCtxs = [];
3049
+ const txId = { id: /* @__PURE__ */ Symbol("graphddb.mutate") };
3050
+ let rewrites = [];
3051
+ if (writeMw.active) {
3052
+ const w1ed = [];
3053
+ for (const op of ops) {
3054
+ const r = await runEnvelopeW1(op, writeMw, txId, writeCtxs);
3055
+ w1ed.push(r.op);
3056
+ rewrites.push(r.rewritten ?? null);
3057
+ }
3058
+ ops = w1ed;
3059
+ } else {
3060
+ rewrites = ops.map(() => null);
3061
+ }
3062
+ const renderedOps = ops.map(
3063
+ (op, i) => rewrites[i] !== null ? rewrittenCompiledOp(rewrites[i]) : renderCompiledOp(op, `${label} (op #${i})`)
3064
+ );
2321
3065
  const rendered = renderedOps.map((r) => r.write);
2322
3066
  const modelBySignature = /* @__PURE__ */ new Map();
2323
3067
  const edgeItems = [];
@@ -2325,6 +3069,7 @@ async function executeCompiledWriteOp(ops, options) {
2325
3069
  const guardItems = [];
2326
3070
  const outboxItems = [];
2327
3071
  const idempotencyItems = [];
3072
+ const maintainItems = [];
2328
3073
  const checkItems = [];
2329
3074
  for (const r of renderedOps) {
2330
3075
  for (const [sig, name] of r.modelBySignature) modelBySignature.set(sig, name);
@@ -2333,11 +3078,17 @@ async function executeCompiledWriteOp(ops, options) {
2333
3078
  guardItems.push(...r.guardItems);
2334
3079
  outboxItems.push(...r.outboxItems);
2335
3080
  idempotencyItems.push(...r.idempotencyItems);
3081
+ maintainItems.push(...r.maintainItems);
2336
3082
  checkItems.push(...r.checkItems);
2337
3083
  }
2338
3084
  const hasDerivedEffects = renderedOps.some((r) => r.hasDerivedEffects);
3085
+ const origins = writeMw.active ? rendered.map((w) => ({ model: ctxModelFor(w.modelClass), kind: w.operation })) : [];
2339
3086
  if (rendered.length === 1 && !hasDerivedEffects) {
2340
- await executeSingleWrite(rendered[0], options.retry);
3087
+ if (writeMw.active) {
3088
+ await persistSingleWriteWithHooks(rendered[0], writeMw, origins, txId, options.retry);
3089
+ } else {
3090
+ await executeSingleWrite(rendered[0], options.retry);
3091
+ }
2341
3092
  } else {
2342
3093
  await executeReferentialTransaction(
2343
3094
  rendered,
@@ -2346,26 +3097,120 @@ async function executeCompiledWriteOp(ops, options) {
2346
3097
  guardItems,
2347
3098
  outboxItems,
2348
3099
  idempotencyItems,
3100
+ maintainItems,
2349
3101
  checkItems,
2350
3102
  label,
2351
3103
  modelBySignature,
2352
- options.retry
3104
+ options.retry,
3105
+ writeMw.active ? { runtime: writeMw, origins, transaction: txId } : void 0
2353
3106
  );
2354
3107
  }
3108
+ if (writeMw.active) {
3109
+ for (let i = writeCtxs.length - 1; i >= 0; i--) {
3110
+ await writeMw.runWriteAfter(writeCtxs[i], {});
3111
+ }
3112
+ }
2355
3113
  const readBacks = await Promise.all(
2356
3114
  ops.map((op, i) => readBackForCompiledOp(op, rendered[i]))
2357
3115
  );
2358
3116
  return { readBacks };
2359
3117
  }
3118
+ async function persistSingleWriteWithHooks(w, runtime, origins, transaction, retry) {
3119
+ const options = w.condition !== void 0 || retry !== void 0 ? {
3120
+ ...w.condition !== void 0 ? { condition: w.condition } : {},
3121
+ ...retry !== void 0 ? { retry } : {}
3122
+ } : void 0;
3123
+ let item;
3124
+ if (w.operation === "put") {
3125
+ item = { Put: buildPutInput(w.modelClass, w.item ?? {}, options) };
3126
+ } else if (w.operation === "update") {
3127
+ item = { Update: buildUpdateInput(w.modelClass, w.key, w.changes ?? {}, options) };
3128
+ } else {
3129
+ item = { Delete: buildDeleteInput(w.modelClass, w.key, options) };
3130
+ }
3131
+ const persistCtx = runtime.persistCtx([item], origins, transaction);
3132
+ await runtime.runPersist(persistCtx, async (items) => {
3133
+ const sent = items[0];
3134
+ const sendOpts = retry !== void 0 ? { retry } : void 0;
3135
+ const exec = ClientManager.getExecutor();
3136
+ if ("Put" in sent) return exec.put(sent.Put, sendOpts);
3137
+ if ("Update" in sent) return exec.update(sent.Update, sendOpts);
3138
+ if ("Delete" in sent) return exec.delete(sent.Delete, sendOpts);
3139
+ throw new Error("graphddb middleware: a single-op persist received a ConditionCheck.");
3140
+ });
3141
+ }
3142
+ async function runEnvelopeW1(op, runtime, transaction, writeCtxs) {
3143
+ const kind = op.fragment.op.operation;
3144
+ const keyFields = new Set(op.fragment.keyFields);
3145
+ const input = {};
3146
+ if (kind === "put") {
3147
+ input.item = { ...op.params };
3148
+ } else {
3149
+ const key = {};
3150
+ const changes = {};
3151
+ for (const [k, v] of Object.entries(op.params)) {
3152
+ if (keyFields.has(k)) key[k] = v;
3153
+ else changes[k] = v;
3154
+ }
3155
+ input.key = key;
3156
+ input.changes = changes;
3157
+ }
3158
+ const modelClass = op.fragment.op.entity.modelClass;
3159
+ const ctx = runtime.writeCtx(kind, ctxModelFor(modelClass), input, transaction);
3160
+ await runtime.runWriteBefore(ctx);
3161
+ writeCtxs.push(ctx);
3162
+ if (ctx.kind !== kind) {
3163
+ return { op, rewritten: renderRewrittenWrite(modelClass, ctx) };
3164
+ }
3165
+ const merged = ctx.kind === "put" ? { ...ctx.input.item ?? {} } : { ...ctx.input.key ?? {}, ...ctx.input.changes ?? {} };
3166
+ return { op: { ...op, params: merged } };
3167
+ }
3168
+ function renderRewrittenWrite(modelClass, ctx) {
3169
+ const modelStatic = modelClass.asModel();
3170
+ const condition = ctx.input.options?.condition;
3171
+ const base = {
3172
+ modelClass,
3173
+ modelStatic,
3174
+ ...condition !== void 0 ? { condition } : {}
3175
+ };
3176
+ if (ctx.kind === "put") {
3177
+ const item = ctx.input.item ?? {};
3178
+ return { ...base, operation: "put", key: item, item };
3179
+ }
3180
+ if (ctx.kind === "update") {
3181
+ return {
3182
+ ...base,
3183
+ operation: "update",
3184
+ key: ctx.input.key ?? {},
3185
+ changes: ctx.input.changes ?? {}
3186
+ };
3187
+ }
3188
+ return { ...base, operation: "delete", key: ctx.input.key ?? {} };
3189
+ }
3190
+ function rewrittenCompiledOp(write) {
3191
+ return {
3192
+ write,
3193
+ edgeItems: [],
3194
+ updateItems: [],
3195
+ guardItems: [],
3196
+ outboxItems: [],
3197
+ idempotencyItems: [],
3198
+ maintainItems: [],
3199
+ checkItems: [],
3200
+ modelBySignature: /* @__PURE__ */ new Map(),
3201
+ hasDerivedEffects: false
3202
+ };
3203
+ }
2360
3204
  async function executeCompiledParallelWrites(ops, options) {
2361
3205
  const label = options.label;
2362
3206
  const results = new Array(ops.length);
2363
3207
  const renderedOps = ops.map((op, i) => renderCompiledOp(op, `${label} (op #${i})`));
2364
3208
  const coalescibleIdx = [];
2365
3209
  const individualIdx = [];
3210
+ const hooksActive = buildWriteRuntime(options.context).active;
2366
3211
  renderedOps.forEach((r, i) => {
2367
3212
  const w = r.write;
2368
- if (!r.hasDerivedEffects && w.condition === void 0 && (w.operation === "put" || w.operation === "delete")) {
3213
+ if (!hooksActive && !r.hasDerivedEffects && w.condition === void 0 && (w.operation === "put" || w.operation === "delete")) {
2369
3214
  coalescibleIdx.push(i);
2370
3215
  } else {
2371
3216
  individualIdx.push(i);
@@ -2397,7 +3242,8 @@ async function executeCompiledParallelWrites(ops, options) {
2397
3242
  try {
2398
3243
  const { readBacks } = await executeCompiledWriteOp([ops[i]], {
2399
3244
  label: `${label} (op #${i})`,
2400
- ...options.retry !== void 0 ? { retry: options.retry } : {}
3245
+ ...options.retry !== void 0 ? { retry: options.retry } : {},
3246
+ ...options.context !== void 0 ? { context: options.context } : {}
2401
3247
  });
2402
3248
  results[i] = { ok: true, value: readBacks[0] };
2403
3249
  } catch (e) {
@@ -2455,14 +3301,16 @@ async function executeReadRoute(alias, route) {
2455
3301
  if (isQuery) {
2456
3302
  return executeQuery(modelClass, route.key, route.select, {
2457
3303
  consistentRead: opt.consistentRead,
2458
- maxDepth: opt.maxDepth
3304
+ maxDepth: opt.maxDepth,
3305
+ context: opt.context
2459
3306
  });
2460
3307
  }
2461
3308
  return executeList(modelClass, route.key, route.select, {
2462
3309
  limit: opt.limit,
2463
3310
  after: opt.after,
2464
3311
  order: opt.order,
2465
- filter: opt.filter
3312
+ filter: opt.filter,
3313
+ context: opt.context
2466
3314
  });
2467
3315
  }
2468
3316
  var INTENT_KEYS = ["create", "update", "remove"];
@@ -2476,9 +3324,9 @@ async function executeWriteEnvelope(envelope, options = {}) {
2476
3324
  }
2477
3325
  const ops = aliases.flatMap((alias) => normalizeWriteOp(alias, envelope[alias]));
2478
3326
  if (mode === "transaction") {
2479
- return executeTransactionMode(ops, options.retry);
3327
+ return executeTransactionMode(ops, options.retry, options.context);
2480
3328
  }
2481
- return executeParallelMode(ops, options.retry);
3329
+ return executeParallelMode(ops, options.retry, options.context);
2482
3330
  }
2483
3331
  function normalizeWriteOp(alias, d) {
2484
3332
  if (d === null || typeof d !== "object") {
@@ -2551,11 +3399,12 @@ function mapToRefs($, fields) {
2551
3399
  for (const f of fields) out[f] = $[f];
2552
3400
  return out;
2553
3401
  }
2554
- async function executeTransactionMode(ops, retry) {
3402
+ async function executeTransactionMode(ops, retry, context) {
2555
3403
  const compiled = ops.map(compileWriteOp);
2556
3404
  const { readBacks } = await executeCompiledWriteOp(compiled, {
2557
3405
  label: "DDBModel.mutate",
2558
- ...retry !== void 0 ? { retry } : {}
3406
+ ...retry !== void 0 ? { retry } : {},
3407
+ ...context !== void 0 ? { context } : {}
2559
3408
  });
2560
3409
  const out = {};
2561
3410
  ops.forEach((op, i) => {
@@ -2570,11 +3419,12 @@ async function executeTransactionMode(ops, retry) {
2570
3419
  });
2571
3420
  return out;
2572
3421
  }
2573
- async function executeParallelMode(ops, retry) {
3422
+ async function executeParallelMode(ops, retry, context) {
2574
3423
  const compiled = ops.map(compileWriteOp);
2575
3424
  const settled = await executeCompiledParallelWrites(compiled, {
2576
3425
  label: "DDBModel.mutate",
2577
- ...retry !== void 0 ? { retry } : {}
3426
+ ...retry !== void 0 ? { retry } : {},
3427
+ ...context !== void 0 ? { context } : {}
2578
3428
  });
2579
3429
  const out = {};
2580
3430
  ops.forEach((op, i) => {
@@ -2634,8 +3484,36 @@ var DDBModel = class {
2634
3484
  static setRetryPolicy(policy) {
2635
3485
  ClientManager.setRetryPolicy(policy);
2636
3486
  }
2637
- static async transaction(fn) {
2638
- await executeTransaction(fn);
3487
+ /**
3488
+ * Register a {@link Middleware} (issue #50; read path #138, write path #139).
3489
+ * One registered object may carry read and/or write hooks.
3490
+ *
3491
+ * - **Read** R1–R5 — request entry (`read.before`), each physical op before send
3492
+ * / after raw fetch (`read.op.before` / `read.op.afterFetch`, INCLUDING every
3493
+ * relation fan-out fetch), the final hydrated result (`read.afterFetch`), and on
3494
+ * error (`read.op.onError` / `read.onError`).
3495
+ * - **Write** W1–W5 — the logical write before effect derivation (`write.before`,
3496
+ * may mutate `ctx.input` / `ctx.kind` incl. a delete→update rewrite), after commit
3497
+ * (`write.after`), the physical persist on the fully composed batch
3498
+ * (`write.persist.before` / `write.persist.after`, fires ONCE per atomic
3499
+ * transaction), and on error (`write.persist.onError` / `write.onError`).
3500
+ *
3501
+ * Hooks may observe, mutate `ctx`, `throw` to cancel, or (on `onError`) recover by
3502
+ * returning a value; the library imposes no semantics. Ordering: `before*` runs
3503
+ * first-registered-first (FIFO); `after*` / `onError` run last-registered-first
3504
+ * (LIFO). Mirrors {@link setRetryPolicy} — a host-runtime global stored on
3505
+ * {@link ClientManager}; never serialized (the bridge #48 is unaffected). Per-call
3506
+ * host context flows in via the read's / write's `{ context }` option.
3507
+ */
3508
+ static use(mw) {
3509
+ ClientManager.use(mw);
3510
+ }
3511
+ /** Remove every registered middleware (read AND write; teardown / tests). See {@link use}. */
3512
+ static clearMiddleware() {
3513
+ ClientManager.clearMiddleware();
3514
+ }
3515
+ static async transaction(fn, options) {
3516
+ await executeTransaction(fn, options);
2639
3517
  }
2640
3518
  /**
2641
3519
  * In-process multi-route READ (issue #101 — the unified envelope). Each alias
@@ -2650,8 +3528,20 @@ var DDBModel = class {
2650
3528
  static mutate(envelope, options) {
2651
3529
  return executeWriteEnvelope(envelope, options);
2652
3530
  }
2653
- static async batchGet(requests) {
2654
- return executeBatchGet(requests);
3531
+ /**
3532
+ * Multi-key, multi-model batch read. Each {@link BatchGetRequest} contributes its
3533
+ * keys to a chunked `BatchGetItem` per physical table; results are re-grouped by
3534
+ * model on the returned {@link BatchGetResult}.
3535
+ *
3536
+ * Fires the read hooks (issue #142): request-level R1/R4/R5 with kind
3537
+ * `'batchGet'`, and op-level R2/R3/R5 for each underlying physical `BatchGetItem`
3538
+ * op. The optional `{ context }` is exposed to every hook as `ctx.context` and
3539
+ * threaded UNCHANGED to every op (absent ⇒ `{}`), so a global hook (e.g. a
3540
+ * tenant-scope R2 FilterExpression) applies across the whole batch. With no
3541
+ * middleware registered the read path is unchanged (zero-overhead fast path).
3542
+ */
3543
+ static async batchGet(requests, options) {
3544
+ return executeBatchGet(requests, options);
2655
3545
  }
2656
3546
  static async batchWrite(requests) {
2657
3547
  return executeBatchWrite(requests);
@@ -3188,6 +4078,8 @@ function buildPlannedCommandMethod(name, planned) {
3188
4078
  );
3189
4079
  }
3190
4080
  const idempotencyGuard = idempotencyGuards[0];
4081
+ const maintainWrites = plan2.fragments.flatMap((f) => f.maintainWrites ?? []);
4082
+ assertNoCrossFragmentMaintainCollision(name, maintainWrites);
3191
4083
  return {
3192
4084
  __methodKind: "command",
3193
4085
  op,
@@ -3198,6 +4090,7 @@ function buildPlannedCommandMethod(name, planned) {
3198
4090
  ...uniqueGuards.length > 0 ? { uniqueGuards } : {},
3199
4091
  ...outboxEvents.length > 0 ? { outboxEvents } : {},
3200
4092
  ...idempotencyGuard !== void 0 ? { idempotencyGuard } : {},
4093
+ ...maintainWrites.length > 0 ? { maintainWrites } : {},
3201
4094
  inputArity: "either",
3202
4095
  // A return projection means the method returns the read-back entity; otherwise
3203
4096
  // it is a fire-and-forget write (`void`).
@@ -3485,6 +4378,171 @@ function deriveOneUpdate(fragment, effect) {
3485
4378
  amount: effect.amount
3486
4379
  };
3487
4380
  }
4381
+ var INTENT_MAINTAIN_EVENT = {
4382
+ create: "created",
4383
+ update: "updated",
4384
+ remove: "removed"
4385
+ };
4386
+ var MAINTAIN_ENTITY_PATH_RE = /^\$\.entity\.([A-Za-z_$][\w$]*)$/;
4387
+ var cachedMaintenanceGraph;
4388
+ function globalMaintenanceGraph() {
4389
+ const generation = MetadataRegistry.generation;
4390
+ if (cachedMaintenanceGraph?.generation !== generation) {
4391
+ cachedMaintenanceGraph = { graph: buildMaintenanceGraph(), generation };
4392
+ }
4393
+ return cachedMaintenanceGraph.graph;
4394
+ }
4395
+ function logicalEntityName(fragment) {
4396
+ const meta = MetadataRegistry.get(
4397
+ fragment.entity.modelClass
4398
+ );
4399
+ return meta.prefix.endsWith("#") ? meta.prefix.slice(0, -1) : meta.prefix;
4400
+ }
4401
+ function resolveMaintainers(fragment, graph) {
4402
+ const g = graph ?? globalMaintenanceGraph();
4403
+ const event = INTENT_MAINTAIN_EVENT[fragment.intent];
4404
+ const trigger = `${logicalEntityName(fragment)}.${event}`;
4405
+ return g.effectsFor(trigger);
4406
+ }
4407
+ function maintainPathToInputField(fragment, item, role, path) {
4408
+ const entityMatch = MAINTAIN_ENTITY_PATH_RE.exec(path);
4409
+ if (entityMatch !== null) return entityMatch[1];
4410
+ const inputMatch = INPUT_PATH_RE.exec(path);
4411
+ if (inputMatch !== null) return inputMatch[1];
4412
+ throw new Error(
4413
+ `mutation: the '${fragment.intent}' fragment on '${fragment.entity.name}' fires a maintenance effect (relation '${item.relationProperty}' on '${item.ownerEntity}') whose ${role} binds to ${JSON.stringify(path)}, which is not a payload-rooted \`$.entity.<field>\` / \`$.input.<field>\` source. A maintenance write projects only from the written source row (payload \u540C\u68B1) \u2014 fix the relation's projection / key binding.`
4414
+ );
4415
+ }
4416
+ function deriveMaintainItems(fragment, items) {
4417
+ const byRow = /* @__PURE__ */ new Map();
4418
+ for (const item of items) {
4419
+ const prior = byRow.get(item.destinationRowKey);
4420
+ if (prior !== void 0) {
4421
+ const bothCounter = item.effect.kind === "counter" && prior.effect.kind === "counter";
4422
+ if (!bothCounter) {
4423
+ throw new Error(
4424
+ `mutation: the '${fragment.intent}' fragment on '${fragment.entity.name}' fires TWO maintenance effects that target the SAME row (relation '${prior.relationProperty}' and '${item.relationProperty}' on '${item.ownerEntity}', destination '${item.destinationRowKey}'). Phase 1 constrains a mutation to ONE non-counter maintenance effect per target row (\u8AD6\u70B92 = b) \u2014 a \`TransactWriteItems\` may not touch one key twice with a snapshot/collection write, and merging those is a separate future issue. (Counter ADDs are exempt \u2014 they merge. Consistent with the #96 same-row reject.)`
4425
+ );
4426
+ }
4427
+ } else {
4428
+ byRow.set(item.destinationRowKey, item);
4429
+ }
4430
+ }
4431
+ return items.map((item) => deriveOneMaintainWrite(fragment, item));
4432
+ }
4433
+ function maintainWriteRowKey(write) {
4434
+ const parts = Object.entries(write.keyBinding).map(([field, inputField]) => `${field}=${inputField}`).sort();
4435
+ return `${write.entity.name}#${parts.join("&")}`;
4436
+ }
4437
+ function assertNoCrossFragmentMaintainCollision(methodName, writes) {
4438
+ const byRow = /* @__PURE__ */ new Map();
4439
+ for (const write of writes) {
4440
+ const rowKey = maintainWriteRowKey(write);
4441
+ const prior = byRow.get(rowKey);
4442
+ if (prior !== void 0) {
4443
+ const bothCounter = write.kind === "counter" && prior.kind === "counter";
4444
+ if (!bothCounter) {
4445
+ throw new Error(
4446
+ `publicCommandModel: planned method '${methodName}' aggregates TWO maintenance effects that resolve to the SAME owner row (relation '${prior.relationProperty}' and '${write.relationProperty}' on '${write.entity.name}', destination '${rowKey}'), across different mutation fragments. Phase 1 constrains a mutation to ONE non-counter maintenance effect per target row (\u8AD6\u70B92 = b) \u2014 this holds for the WHOLE mutation, not just within a single fragment. Multiple snapshot/collection maintain effects resolving to one owner row is the true cause here (NOT a derived-counter merge failure); a \`TransactWriteItems\` may not touch one key twice with a snapshot/collection write, and merging those is a separate future issue. (Counter ADDs are exempt \u2014 they merge. Consistent with the intra-fragment reject and the #96 same-row reject.)`
4447
+ );
4448
+ }
4449
+ } else {
4450
+ byRow.set(rowKey, write);
4451
+ }
4452
+ }
4453
+ }
4454
+ function deriveOneMaintainWrite(fragment, item) {
4455
+ const effect = item.effect;
4456
+ if (effect.updateMode === "stream") {
4457
+ throw new Error(
4458
+ `mutation: the '${fragment.intent}' fragment on '${fragment.entity.name}' fires a maintenance effect (relation '${item.relationProperty}' on '${item.ownerEntity}') declared \`updateMode: 'stream'\` (asynchronous). Phase 1 (#125) lowers only the SYNCHRONOUS \`mutation\` path (the same TransactWriteItems as the source write); the stream path (outbox \u2192 CDC drain) is #130 (Phase 2). Use \`updateMode: 'mutation'\` (the default) for a synchronous maintenance, or wait for #130.`
4459
+ );
4460
+ }
4461
+ const ownerClass = effect.targetFactory();
4462
+ const ownerMeta = MetadataRegistry.get(
4463
+ ownerClass
4464
+ );
4465
+ const ownerName = ownerClass.name;
4466
+ const keyBinding = {};
4467
+ for (const [ownerField, source] of Object.entries(effect.keys)) {
4468
+ keyBinding[ownerField] = maintainPathToInputField(
4469
+ fragment,
4470
+ item,
4471
+ `destination-key field '${ownerField}'`,
4472
+ source
4473
+ );
4474
+ }
4475
+ if (effect.kind === "counter") {
4476
+ return deriveOneCounterWrite(fragment, item, effect, ownerClass, ownerMeta, ownerName, keyBinding);
4477
+ }
4478
+ const projection = {};
4479
+ for (const [attr, transform] of Object.entries(effect.project)) {
4480
+ const inputField = maintainPathToInputField(
4481
+ fragment,
4482
+ item,
4483
+ `projection attribute '${attr}'`,
4484
+ transform.path
4485
+ );
4486
+ projection[attr] = { op: transform.op, args: transform.args, inputField };
4487
+ }
4488
+ if (!ownerMeta.primaryKey) {
4489
+ throw new Error(
4490
+ `mutation: the '${fragment.intent}' fragment on '${fragment.entity.name}' fires a maintenance effect on '${ownerName}', which has no primary key \u2014 a maintenance write keys a single target row, so the maintained model must declare one.`
4491
+ );
4492
+ }
4493
+ const base = {
4494
+ entity: { name: ownerName, modelClass: ownerClass },
4495
+ relationProperty: item.relationProperty,
4496
+ trigger: item.trigger,
4497
+ keyBinding,
4498
+ projection
4499
+ };
4500
+ if (effect.kind === "collection") {
4501
+ const orderBy = effect.collection.orderBy !== void 0 ? maintainPathToInputField(fragment, item, "collection `orderBy`", effect.collection.orderBy) : void 0;
4502
+ return {
4503
+ ...base,
4504
+ kind: "collection",
4505
+ collection: {
4506
+ field: effect.collection.field,
4507
+ ...effect.collection.maxItems !== void 0 ? { maxItems: effect.collection.maxItems } : {},
4508
+ ...orderBy !== void 0 ? { orderBy } : {}
4509
+ }
4510
+ };
4511
+ }
4512
+ return { ...base, kind: "snapshot" };
4513
+ }
4514
+ function deriveOneCounterWrite(fragment, item, effect, ownerClass, ownerMeta, ownerName, keyBinding) {
4515
+ if (effect.value.op === "max") {
4516
+ throw new Error(
4517
+ `mutation: the '${fragment.intent}' fragment on '${fragment.entity.name}' fires a counter maintenance effect (\`@aggregate\` field '${item.relationProperty}' on '${item.ownerEntity}') declared \`value: max('${effect.value.field}')\`. Phase 1 (#141) realizes only \`count()\` synchronously: a \`max\` needs a conditional \`SET\` whose failed guard would roll back the SOURCE write that legitimately happened, so a running max is the asynchronous stream path (#130, Phase 2). Use \`value: count()\` for a synchronous counter, or wait for #130.`
4518
+ );
4519
+ }
4520
+ if (effect.delta === void 0) {
4521
+ throw new Error(
4522
+ `mutation: the '${fragment.intent}' fragment on '${fragment.entity.name}' fires a counter maintenance effect (\`@aggregate\` field '${item.relationProperty}' on '${item.ownerEntity}') with no \`delta\` \u2014 the maintenance graph (#141) should set it from the trigger event.`
4523
+ );
4524
+ }
4525
+ if (!ownerMeta.primaryKey) {
4526
+ throw new Error(
4527
+ `mutation: the '${fragment.intent}' fragment on '${fragment.entity.name}' fires a counter maintenance effect on '${ownerName}', which has no primary key \u2014 a counter \`UpdateItem\` keys a single row, so the maintained model must declare one.`
4528
+ );
4529
+ }
4530
+ const isField = ownerMeta.fields.some((f) => f.propertyName === effect.attribute) || ownerMeta.aggregates.some((a) => a.propertyName === effect.attribute);
4531
+ if (!isField) {
4532
+ throw new Error(
4533
+ `mutation: the '${fragment.intent}' fragment on '${fragment.entity.name}' derives a counter on '${ownerName}.${effect.attribute}', which is not a field of '${ownerName}'.`
4534
+ );
4535
+ }
4536
+ return {
4537
+ entity: { name: ownerName, modelClass: ownerClass },
4538
+ relationProperty: item.relationProperty,
4539
+ trigger: item.trigger,
4540
+ kind: "counter",
4541
+ keyBinding,
4542
+ projection: {},
4543
+ counter: { attribute: effect.attribute, delta: effect.delta }
4544
+ };
4545
+ }
3488
4546
  var UNIQUE_GUARD_PK_PREFIX = "UNIQUE#";
3489
4547
  var UNIQUE_GUARD_SK_PREFIX = "VALUE#";
3490
4548
  function deriveUniqueGuards(fragment, unique) {
@@ -3703,7 +4761,7 @@ function renderInputLeaf(leaf, isKeyFieldInPut, consumerIndex, consumerField, re
3703
4761
  }
3704
4762
  return leaf;
3705
4763
  }
3706
- function compileFragment(fragment, index = 0, resolveEntityRef = null) {
4764
+ function compileFragment(fragment, index = 0, resolveEntityRef = null, maintenanceGraph) {
3707
4765
  const keyFields = primaryKeyFields(fragment);
3708
4766
  const keyFieldSet = new Set(keyFields);
3709
4767
  const operation = INTENT_OPERATION[fragment.intent];
@@ -3759,6 +4817,8 @@ function compileFragment(fragment, index = 0, resolveEntityRef = null) {
3759
4817
  const uniqueGuards = lifecycle?.effects.unique !== void 0 && lifecycle.effects.unique.length > 0 ? deriveUniqueGuards(fragment, lifecycle.effects.unique) : void 0;
3760
4818
  const outboxEvents = lifecycle?.effects.emits !== void 0 && lifecycle.effects.emits.length > 0 ? deriveOutboxEvents(fragment, lifecycle.effects.emits) : void 0;
3761
4819
  const idempotencyGuard = lifecycle?.effects.idempotency !== void 0 ? deriveIdempotencyGuard(fragment, lifecycle.effects.idempotency) : void 0;
4820
+ const maintainers = resolveMaintainers(fragment, maintenanceGraph);
4821
+ const maintainWrites = maintainers.length > 0 ? deriveMaintainItems(fragment, maintainers) : void 0;
3762
4822
  return {
3763
4823
  op,
3764
4824
  keyFields,
@@ -3768,7 +4828,8 @@ function compileFragment(fragment, index = 0, resolveEntityRef = null) {
3768
4828
  ...derivedUpdates !== void 0 ? { derivedUpdates } : {},
3769
4829
  ...uniqueGuards !== void 0 ? { uniqueGuards } : {},
3770
4830
  ...outboxEvents !== void 0 ? { outboxEvents } : {},
3771
- ...idempotencyGuard !== void 0 ? { idempotencyGuard } : {}
4831
+ ...idempotencyGuard !== void 0 ? { idempotencyGuard } : {},
4832
+ ...maintainWrites !== void 0 ? { maintainWrites } : {}
3772
4833
  };
3773
4834
  }
3774
4835
  function compileSingleFragmentPlan(plan2) {
@@ -5814,6 +6875,69 @@ function buildDerivedUpdateItems(derivedUpdates) {
5814
6875
  }
5815
6876
  return { items, params };
5816
6877
  }
6878
+ function buildMaintainWriteItems(maintainWrites) {
6879
+ const items = [];
6880
+ const params = {};
6881
+ for (const maintain of maintainWrites) {
6882
+ const metadata = MetadataRegistry.get(
6883
+ maintain.entity.modelClass
6884
+ );
6885
+ if (!metadata.primaryKey) {
6886
+ throw new Error(
6887
+ `maintenance write: the maintained entity '${maintain.entity.name}' has no primary key, so a maintenance \`UpdateItem\` cannot be keyed.`
6888
+ );
6889
+ }
6890
+ const tableName = TableMapping.resolve(metadata.tableName);
6891
+ const present = new Set(Object.keys(maintain.keyBinding));
6892
+ const { pk, sk } = evaluateKey(
6893
+ metadata.primaryKey.segmented,
6894
+ "param",
6895
+ present,
6896
+ (field) => maintain.keyBinding[field] ?? field
6897
+ );
6898
+ const keyCondition = { PK: pk };
6899
+ if (sk !== void 0) keyCondition.SK = sk;
6900
+ const projection = {};
6901
+ for (const [attr, transform] of Object.entries(maintain.projection)) {
6902
+ projection[attr] = {
6903
+ op: transform.op,
6904
+ args: [...transform.args],
6905
+ inputField: transform.inputField
6906
+ };
6907
+ }
6908
+ const maintainSpec = {
6909
+ kind: maintain.kind,
6910
+ relationProperty: maintain.relationProperty,
6911
+ trigger: maintain.trigger,
6912
+ projection,
6913
+ ...maintain.kind === "collection" && maintain.collection !== void 0 ? {
6914
+ collection: {
6915
+ field: maintain.collection.field,
6916
+ ...maintain.collection.maxItems !== void 0 ? { maxItems: maintain.collection.maxItems } : {},
6917
+ ...maintain.collection.orderBy !== void 0 ? { orderBy: maintain.collection.orderBy } : {}
6918
+ }
6919
+ } : {},
6920
+ // #141: a counter carries its scalar `ADD` (attribute + literal numeric delta);
6921
+ // the runtimes render `ADD #attr :delta` exactly as the self-lifecycle derived
6922
+ // counter (#85) does, so a same-row counter merge stays consistent across paths.
6923
+ ...maintain.kind === "counter" && maintain.counter !== void 0 ? {
6924
+ counter: {
6925
+ attribute: maintain.counter.attribute,
6926
+ delta: String(maintain.counter.delta)
6927
+ }
6928
+ } : {}
6929
+ };
6930
+ items.push({
6931
+ type: "Update",
6932
+ tableName,
6933
+ entity: maintain.entity.name,
6934
+ keyCondition,
6935
+ maintain: maintainSpec
6936
+ });
6937
+ collectTemplateParams(keyCondition, metadata, params);
6938
+ }
6939
+ return { items, params };
6940
+ }
5817
6941
  function buildUniqueGuardItems(uniqueGuards, entityMetadata) {
5818
6942
  const items = [];
5819
6943
  const params = {};
@@ -5846,7 +6970,7 @@ function buildIdempotencyGuardItems(guard, entityMetadata) {
5846
6970
  }
5847
6971
  return { items, params };
5848
6972
  }
5849
- function synthesizeMutationTransaction(contractName, methodName, ops, checks, edgeWrites2, derivedUpdates, uniqueGuards, outboxEvents, idempotencyGuard) {
6973
+ function synthesizeMutationTransaction(contractName, methodName, ops, checks, edgeWrites2, derivedUpdates, uniqueGuards, outboxEvents, idempotencyGuard, maintainWrites) {
5850
6974
  const label = opRefName(contractName, methodName);
5851
6975
  const base = composeFragmentTransaction(contractName, methodName, ops);
5852
6976
  const { items: edgeItemsRaw, params: edgeParams } = buildEdgeWriteItems(edgeWrites2);
@@ -5864,6 +6988,7 @@ function synthesizeMutationTransaction(contractName, methodName, ops, checks, ed
5864
6988
  primaryMetadata
5865
6989
  );
5866
6990
  const { items: idempotencyItems, params: idempotencyParams } = idempotencyGuard !== void 0 ? buildIdempotencyGuardItems(idempotencyGuard, primaryMetadata) : { items: [], params: {} };
6991
+ const { items: maintainItems, params: maintainParams } = buildMaintainWriteItems(maintainWrites);
5867
6992
  const baseKeys = new Set(base.items.map(itemKeySignature));
5868
6993
  for (const update of updateItems) {
5869
6994
  if (baseKeys.has(itemKeySignature(update))) {
@@ -5886,7 +7011,8 @@ function synthesizeMutationTransaction(contractName, methodName, ops, checks, ed
5886
7011
  checkParams,
5887
7012
  guardParams,
5888
7013
  outboxParams,
5889
- idempotencyParams
7014
+ idempotencyParams,
7015
+ maintainParams
5890
7016
  ]) {
5891
7017
  for (const [name, spec] of Object.entries(source)) params[name] = spec;
5892
7018
  }
@@ -5897,6 +7023,7 @@ function synthesizeMutationTransaction(contractName, methodName, ops, checks, ed
5897
7023
  ...guardItems,
5898
7024
  ...outboxItems,
5899
7025
  ...idempotencyItems,
7026
+ ...maintainItems,
5900
7027
  ...checkItems
5901
7028
  ];
5902
7029
  if (items.length > MAX_TRANSACT_ITEMS) {
@@ -6002,7 +7129,8 @@ function serializeCommandContract(contractName, contract, commandSpecs, transact
6002
7129
  const uniqueGuards = method.uniqueGuards ?? [];
6003
7130
  const outboxEvents = method.outboxEvents ?? [];
6004
7131
  const idempotencyGuard = method.idempotencyGuard;
6005
- const hasDerivedEffects = conditionChecks.length > 0 || edgeWrites2.length > 0 || derivedUpdates.length > 0 || uniqueGuards.length > 0 || outboxEvents.length > 0 || idempotencyGuard !== void 0;
7132
+ const maintainWrites = method.maintainWrites ?? [];
7133
+ const hasDerivedEffects = conditionChecks.length > 0 || edgeWrites2.length > 0 || derivedUpdates.length > 0 || uniqueGuards.length > 0 || outboxEvents.length > 0 || idempotencyGuard !== void 0 || maintainWrites.length > 0;
6006
7134
  const isMultiFragment = method.ops !== void 0 && method.ops.length > 1;
6007
7135
  const promotedToTransaction = isMultiFragment || hasDerivedEffects;
6008
7136
  if (promotedToTransaction) {
@@ -6020,7 +7148,8 @@ function serializeCommandContract(contractName, contract, commandSpecs, transact
6020
7148
  derivedUpdates,
6021
7149
  uniqueGuards,
6022
7150
  outboxEvents,
6023
- idempotencyGuard
7151
+ idempotencyGuard,
7152
+ maintainWrites
6024
7153
  )
6025
7154
  ) : (
6026
7155
  // #90: writes only.
@@ -6186,6 +7315,10 @@ export {
6186
7315
  BatchGetResult,
6187
7316
  executeBatchGet,
6188
7317
  executeBatchWrite,
7318
+ maintainTrigger,
7319
+ isMaintainTrigger,
7320
+ preview,
7321
+ identity,
6189
7322
  LIFECYCLE_CONTRACT_MARKER,
6190
7323
  isLifecycleContract,
6191
7324
  ENTITY_WRITES_MARKER,
@@ -6201,6 +7334,7 @@ export {
6201
7334
  deriveEdgeWriteItems,
6202
7335
  getEdgeWrites,
6203
7336
  deriveModelEdgeWriteItems,
7337
+ buildMaintenanceGraph,
6204
7338
  resolveLifecycle,
6205
7339
  compileFragment,
6206
7340
  compileSingleFragmentPlan,