graphddb 0.2.5 → 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.
@@ -37,7 +37,7 @@ import {
37
37
  serializeFieldValue,
38
38
  serializeRawCondition,
39
39
  skTemplate
40
- } from "./chunk-4TYK3AV6.js";
40
+ } from "./chunk-6AIAHP3A.js";
41
41
 
42
42
  // src/spec/types.ts
43
43
  var SPEC_VERSION = "1.0";
@@ -1757,6 +1757,46 @@ async function executeBatchWrite(requests) {
1757
1757
  }
1758
1758
 
1759
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
+ }
1760
1800
  var LIFECYCLE_CONTRACT_MARKER = /* @__PURE__ */ Symbol(
1761
1801
  "graphddb:lifecycleContract"
1762
1802
  );
@@ -1799,6 +1839,9 @@ var recorder = {
1799
1839
  increment(targetFactory, keys, attribute, amount) {
1800
1840
  return { kind: "derive", targetFactory, keys, attribute, amount };
1801
1841
  },
1842
+ transform(path, op, ...args) {
1843
+ return makeProjectionTransform(path, op, args);
1844
+ },
1802
1845
  event(name, payload) {
1803
1846
  return { kind: "event", name, payload };
1804
1847
  },
@@ -1986,6 +2029,387 @@ function deriveModelEdgeWriteItems(modelClass, lifecycle) {
1986
2029
  return deriveEdgeWriteItems(modelClass, writes, lifecycle);
1987
2030
  }
1988
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
+
1989
2413
  // src/runtime/command-runtime.ts
1990
2414
  function resolveCommandMethod(contract, methodName) {
1991
2415
  const method = contract.methods[methodName];
@@ -2245,7 +2669,121 @@ function renderIdempotencyGuardItems(guard, key, params, contextLabel) {
2245
2669
  return { Put: put };
2246
2670
  });
2247
2671
  }
