graphddb 0.3.0 → 0.4.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.
@@ -10,6 +10,7 @@ import {
10
10
  attachModelClass,
11
11
  buildConditionExpression,
12
12
  buildDeleteInput,
13
+ buildMaintenanceGraph,
13
14
  buildPutInput,
14
15
  buildReadRuntime,
15
16
  buildUpdateInput,
@@ -25,9 +26,13 @@ import {
25
26
  executePut,
26
27
  executeTransaction,
27
28
  executeUpdate,
29
+ getEntityWrites,
28
30
  isColumn,
31
+ isEntityWritesDefinition,
32
+ isLifecycleContract,
29
33
  isParam,
30
34
  isRawCondition,
35
+ lifecyclePhaseForIntent,
31
36
  param,
32
37
  pkTemplate,
33
38
  resolveKey,
@@ -37,7 +42,7 @@ import {
37
42
  serializeFieldValue,
38
43
  serializeRawCondition,
39
44
  skTemplate
40
- } from "./chunk-6AIAHP3A.js";
45
+ } from "./chunk-72VZYOSG.js";
41
46
 
42
47
  // src/spec/types.ts
43
48
  var SPEC_VERSION = "1.0";
@@ -1756,137 +1761,6 @@ async function executeBatchWrite(requests) {
1756
1761
  }
1757
1762
  }
1758
1763
 
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
- }
1800
- var LIFECYCLE_CONTRACT_MARKER = /* @__PURE__ */ Symbol(
1801
- "graphddb:lifecycleContract"
1802
- );
1803
- function isLifecycleContract(value) {
1804
- return typeof value === "object" && value !== null && value[LIFECYCLE_CONTRACT_MARKER] === true;
1805
- }
1806
- var ENTITY_WRITES_MARKER = /* @__PURE__ */ Symbol("graphddb:entityWrites");
1807
- function isEntityWritesDefinition(value) {
1808
- return typeof value === "object" && value !== null && value[ENTITY_WRITES_MARKER] === true;
1809
- }
1810
- function freezeEffects(effects) {
1811
- const out = {};
1812
- if (effects.requires !== void 0) out.requires = effects.requires;
1813
- if (effects.unique !== void 0) out.unique = effects.unique;
1814
- if (effects.edges !== void 0) out.edges = effects.edges;
1815
- if (effects.derive !== void 0) out.derive = effects.derive;
1816
- if (effects.emits !== void 0) out.emits = effects.emits;
1817
- if (effects.idempotency !== void 0) out.idempotency = effects.idempotency;
1818
- return out;
1819
- }
1820
- var recorder = {
1821
- lifecycle(effects = {}) {
1822
- return {
1823
- [LIFECYCLE_CONTRACT_MARKER]: true,
1824
- effects: freezeEffects(effects)
1825
- };
1826
- },
1827
- exists(targetFactory, keys) {
1828
- return { kind: "requires", targetFactory, keys };
1829
- },
1830
- unique(spec) {
1831
- return { kind: "unique", name: spec.name, scope: spec.scope, fields: spec.fields };
1832
- },
1833
- putEdge(targetFactory, relationProperty) {
1834
- return { kind: "putEdge", targetFactory, relationProperty };
1835
- },
1836
- deleteEdge(targetFactory, relationProperty) {
1837
- return { kind: "deleteEdge", targetFactory, relationProperty };
1838
- },
1839
- increment(targetFactory, keys, attribute, amount) {
1840
- return { kind: "derive", targetFactory, keys, attribute, amount };
1841
- },
1842
- transform(path, op, ...args) {
1843
- return makeProjectionTransform(path, op, args);
1844
- },
1845
- event(name, payload) {
1846
- return { kind: "event", name, payload };
1847
- },
1848
- idempotentBy(token) {
1849
- return { kind: "idempotency", token };
1850
- }
1851
- };
1852
- function entityWrites(builder) {
1853
- const shape = builder(recorder);
1854
- for (const phase of ["create", "update", "remove"]) {
1855
- const contract = shape[phase];
1856
- if (contract !== void 0 && !isLifecycleContract(contract)) {
1857
- throw new Error(
1858
- `entityWrites: the '${phase}' lifecycle must be built with \`w.lifecycle({...})\` (it carries the \xA72 effect arrays), but received ${JSON.stringify(contract)}.`
1859
- );
1860
- }
1861
- }
1862
- if (shape.create === void 0 && shape.update === void 0 && shape.remove === void 0) {
1863
- throw new Error(
1864
- "entityWrites(...) must declare at least one lifecycle (`create` / `update` / `remove`); an empty save contract declares nothing."
1865
- );
1866
- }
1867
- return {
1868
- [ENTITY_WRITES_MARKER]: true,
1869
- ...shape.create !== void 0 ? { create: shape.create } : {},
1870
- ...shape.update !== void 0 ? { update: shape.update } : {},
1871
- ...shape.remove !== void 0 ? { remove: shape.remove } : {}
1872
- };
1873
- }
1874
- function getEntityWrites(modelClass) {
1875
- for (const name of Object.getOwnPropertyNames(modelClass)) {
1876
- let value;
1877
- try {
1878
- value = modelClass[name];
1879
- } catch {
1880
- continue;
1881
- }
1882
- if (isEntityWritesDefinition(value)) return value;
1883
- }
1884
- return void 0;
1885
- }
1886
- function lifecyclePhaseForIntent(intent) {
1887
- return intent;
1888
- }
1889
-
1890
1764
  // src/relation/edge-write.ts