2248
- async function executeReferentialTransaction(writes, edgeItems, updateItems, guardItemsRaw, outboxItems, idempotencyItems, checks, methodName, modelBySignature = /* @__PURE__ */ new Map(), retry, middleware) {
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) {
2249
2787
  const writeItems = writes.map((w) => {
2250
2788
  const options = w.condition !== void 0 ? { condition: w.condition } : void 0;
2251
2789
  let item;
@@ -2268,6 +2806,7 @@ async function executeReferentialTransaction(writes, edgeItems, updateItems, gua
2268
2806
  ...guardItemsRaw,
2269
2807
  ...outboxItems,
2270
2808
  ...idempotencyItems,
2809
+ ...maintainItems,
2271
2810
  ...checkItems
2272
2811
  ],
2273
2812
  {
@@ -2275,7 +2814,7 @@ async function executeReferentialTransaction(writes, edgeItems, updateItems, gua
2275
2814
  ...retry !== void 0 ? { retry } : {},
2276
2815
  ...middleware !== void 0 ? { middleware } : {},
2277
2816
  limitError: (count) => new Error(
2278
- `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.`
2279
2818
  )
2280
2819
  }
2281
2820
  );
@@ -2335,10 +2874,10 @@ async function executeCommandMethod(contract, methodName, keyOrKeys, params = {}
2335
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.`
2336
2875
  );
2337
2876
  }
2338
- 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;
2339
2878
  if (hasDerivedEffects2) {
2340
2879
  throw new Error(
2341
- `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.`
2342
2881
  );
2343
2882
  }
2344
2883
  const writes = keys.map(
@@ -2357,7 +2896,8 @@ async function executeCommandMethod(contract, methodName, keyOrKeys, params = {}
2357
2896
  const uniqueGuards = method.uniqueGuards ?? [];
2358
2897
  const outboxEvents = method.outboxEvents ?? [];
2359
2898
  const idempotencyGuard = method.idempotencyGuard;
2360
- 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;
2361
2901
  const isMultiFragment = method.ops !== void 0 && method.ops.length > 1;
2362
2902
  if (isMultiFragment || hasDerivedEffects) {
2363
2903
  const ops = method.ops ?? [method.op];
@@ -2396,6 +2936,15 @@ async function executeCommandMethod(contract, methodName, keyOrKeys, params = {}
2396
2936
  params,
2397
2937
  `command method '${methodName}' (idempotency)`
2398
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
+ );
2399
2948
  const checks = conditionChecks.map((check, i) => ({
2400
2949
  check: renderConditionCheck(
2401
2950
  check,
@@ -2411,6 +2960,7 @@ async function executeCommandMethod(contract, methodName, keyOrKeys, params = {}
2411
2960
  guardItems,
2412
2961
  outboxItems,
2413
2962
  idempotencyItems,
2963
+ maintainItems,
2414
2964
  checks,
2415
2965
  methodName,
2416
2966
  modelBySignature
@@ -2439,6 +2989,7 @@ function renderCompiledOp(op, ctx) {
2439
2989
  const guardItems = [];
2440
2990
  const outboxItems = [];
2441
2991
  const idempotencyItems = [];
2992
+ const maintainItems = [];
2442
2993
  const checkItems = [];
2443
2994
  for (const edge of fragment.edgeWrites ?? []) {
2444
2995
  edgeItems.push(...renderEdgeWriteItems(edge, op.params, op.params, `${ctx} edge`, modelBySignature));
@@ -2457,10 +3008,15 @@ function renderCompiledOp(op, ctx) {
2457
3008
  ...renderIdempotencyGuardItems(fragment.idempotencyGuard, op.params, op.params, `${ctx} idempotency`)
2458
3009
  );
2459
3010
  }
3011
+ for (const m of fragment.maintainWrites ?? []) {
3012
+ maintainItems.push(
3013
+ renderMaintainWriteItem(m, op.params, op.params, `${ctx} maintain`, modelBySignature)
3014
+ );
3015
+ }
2460
3016
  for (const c of fragment.conditionChecks ?? []) {
2461
3017
  checkItems.push({ check: renderConditionCheck(c, op.params, op.params, `${ctx} requires`) });
2462
3018
  }
2463
- 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;
2464
3020
  return {
2465
3021
  write,
2466
3022
  edgeItems,
@@ -2468,6 +3024,7 @@ function renderCompiledOp(op, ctx) {
2468
3024
  guardItems,
2469
3025
  outboxItems,
2470
3026
  idempotencyItems,
3027
+ maintainItems,
2471
3028
  checkItems,
2472
3029
  modelBySignature,
2473
3030
  hasDerivedEffects
@@ -2512,6 +3069,7 @@ async function executeCompiledWriteOp(ops, options) {
2512
3069
  const guardItems = [];
2513
3070
  const outboxItems = [];
2514
3071
  const idempotencyItems = [];
3072
+ const maintainItems = [];
2515
3073
  const checkItems = [];
2516
3074
  for (const r of renderedOps) {
2517
3075
  for (const [sig, name] of r.modelBySignature) modelBySignature.set(sig, name);
@@ -2520,6 +3078,7 @@ async function executeCompiledWriteOp(ops, options) {
2520
3078
  guardItems.push(...r.guardItems);
2521
3079
  outboxItems.push(...r.outboxItems);
2522
3080
  idempotencyItems.push(...r.idempotencyItems);
3081
+ maintainItems.push(...r.maintainItems);
2523
3082
  checkItems.push(...r.checkItems);
2524
3083
  }
2525
3084
  const hasDerivedEffects = renderedOps.some((r) => r.hasDerivedEffects);
@@ -2538,6 +3097,7 @@ async function executeCompiledWriteOp(ops, options) {
2538
3097
  guardItems,
2539
3098
  outboxItems,
2540
3099
  idempotencyItems,
3100
+ maintainItems,
2541
3101
  checkItems,
2542
3102
  label,
2543
3103
  modelBySignature,
@@ -2635,6 +3195,7 @@ function rewrittenCompiledOp(write) {
2635
3195
  guardItems: [],
2636
3196
  outboxItems: [],
2637
3197
  idempotencyItems: [],
3198
+ maintainItems: [],
2638
3199
  checkItems: [],
2639
3200
  modelBySignature: /* @__PURE__ */ new Map(),
2640
3201
  hasDerivedEffects: false
@@ -3517,6 +4078,8 @@ function buildPlannedCommandMethod(name, planned) {
3517
4078
  );
3518
4079
  }
3519
4080
  const idempotencyGuard = idempotencyGuards[0];
4081
+ const maintainWrites = plan2.fragments.flatMap((f) => f.maintainWrites ?? []);
4082
+ assertNoCrossFragmentMaintainCollision(name, maintainWrites);
3520
4083
  return {
3521
4084
  __methodKind: "command",
3522
4085
  op,
@@ -3527,6 +4090,7 @@ function buildPlannedCommandMethod(name, planned) {
3527
4090
  ...uniqueGuards.length > 0 ? { uniqueGuards } : {},
3528
4091
  ...outboxEvents.length > 0 ? { outboxEvents } : {},
3529
4092
  ...idempotencyGuard !== void 0 ? { idempotencyGuard } : {},
4093
+ ...maintainWrites.length > 0 ? { maintainWrites } : {},
3530
4094
  inputArity: "either",
3531
4095
  // A return projection means the method returns the read-back entity; otherwise
3532
4096
  // it is a fire-and-forget write (`void`).
@@ -3814,6 +4378,171 @@ function deriveOneUpdate(fragment, effect) {
3814
4378
  amount: effect.amount
3815
4379
  };
3816
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
+ }
3817
4546
  var UNIQUE_GUARD_PK_PREFIX = "UNIQUE#";
3818
4547
  var UNIQUE_GUARD_SK_PREFIX = "VALUE#";
3819
4548
  function deriveUniqueGuards(fragment, unique) {
@@ -4032,7 +4761,7 @@ function renderInputLeaf(leaf, isKeyFieldInPut, consumerIndex, consumerField, re
4032
4761
  }
4033
4762
  return leaf;
4034
4763
  }
4035
- function compileFragment(fragment, index = 0, resolveEntityRef = null) {
4764
+ function compileFragment(fragment, index = 0, resolveEntityRef = null, maintenanceGraph) {
4036
4765
  const keyFields = primaryKeyFields(fragment);
4037
4766
  const keyFieldSet = new Set(keyFields);
4038
4767
  const operation = INTENT_OPERATION[fragment.intent];
@@ -4088,6 +4817,8 @@ function compileFragment(fragment, index = 0, resolveEntityRef = null) {
4088
4817
  const uniqueGuards = lifecycle?.effects.unique !== void 0 && lifecycle.effects.unique.length > 0 ? deriveUniqueGuards(fragment, lifecycle.effects.unique) : void 0;
4089
4818
  const outboxEvents = lifecycle?.effects.emits !== void 0 && lifecycle.effects.emits.length > 0 ? deriveOutboxEvents(fragment, lifecycle.effects.emits) : void 0;
4090
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;
4091
4822
  return {
4092
4823
  op,
4093
4824
  keyFields,
@@ -4097,7 +4828,8 @@ function compileFragment(fragment, index = 0, resolveEntityRef = null) {
4097
4828
  ...derivedUpdates !== void 0 ? { derivedUpdates } : {},
4098
4829
  ...uniqueGuards !== void 0 ? { uniqueGuards } : {},
4099
4830
  ...outboxEvents !== void 0 ? { outboxEvents } : {},
4100
- ...idempotencyGuard !== void 0 ? { idempotencyGuard } : {}
4831
+ ...idempotencyGuard !== void 0 ? { idempotencyGuard } : {},
4832
+ ...maintainWrites !== void 0 ? { maintainWrites } : {}
4101
4833
  };
4102
4834
  }
4103
4835
  function compileSingleFragmentPlan(plan2) {
@@ -6143,6 +6875,69 @@ function buildDerivedUpdateItems(derivedUpdates) {
6143
6875
  }
6144
6876
  return { items, params };
6145
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
+ }
6146
6941
  function buildUniqueGuardItems(uniqueGuards, entityMetadata) {
6147
6942
  const items = [];
6148
6943
  const params = {};
@@ -6175,7 +6970,7 @@ function buildIdempotencyGuardItems(guard, entityMetadata) {
6175
6970
  }
6176
6971
  return { items, params };
6177
6972
  }
6178
- function synthesizeMutationTransaction(contractName, methodName, ops, checks, edgeWrites2, derivedUpdates, uniqueGuards, outboxEvents, idempotencyGuard) {
6973
+ function synthesizeMutationTransaction(contractName, methodName, ops, checks, edgeWrites2, derivedUpdates, uniqueGuards, outboxEvents, idempotencyGuard, maintainWrites) {
6179
6974
  const label = opRefName(contractName, methodName);
6180
6975
  const base = composeFragmentTransaction(contractName, methodName, ops);
6181
6976
  const { items: edgeItemsRaw, params: edgeParams } = buildEdgeWriteItems(edgeWrites2);
@@ -6193,6 +6988,7 @@ function synthesizeMutationTransaction(contractName, methodName, ops, checks, ed
6193
6988
  primaryMetadata
6194
6989
  );
6195
6990
  const { items: idempotencyItems, params: idempotencyParams } = idempotencyGuard !== void 0 ? buildIdempotencyGuardItems(idempotencyGuard, primaryMetadata) : { items: [], params: {} };
6991
+ const { items: maintainItems, params: maintainParams } = buildMaintainWriteItems(maintainWrites);
6196
6992
  const baseKeys = new Set(base.items.map(itemKeySignature));
6197
6993
  for (const update of updateItems) {
6198
6994
  if (baseKeys.has(itemKeySignature(update))) {
@@ -6215,7 +7011,8 @@ function synthesizeMutationTransaction(contractName, methodName, ops, checks, ed
6215
7011
  checkParams,
6216
7012
  guardParams,
6217
7013
  outboxParams,
6218
- idempotencyParams
7014
+ idempotencyParams,
7015
+ maintainParams
6219
7016
  ]) {
6220
7017
  for (const [name, spec] of Object.entries(source)) params[name] = spec;
6221
7018
  }
@@ -6226,6 +7023,7 @@ function synthesizeMutationTransaction(contractName, methodName, ops, checks, ed
6226
7023
  ...guardItems,
6227
7024
  ...outboxItems,
6228
7025
  ...idempotencyItems,
7026
+ ...maintainItems,
6229
7027
  ...checkItems
6230
7028
  ];
6231
7029
  if (items.length > MAX_TRANSACT_ITEMS) {
@@ -6331,7 +7129,8 @@ function serializeCommandContract(contractName, contract, commandSpecs, transact
6331
7129
  const uniqueGuards = method.uniqueGuards ?? [];
6332
7130
  const outboxEvents = method.outboxEvents ?? [];
6333
7131
  const idempotencyGuard = method.idempotencyGuard;
6334
- 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;
6335
7134
  const isMultiFragment = method.ops !== void 0 && method.ops.length > 1;
6336
7135
  const promotedToTransaction = isMultiFragment || hasDerivedEffects;
6337
7136
  if (promotedToTransaction) {
@@ -6349,7 +7148,8 @@ function serializeCommandContract(contractName, contract, commandSpecs, transact
6349
7148
  derivedUpdates,
6350
7149
  uniqueGuards,
6351
7150
  outboxEvents,
6352
- idempotencyGuard
7151
+ idempotencyGuard,
7152
+ maintainWrites
6353
7153
  )
6354
7154
  ) : (
6355
7155
  // #90: writes only.
@@ -6515,6 +7315,10 @@ export {
6515
7315
  BatchGetResult,
6516
7316
  executeBatchGet,
6517
7317
  executeBatchWrite,
7318
+ maintainTrigger,
7319
+ isMaintainTrigger,
7320
+ preview,
7321
+ identity,
6518
7322
  LIFECYCLE_CONTRACT_MARKER,
6519
7323
  isLifecycleContract,
6520
7324
  ENTITY_WRITES_MARKER,
@@ -6530,6 +7334,7 @@ export {
6530
7334
  deriveEdgeWriteItems,
6531
7335
  getEdgeWrites,
6532
7336
  deriveModelEdgeWriteItems,
7337
+ buildMaintenanceGraph,
6533
7338
  resolveLifecycle,
6534
7339
  compileFragment,
6535
7340
  compileSingleFragmentPlan,