1891
1765
  var OLD_VALUE_NAMESPACE = "old";
1892
1766
  var EDGE_WRITES_MARKER = /* @__PURE__ */ Symbol("graphddb:edgeWrites");
@@ -1896,13 +1770,22 @@ function isEdgeWritesDefinition(value) {
1896
1770
  function declaration(target, relationProperty) {
1897
1771
  return { targetFactory: target, relationProperty };
1898
1772
  }
1899
- var recorder2 = {
1773
+ var recorder = {
1900
1774
  putEdge: declaration,
1901
1775
  deleteEdge: declaration,
1902
- updateKeyChangeEdge: declaration
1776
+ updateKeyChangeEdge: declaration,
1777
+ dualEdge: (forward, inverse) => ({
1778
+ targetFactory: forward.target,
1779
+ relationProperty: forward.relationProperty,
1780
+ inverse: {
1781
+ adjacencyFactory: inverse.adjacency,
1782
+ targetFactory: inverse.target,
1783
+ relationProperty: inverse.relationProperty
1784
+ }
1785
+ })
1903
1786
  };
1904
1787
  function edgeWrites(builder) {
1905
- const edges = builder(recorder2);
1788
+ const edges = builder(recorder);
1906
1789
  if (edges.length === 0) {
1907
1790
  throw new Error(
1908
1791
  "edgeWrites(...) must declare at least one edge (w.putEdge / w.deleteEdge / w.updateKeyChangeEdge); an empty declaration writes nothing."
@@ -1944,6 +1827,13 @@ function assertRelationRoundTrips(entity, edgeFields, decl) {
1944
1827
  }
1945
1828
  return relation;
1946
1829
  }
1830
+ function assertDualEdge(forwardAdjacency, inverseAdjacency, decl) {
1831
+ if (inverseAdjacency === forwardAdjacency) {
1832
+ throw new Error(
1833
+ `Dual-edge declaration for forward relation '${decl.relationProperty}' on adjacency entity '${forwardAdjacency.name}' points its inverse edge at the SAME adjacency model. A dual edge is TWO physical rows (forward + inverse); both directions resolving to one model would write the same key twice in a transaction (cf. #96). Declare the inverse on a DISTINCT adjacency model, or drop \`inverse\` for the single-row (base PK + inverse GSI) bidirectional edge.`
1834
+ );
1835
+ }
1836
+ }
1947
1837
  function templateRecord(fields) {
1948
1838
  const out = {};
1949
1839
  for (const field of [...fields].sort()) {
@@ -1962,6 +1852,25 @@ function putItemTemplate(edgeFields) {
1962
1852
  return templateRecord(edgeFields);
1963
1853
  }
1964
1854
  function deriveEdgeWriteItemsFor(adjacencyClass, decl, lifecycle) {
1855
+ const forward = deriveOneEdgeRow(
1856
+ adjacencyClass,
1857
+ { targetFactory: decl.targetFactory, relationProperty: decl.relationProperty },
1858
+ lifecycle
1859
+ );
1860
+ if (decl.inverse === void 0) return forward;
1861
+ const inverseAdjacency = decl.inverse.adjacencyFactory();
1862
+ assertDualEdge(adjacencyClass, inverseAdjacency, decl);
1863
+ const inverse = deriveOneEdgeRow(
1864
+ inverseAdjacency,
1865
+ {
1866
+ targetFactory: decl.inverse.targetFactory,
1867
+ relationProperty: decl.inverse.relationProperty
1868
+ },
1869
+ lifecycle
1870
+ );
1871
+ return [...forward, ...inverse];
1872
+ }
1873
+ function deriveOneEdgeRow(adjacencyClass, decl, lifecycle) {
1965
1874
  const entity = MetadataRegistry.get(adjacencyClass);
1966
1875
  if (!entity.primaryKey) {
1967
1876
  throw new Error(
@@ -2029,387 +1938,6 @@ function deriveModelEdgeWriteItems(modelClass, lifecycle) {
2029
1938
  return deriveEdgeWriteItems(modelClass, writes, lifecycle);
2030
1939
  }
2031
1940
 
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
-
2413
1941
  // src/runtime/command-runtime.ts
2414
1942
  function resolveCommandMethod(contract, methodName) {
2415
1943
  const method = contract.methods[methodName];
@@ -2658,6 +2186,13 @@ function renderOutboxEventItems(event, key, params, contextLabel) {
2658
2186
  return { Put: put };
2659
2187
  });
2660
2188
  }
2189
+ function renderMaintainOutboxItems(outbox, key, params, contextLabel) {
2190
+ return outbox.items.map((item) => {
2191
+ const Item = renderTemplateRecord(item.item, key, params, `${contextLabel} maintain-outbox Put`);
2192
+ const put = { TableName: TableMapping.resolve(item.tableName), Item };
2193
+ return { Put: put };
2194
+ });
2195
+ }
2661
2196
  function renderIdempotencyGuardItems(guard, key, params, contextLabel) {
2662
2197
  return guard.items.map((item) => {
2663
2198
  const Item = renderTemplateRecord(item.item, key, params, `${contextLabel} idempotency Put`);
@@ -2727,6 +2262,11 @@ function renderMaintainWriteItem(maintain, key, params, contextLabel, modelBySig
2727
2262
  `Contract runtime: ${contextLabel} is a 'counter' maintenance write with no \`counter\` payload \u2014 the compiler (#141) should have set it.`
2728
2263
  );
2729
2264
  }
2265
+ if (counter.op !== "count") {
2266
+ throw new Error(
2267
+ `Contract runtime: ${contextLabel} is a 'counter' maintenance write with op '${counter.op}', which is the asynchronous stream path (#130) and must not be composed into a synchronous TransactWriteItems \u2014 the compiler should divert it to a maintenance-outbox row.`
2268
+ );
2269
+ }
2730
2270
  updateInput = {
2731
2271
  TableName,
2732
2272
  Key,
@@ -2738,6 +2278,11 @@ function renderMaintainWriteItem(maintain, key, params, contextLabel, modelBySig
2738
2278
  modelBySignature?.set(execItemKeySignature(out2), ownerClass.name);
2739
2279
  return out2;
2740
2280
  }
2281
+ if (maintain.kind === "membership") {
2282
+ throw new Error(
2283
+ `Contract runtime: ${contextLabel} is a 'membership' (sparse-view) maintenance write, which is stream-only (#133) and must not be composed into a synchronous TransactWriteItems \u2014 the compiler should divert it to a maintenance-outbox row.`
2284
+ );
2285
+ }
2741
2286
  const projection = buildMaintainProjection(maintain, key, params, contextLabel);
2742
2287
  if (maintain.kind === "collection") {
2743
2288
  const field = maintain.collection?.field;
@@ -2783,7 +2328,7 @@ function renderMaintainWriteItem(maintain, key, params, contextLabel, modelBySig
2783
2328
  modelBySignature?.set(execItemKeySignature(out), ownerClass.name);
2784
2329
  return out;
2785
2330
  }
2786
- async function executeReferentialTransaction(writes, edgeItems, updateItems, guardItemsRaw, outboxItems, idempotencyItems, maintainItems, checks, methodName, modelBySignature = /* @__PURE__ */ new Map(), retry, middleware) {
2331
+ async function executeReferentialTransaction(writes, edgeItems, updateItems, guardItemsRaw, outboxItems, idempotencyItems, maintainItems, maintainOutboxItems, checks, methodName, modelBySignature = /* @__PURE__ */ new Map(), retry, middleware) {
2787
2332
  const writeItems = writes.map((w) => {
2788
2333
  const options = w.condition !== void 0 ? { condition: w.condition } : void 0;
2789
2334
  let item;
@@ -2807,6 +2352,7 @@ async function executeReferentialTransaction(writes, edgeItems, updateItems, gua
2807
2352
  ...outboxItems,
2808
2353
  ...idempotencyItems,
2809
2354
  ...maintainItems,
2355
+ ...maintainOutboxItems,
2810
2356
  ...checkItems
2811
2357
  ],
2812
2358
  {
@@ -2874,7 +2420,7 @@ async function executeCommandMethod(contract, methodName, keyOrKeys, params = {}
2874
2420
  `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.`
2875
2421
  );
2876
2422
  }
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;
2423
+ 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.maintainOutbox !== void 0 && method.maintainOutbox.length > 0 || method.idempotencyGuard !== void 0;
2878
2424
  if (hasDerivedEffects2) {
2879
2425
  throw new Error(
2880
2426
  `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.`
@@ -2897,7 +2443,8 @@ async function executeCommandMethod(contract, methodName, keyOrKeys, params = {}
2897
2443
  const outboxEvents = method.outboxEvents ?? [];
2898
2444
  const idempotencyGuard = method.idempotencyGuard;
2899
2445
  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;
2446
+ const maintainOutbox = method.maintainOutbox ?? [];
2447
+ const hasDerivedEffects = conditionChecks.length > 0 || edgeWrites2.length > 0 || derivedUpdates.length > 0 || uniqueGuards.length > 0 || outboxEvents.length > 0 || maintainWrites.length > 0 || maintainOutbox.length > 0 || idempotencyGuard !== void 0;
2901
2448
  const isMultiFragment = method.ops !== void 0 && method.ops.length > 1;
2902
2449
  if (isMultiFragment || hasDerivedEffects) {
2903
2450
  const ops = method.ops ?? [method.op];
@@ -2945,6 +2492,9 @@ async function executeCommandMethod(contract, methodName, keyOrKeys, params = {}
2945
2492
  modelBySignature
2946
2493
  )
2947
2494
  );
2495
+ const maintainOutboxItems = maintainOutbox.flatMap(
2496
+ (mo, i) => renderMaintainOutboxItems(mo, key, params, `command method '${methodName}' (maintain-outbox #${i})`)
2497
+ );
2948
2498
  const checks = conditionChecks.map((check, i) => ({
2949
2499
  check: renderConditionCheck(
2950
2500
  check,
@@ -2961,6 +2511,7 @@ async function executeCommandMethod(contract, methodName, keyOrKeys, params = {}
2961
2511
  outboxItems,
2962
2512
  idempotencyItems,
2963
2513
  maintainItems,
2514
+ maintainOutboxItems,
2964
2515
  checks,
2965
2516
  methodName,
2966
2517
  modelBySignature
@@ -2990,6 +2541,7 @@ function renderCompiledOp(op, ctx) {
2990
2541
  const outboxItems = [];
2991
2542
  const idempotencyItems = [];
2992
2543
  const maintainItems = [];
2544
+ const maintainOutboxItems = [];
2993
2545
  const checkItems = [];
2994
2546
  for (const edge of fragment.edgeWrites ?? []) {
2995
2547
  edgeItems.push(...renderEdgeWriteItems(edge, op.params, op.params, `${ctx} edge`, modelBySignature));
@@ -3013,10 +2565,15 @@ function renderCompiledOp(op, ctx) {
3013
2565
  renderMaintainWriteItem(m, op.params, op.params, `${ctx} maintain`, modelBySignature)
3014
2566
  );
3015
2567
  }
2568
+ for (const mo of fragment.maintainOutbox ?? []) {
2569
+ maintainOutboxItems.push(
2570
+ ...renderMaintainOutboxItems(mo, op.params, op.params, `${ctx} maintain-outbox`)
2571
+ );
2572
+ }
3016
2573
  for (const c of fragment.conditionChecks ?? []) {
3017
2574
  checkItems.push({ check: renderConditionCheck(c, op.params, op.params, `${ctx} requires`) });
3018
2575
  }
3019
- const hasDerivedEffects = edgeItems.length > 0 || updateItems.length > 0 || guardItems.length > 0 || outboxItems.length > 0 || idempotencyItems.length > 0 || maintainItems.length > 0 || checkItems.length > 0;
2576
+ const hasDerivedEffects = edgeItems.length > 0 || updateItems.length > 0 || guardItems.length > 0 || outboxItems.length > 0 || idempotencyItems.length > 0 || maintainItems.length > 0 || maintainOutboxItems.length > 0 || checkItems.length > 0;
3020
2577
  return {
3021
2578
  write,
3022
2579
  edgeItems,
@@ -3025,6 +2582,7 @@ function renderCompiledOp(op, ctx) {
3025
2582
  outboxItems,
3026
2583
  idempotencyItems,
3027
2584
  maintainItems,
2585
+ maintainOutboxItems,
3028
2586
  checkItems,
3029
2587
  modelBySignature,
3030
2588
  hasDerivedEffects
@@ -3070,6 +2628,7 @@ async function executeCompiledWriteOp(ops, options) {
3070
2628
  const outboxItems = [];
3071
2629
  const idempotencyItems = [];
3072
2630
  const maintainItems = [];
2631
+ const maintainOutboxItems = [];
3073
2632
  const checkItems = [];
3074
2633
  for (const r of renderedOps) {
3075
2634
  for (const [sig, name] of r.modelBySignature) modelBySignature.set(sig, name);
@@ -3079,6 +2638,7 @@ async function executeCompiledWriteOp(ops, options) {
3079
2638
  outboxItems.push(...r.outboxItems);
3080
2639
  idempotencyItems.push(...r.idempotencyItems);
3081
2640
  maintainItems.push(...r.maintainItems);
2641
+ maintainOutboxItems.push(...r.maintainOutboxItems);
3082
2642
  checkItems.push(...r.checkItems);
3083
2643
  }
3084
2644
  const hasDerivedEffects = renderedOps.some((r) => r.hasDerivedEffects);
@@ -3098,6 +2658,7 @@ async function executeCompiledWriteOp(ops, options) {
3098
2658
  outboxItems,
3099
2659
  idempotencyItems,
3100
2660
  maintainItems,
2661
+ maintainOutboxItems,
3101
2662
  checkItems,
3102
2663
  label,
3103
2664
  modelBySignature,
@@ -3196,6 +2757,7 @@ function rewrittenCompiledOp(write) {
3196
2757
  outboxItems: [],
3197
2758
  idempotencyItems: [],
3198
2759
  maintainItems: [],
2760
+ maintainOutboxItems: [],
3199
2761
  checkItems: [],
3200
2762
  modelBySignature: /* @__PURE__ */ new Map(),
3201
2763
  hasDerivedEffects: false
@@ -4080,6 +3642,7 @@ function buildPlannedCommandMethod(name, planned) {
4080
3642
  const idempotencyGuard = idempotencyGuards[0];
4081
3643
  const maintainWrites = plan2.fragments.flatMap((f) => f.maintainWrites ?? []);
4082
3644
  assertNoCrossFragmentMaintainCollision(name, maintainWrites);
3645
+ const maintainOutbox = plan2.fragments.flatMap((f) => f.maintainOutbox ?? []);
4083
3646
  return {
4084
3647
  __methodKind: "command",
4085
3648
  op,
@@ -4091,6 +3654,7 @@ function buildPlannedCommandMethod(name, planned) {
4091
3654
  ...outboxEvents.length > 0 ? { outboxEvents } : {},
4092
3655
  ...idempotencyGuard !== void 0 ? { idempotencyGuard } : {},
4093
3656
  ...maintainWrites.length > 0 ? { maintainWrites } : {},
3657
+ ...maintainOutbox.length > 0 ? { maintainOutbox } : {},
4094
3658
  inputArity: "either",
4095
3659
  // A return projection means the method returns the read-back entity; otherwise
4096
3660
  // it is a fire-and-forget write (`void`).
@@ -4453,11 +4017,7 @@ function assertNoCrossFragmentMaintainCollision(methodName, writes) {
4453
4017
  }
4454
4018
  function deriveOneMaintainWrite(fragment, item) {
4455
4019
  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
- }
4020
+ const updateMode = effect.updateMode === "stream" ? "stream" : "mutation";
4461
4021
  const ownerClass = effect.targetFactory();
4462
4022
  const ownerMeta = MetadataRegistry.get(
4463
4023
  ownerClass
@@ -4473,7 +4033,7 @@ function deriveOneMaintainWrite(fragment, item) {
4473
4033
  );
4474
4034
  }
4475
4035
  if (effect.kind === "counter") {
4476
- return deriveOneCounterWrite(fragment, item, effect, ownerClass, ownerMeta, ownerName, keyBinding);
4036
+ return deriveOneCounterWrite(fragment, item, effect, ownerClass, ownerMeta, ownerName, keyBinding, updateMode);
4477
4037
  }
4478
4038
  const projection = {};
4479
4039
  for (const [attr, transform] of Object.entries(effect.project)) {
@@ -4494,9 +4054,32 @@ function deriveOneMaintainWrite(fragment, item) {
4494
4054
  entity: { name: ownerName, modelClass: ownerClass },
4495
4055
  relationProperty: item.relationProperty,
4496
4056
  trigger: item.trigger,
4057
+ updateMode,
4497
4058
  keyBinding,
4498
4059
  projection
4499
4060
  };
4061
+ if (effect.kind === "membership") {
4062
+ if (updateMode !== "stream") {
4063
+ throw new Error(
4064
+ `mutation: the '${fragment.intent}' fragment on '${fragment.entity.name}' fires a sparse-view membership effect (view '${item.ownerEntity}', relation '${item.relationProperty}') on the SYNCHRONOUS \`mutation\` path. A membership row is PUT when its predicate holds and DELETED when it flips false; a single atomic transaction cannot branch an item between Put and Delete on a runtime predicate. Declare \`write: { updateMode: 'stream' }\` so the host-side drain applies the put/delete (#133 sparse view is the asynchronous stream path).`
4065
+ );
4066
+ }
4067
+ const predInput = maintainPathToInputField(
4068
+ fragment,
4069
+ item,
4070
+ "membership predicate",
4071
+ effect.predicate.path
4072
+ );
4073
+ return {
4074
+ ...base,
4075
+ kind: "membership",
4076
+ membership: {
4077
+ op: effect.predicate.op,
4078
+ inputField: predInput,
4079
+ ...effect.predicate.value !== void 0 ? { value: effect.predicate.value } : {}
4080
+ }
4081
+ };
4082
+ }
4500
4083
  if (effect.kind === "collection") {
4501
4084
  const orderBy = effect.collection.orderBy !== void 0 ? maintainPathToInputField(fragment, item, "collection `orderBy`", effect.collection.orderBy) : void 0;
4502
4085
  return {
@@ -4505,17 +4088,35 @@ function deriveOneMaintainWrite(fragment, item) {
4505
4088
  collection: {
4506
4089
  field: effect.collection.field,
4507
4090
  ...effect.collection.maxItems !== void 0 ? { maxItems: effect.collection.maxItems } : {},
4508
- ...orderBy !== void 0 ? { orderBy } : {}
4091
+ ...orderBy !== void 0 ? { orderBy } : {},
4092
+ ...orderBy !== void 0 && effect.collection.orderDir !== void 0 ? { orderDir: effect.collection.orderDir } : {}
4509
4093
  }
4510
4094
  };
4511
4095
  }
4512
4096
  return { ...base, kind: "snapshot" };
4513
4097
  }
4514
- function deriveOneCounterWrite(fragment, item, effect, ownerClass, ownerMeta, ownerName, keyBinding) {
4098
+ function deriveOneCounterWrite(fragment, item, effect, ownerClass, ownerMeta, ownerName, keyBinding, updateMode) {
4515
4099
  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
- );
4100
+ if (updateMode !== "stream") {
4101
+ throw new Error(
4102
+ `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}')\` on the SYNCHRONOUS \`mutation\` path. 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). Use \`value: count()\` for a synchronous counter, or declare \`updateMode: 'stream'\` on the \`@aggregate\` to realize the running max.`
4103
+ );
4104
+ }
4105
+ if (!ownerMeta.primaryKey) {
4106
+ throw new Error(
4107
+ `mutation: the '${fragment.intent}' fragment on '${fragment.entity.name}' fires a max counter maintenance effect on '${ownerName}', which has no primary key.`
4108
+ );
4109
+ }
4110
+ return {
4111
+ entity: { name: ownerName, modelClass: ownerClass },
4112
+ relationProperty: item.relationProperty,
4113
+ trigger: item.trigger,
4114
+ updateMode,
4115
+ kind: "counter",
4116
+ keyBinding,
4117
+ projection: {},
4118
+ counter: { attribute: effect.attribute, op: "max", field: effect.value.field }
4119
+ };
4519
4120
  }
4520
4121
  if (effect.delta === void 0) {
4521
4122
  throw new Error(
@@ -4537,10 +4138,11 @@ function deriveOneCounterWrite(fragment, item, effect, ownerClass, ownerMeta, ow
4537
4138
  entity: { name: ownerName, modelClass: ownerClass },
4538
4139
  relationProperty: item.relationProperty,
4539
4140
  trigger: item.trigger,
4141
+ updateMode,
4540
4142
  kind: "counter",
4541
4143
  keyBinding,
4542
4144
  projection: {},
4543
- counter: { attribute: effect.attribute, delta: effect.delta }
4145
+ counter: { attribute: effect.attribute, op: "count", delta: effect.delta }
4544
4146
  };
4545
4147
  }
4546
4148
  var UNIQUE_GUARD_PK_PREFIX = "UNIQUE#";
@@ -4722,6 +4324,67 @@ function deriveIdempotencyGuard(fragment, effect) {
4722
4324
  ]
4723
4325
  };
4724
4326
  }
4327
+ var MAINT_OUTBOX_PK_PREFIX = "OUTBOX#MAINT#";
4328
+ var MAINT_OUTBOX_SK_PREFIX = "MAINT#";
4329
+ var MAINT_OUTBOX_OWNER_ATTR = "__maintOwner__";
4330
+ var MAINT_OUTBOX_RELATION_ATTR = "__maintRelation__";
4331
+ var MAINT_OUTBOX_TRIGGER_ATTR = "__maintTrigger__";
4332
+ var MAINT_OUTBOX_EVENT_VALUE = "maint";
4333
+ function deriveMaintainOutbox(fragment, write) {
4334
+ const keyFields = primaryKeyFields(fragment);
4335
+ const discriminator = `${write.entity.name}#${write.relationProperty}`;
4336
+ const keySegments = keyFields.map((f) => `#{${f}}`).join("");
4337
+ const pk = MAINT_OUTBOX_PK_PREFIX + discriminator + keySegments;
4338
+ const sk = MAINT_OUTBOX_SK_PREFIX + keyFields.map((f) => `{${f}}`).join("#");
4339
+ const item = {
4340
+ PK: pk,
4341
+ SK: sk,
4342
+ [OUTBOX_EVENT_NAME_ATTR]: MAINT_OUTBOX_EVENT_VALUE,
4343
+ [MAINT_OUTBOX_OWNER_ATTR]: write.entity.name,
4344
+ [MAINT_OUTBOX_RELATION_ATTR]: write.relationProperty,
4345
+ [MAINT_OUTBOX_TRIGGER_ATTR]: write.trigger
4346
+ };
4347
+ const sourceFields = /* @__PURE__ */ new Set();
4348
+ for (const inputField of Object.values(write.keyBinding)) sourceFields.add(inputField);
4349
+ for (const transform of Object.values(write.projection)) sourceFields.add(transform.inputField);
4350
+ if (write.collection?.orderBy !== void 0) sourceFields.add(write.collection.orderBy);
4351
+ if (write.counter !== void 0 && write.counter.op === "max") {
4352
+ sourceFields.add(write.counter.field);
4353
+ }
4354
+ if (write.membership !== void 0) sourceFields.add(write.membership.inputField);
4355
+ for (const field of sourceFields) {
4356
+ item[field] = `{${field}}`;
4357
+ }
4358
+ return {
4359
+ ownerEntity: write.entity.name,
4360
+ relationProperty: write.relationProperty,
4361
+ trigger: write.trigger,
4362
+ items: [
4363
+ {
4364
+ type: "Put",
4365
+ tableName: tableNameOf(fragment),
4366
+ entity: MARKER_ROW_ENTITY,
4367
+ item,
4368
+ // No `attribute_not_exists`: the row keys off the source entity key, which the
4369
+ // source `Put`'s own create guard already makes unique — the maintenance intent
4370
+ // is recorded atomically with the source row it derives from.
4371
+ literalKey: true
4372
+ }
4373
+ ]
4374
+ };
4375
+ }
4376
+ function splitMaintainByMode(fragment, writes) {
4377
+ const sync = [];
4378
+ const outbox = [];
4379
+ for (const write of writes) {
4380
+ if (write.updateMode === "stream") {
4381
+ outbox.push(deriveMaintainOutbox(fragment, write));
4382
+ } else {
4383
+ sync.push(write);
4384
+ }
4385
+ }
4386
+ return { sync, outbox };
4387
+ }
4725
4388
  function resolveLifecycle(fragment) {
4726
4389
  const writes = fragment.use ?? getEntityWrites(fragment.entity.modelClass);
4727
4390
  if (writes === void 0) return void 0;
@@ -4818,7 +4481,10 @@ function compileFragment(fragment, index = 0, resolveEntityRef = null, maintenan
4818
4481
  const outboxEvents = lifecycle?.effects.emits !== void 0 && lifecycle.effects.emits.length > 0 ? deriveOutboxEvents(fragment, lifecycle.effects.emits) : void 0;
4819
4482
  const idempotencyGuard = lifecycle?.effects.idempotency !== void 0 ? deriveIdempotencyGuard(fragment, lifecycle.effects.idempotency) : void 0;
4820
4483
  const maintainers = resolveMaintainers(fragment, maintenanceGraph);
4821
- const maintainWrites = maintainers.length > 0 ? deriveMaintainItems(fragment, maintainers) : void 0;
4484
+ const allMaintainWrites = maintainers.length > 0 ? deriveMaintainItems(fragment, maintainers) : void 0;
4485
+ const { sync: syncMaintainWrites, outbox: maintainOutboxAll } = allMaintainWrites !== void 0 ? splitMaintainByMode(fragment, allMaintainWrites) : { sync: [], outbox: [] };
4486
+ const maintainWrites = syncMaintainWrites.length > 0 ? syncMaintainWrites : void 0;
4487
+ const maintainOutbox = maintainOutboxAll.length > 0 ? maintainOutboxAll : void 0;
4822
4488
  return {
4823
4489
  op,
4824
4490
  keyFields,
@@ -4829,7 +4495,8 @@ function compileFragment(fragment, index = 0, resolveEntityRef = null, maintenan
4829
4495
  ...uniqueGuards !== void 0 ? { uniqueGuards } : {},
4830
4496
  ...outboxEvents !== void 0 ? { outboxEvents } : {},
4831
4497
  ...idempotencyGuard !== void 0 ? { idempotencyGuard } : {},
4832
- ...maintainWrites !== void 0 ? { maintainWrites } : {}
4498
+ ...maintainWrites !== void 0 ? { maintainWrites } : {},
4499
+ ...maintainOutbox !== void 0 ? { maintainOutbox } : {}
4833
4500
  };
4834
4501
  }
4835
4502
  function compileSingleFragmentPlan(plan2) {
@@ -5356,7 +5023,7 @@ function runBuildPass(params, build, scalarFields, arrayParams, pass, accessed,
5356
5023
  const recordWrite = (instr) => {
5357
5024
  currentBody.push(instr);
5358
5025
  };
5359
- const recorder3 = {
5026
+ const recorder2 = {
5360
5027
  put(model, item, options) {
5361
5028
  recordWrite({
5362
5029
  kind: "write",
@@ -5449,7 +5116,7 @@ function runBuildPass(params, build, scalarFields, arrayParams, pass, accessed,
5449
5116
  currentBody.push(block);
5450
5117
  }
5451
5118
  };
5452
- build(recorder3, pProxy);
5119
+ build(recorder2, pProxy);
5453
5120
  return instructions;
5454
5121
  }
5455
5122
  function defineTransaction(params, build) {
@@ -6897,6 +6564,11 @@ function buildMaintainWriteItems(maintainWrites) {
6897
6564
  );
6898
6565
  const keyCondition = { PK: pk };
6899
6566
  if (sk !== void 0) keyCondition.SK = sk;
6567
+ if (maintain.kind === "membership") {
6568
+ throw new Error(
6569
+ `maintenance write: a sparse-view membership maintainer ('${maintain.relationProperty}' on '${maintain.entity.name}') reached the synchronous transaction spec builder. Membership is stream-only (#133) \u2014 it should have been diverted to a maintenance-outbox row. This is a compiler defect.`
6570
+ );
6571
+ }
6900
6572
  const projection = {};
6901
6573
  for (const [attr, transform] of Object.entries(maintain.projection)) {
6902
6574
  projection[attr] = {
@@ -6914,13 +6586,14 @@ function buildMaintainWriteItems(maintainWrites) {
6914
6586
  collection: {
6915
6587
  field: maintain.collection.field,
6916
6588
  ...maintain.collection.maxItems !== void 0 ? { maxItems: maintain.collection.maxItems } : {},
6917
- ...maintain.collection.orderBy !== void 0 ? { orderBy: maintain.collection.orderBy } : {}
6589
+ ...maintain.collection.orderBy !== void 0 ? { orderBy: maintain.collection.orderBy } : {},
6590
+ ...maintain.collection.orderBy !== void 0 && maintain.collection.orderDir !== void 0 ? { orderDir: maintain.collection.orderDir } : {}
6918
6591
  }
6919
6592
  } : {},
6920
6593
  // #141: a counter carries its scalar `ADD` (attribute + literal numeric delta);
6921
6594
  // the runtimes render `ADD #attr :delta` exactly as the self-lifecycle derived
6922
6595
  // counter (#85) does, so a same-row counter merge stays consistent across paths.
6923
- ...maintain.kind === "counter" && maintain.counter !== void 0 ? {
6596
+ ...maintain.kind === "counter" && maintain.counter !== void 0 && maintain.counter.op === "count" ? {
6924
6597
  counter: {
6925
6598
  attribute: maintain.counter.attribute,
6926
6599
  delta: String(maintain.counter.delta)
@@ -6961,6 +6634,17 @@ function buildOutboxEventItems(outboxEvents, entityMetadata) {
6961
6634
  }
6962
6635
  return { items, params };
6963
6636
  }
6637
+ function buildMaintainOutboxItems(maintainOutbox, entityMetadata) {
6638
+ const items = [];
6639
+ const params = {};
6640
+ for (const outbox of maintainOutbox) {
6641
+ for (const item of outbox.items) {
6642
+ items.push({ ...item, tableName: TableMapping.resolve(item.tableName) });
6643
+ collectTemplateParams(item.item, entityMetadata, params);
6644
+ }
6645
+ }
6646
+ return { items, params };
6647
+ }
6964
6648
  function buildIdempotencyGuardItems(guard, entityMetadata) {
6965
6649
  const items = [];
6966
6650
  const params = {};
@@ -6970,7 +6654,7 @@ function buildIdempotencyGuardItems(guard, entityMetadata) {
6970
6654
  }
6971
6655
  return { items, params };
6972
6656
  }
6973
- function synthesizeMutationTransaction(contractName, methodName, ops, checks, edgeWrites2, derivedUpdates, uniqueGuards, outboxEvents, idempotencyGuard, maintainWrites) {
6657
+ function synthesizeMutationTransaction(contractName, methodName, ops, checks, edgeWrites2, derivedUpdates, uniqueGuards, outboxEvents, idempotencyGuard, maintainWrites, maintainOutbox) {
6974
6658
  const label = opRefName(contractName, methodName);
6975
6659
  const base = composeFragmentTransaction(contractName, methodName, ops);
6976
6660
  const { items: edgeItemsRaw, params: edgeParams } = buildEdgeWriteItems(edgeWrites2);
@@ -6989,6 +6673,7 @@ function synthesizeMutationTransaction(contractName, methodName, ops, checks, ed
6989
6673
  );
6990
6674
  const { items: idempotencyItems, params: idempotencyParams } = idempotencyGuard !== void 0 ? buildIdempotencyGuardItems(idempotencyGuard, primaryMetadata) : { items: [], params: {} };
6991
6675
  const { items: maintainItems, params: maintainParams } = buildMaintainWriteItems(maintainWrites);
6676
+ const { items: maintainOutboxItems, params: maintainOutboxParams } = buildMaintainOutboxItems(maintainOutbox, primaryMetadata);
6992
6677
  const baseKeys = new Set(base.items.map(itemKeySignature));
6993
6678
  for (const update of updateItems) {
6994
6679
  if (baseKeys.has(itemKeySignature(update))) {
@@ -7012,7 +6697,8 @@ function synthesizeMutationTransaction(contractName, methodName, ops, checks, ed
7012
6697
  guardParams,
7013
6698
  outboxParams,
7014
6699
  idempotencyParams,
7015
- maintainParams
6700
+ maintainParams,
6701
+ maintainOutboxParams
7016
6702
  ]) {
7017
6703
  for (const [name, spec] of Object.entries(source)) params[name] = spec;
7018
6704
  }
@@ -7024,6 +6710,7 @@ function synthesizeMutationTransaction(contractName, methodName, ops, checks, ed
7024
6710
  ...outboxItems,
7025
6711
  ...idempotencyItems,
7026
6712
  ...maintainItems,
6713
+ ...maintainOutboxItems,
7027
6714
  ...checkItems
7028
6715
  ];
7029
6716
  if (items.length > MAX_TRANSACT_ITEMS) {
@@ -7130,7 +6817,8 @@ function serializeCommandContract(contractName, contract, commandSpecs, transact
7130
6817
  const outboxEvents = method.outboxEvents ?? [];
7131
6818
  const idempotencyGuard = method.idempotencyGuard;
7132
6819
  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;
6820
+ const maintainOutbox = method.maintainOutbox ?? [];
6821
+ const hasDerivedEffects = conditionChecks.length > 0 || edgeWrites2.length > 0 || derivedUpdates.length > 0 || uniqueGuards.length > 0 || outboxEvents.length > 0 || idempotencyGuard !== void 0 || maintainWrites.length > 0 || maintainOutbox.length > 0;
7134
6822
  const isMultiFragment = method.ops !== void 0 && method.ops.length > 1;
7135
6823
  const promotedToTransaction = isMultiFragment || hasDerivedEffects;
7136
6824
  if (promotedToTransaction) {
@@ -7149,7 +6837,8 @@ function serializeCommandContract(contractName, contract, commandSpecs, transact
7149
6837
  uniqueGuards,
7150
6838
  outboxEvents,
7151
6839
  idempotencyGuard,
7152
- maintainWrites
6840
+ maintainWrites,
6841
+ maintainOutbox
7153
6842
  )
7154
6843
  ) : (
7155
6844
  // #90: writes only.
@@ -7315,17 +7004,6 @@ export {
7315
7004
  BatchGetResult,
7316
7005
  executeBatchGet,
7317
7006
  executeBatchWrite,
7318
- maintainTrigger,
7319
- isMaintainTrigger,
7320
- preview,
7321
- identity,
7322
- LIFECYCLE_CONTRACT_MARKER,
7323
- isLifecycleContract,
7324
- ENTITY_WRITES_MARKER,
7325
- isEntityWritesDefinition,
7326
- entityWrites,
7327
- getEntityWrites,
7328
- lifecyclePhaseForIntent,
7329
7007
  OLD_VALUE_NAMESPACE,
7330
7008
  EDGE_WRITES_MARKER,
7331
7009
  isEdgeWritesDefinition,
@@ -7334,7 +7012,6 @@ export {
7334
7012
  deriveEdgeWriteItems,
7335
7013
  getEdgeWrites,
7336
7014
  deriveModelEdgeWriteItems,
7337
- buildMaintenanceGraph,
7338
7015
  resolveLifecycle,
7339
7016
  compileFragment,
7340
7017
  compileSingleFragmentPlan,