graphddb 0.3.0 → 0.3.1

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.
@@ -6,10 +6,12 @@ import {
6
6
  MetadataRegistry,
7
7
  NO_MIDDLEWARE,
8
8
  TableMapping,
9
+ ViewRegistry,
9
10
  attachHiddenKey,
10
11
  attachModelClass,
11
12
  buildConditionExpression,
12
13
  buildDeleteInput,
14
+ buildMaintenanceGraph,
13
15
  buildPutInput,
14
16
  buildReadRuntime,
15
17
  buildUpdateInput,
@@ -25,9 +27,13 @@ import {
25
27
  executePut,
26
28
  executeTransaction,
27
29
  executeUpdate,
30
+ getEntityWrites,
28
31
  isColumn,
32
+ isEntityWritesDefinition,
33
+ isLifecycleContract,
29
34
  isParam,
30
35
  isRawCondition,
36
+ lifecyclePhaseForIntent,
31
37
  param,
32
38
  pkTemplate,
33
39
  resolveKey,
@@ -37,7 +43,7 @@ import {
37
43
  serializeFieldValue,
38
44
  serializeRawCondition,
39
45
  skTemplate
40
- } from "./chunk-6AIAHP3A.js";
46
+ } from "./chunk-YIXXTGZ6.js";
41
47
 
42
48
  // src/spec/types.ts
43
49
  var SPEC_VERSION = "1.0";
@@ -1756,137 +1762,6 @@ async function executeBatchWrite(requests) {
1756
1762
  }
1757
1763
  }
1758
1764
 
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
1765
  // src/relation/edge-write.ts
1891
1766
  var OLD_VALUE_NAMESPACE = "old";
1892
1767
  var EDGE_WRITES_MARKER = /* @__PURE__ */ Symbol("graphddb:edgeWrites");
@@ -1896,13 +1771,22 @@ function isEdgeWritesDefinition(value) {
1896
1771
  function declaration(target, relationProperty) {
1897
1772
  return { targetFactory: target, relationProperty };
1898
1773
  }
1899
- var recorder2 = {
1774
+ var recorder = {
1900
1775
  putEdge: declaration,
1901
1776
  deleteEdge: declaration,
1902
- updateKeyChangeEdge: declaration
1777
+ updateKeyChangeEdge: declaration,
1778
+ dualEdge: (forward, inverse) => ({
1779
+ targetFactory: forward.target,
1780
+ relationProperty: forward.relationProperty,
1781
+ inverse: {
1782
+ adjacencyFactory: inverse.adjacency,
1783
+ targetFactory: inverse.target,
1784
+ relationProperty: inverse.relationProperty
1785
+ }
1786
+ })
1903
1787
  };
1904
1788
  function edgeWrites(builder) {
1905
- const edges = builder(recorder2);
1789
+ const edges = builder(recorder);
1906
1790
  if (edges.length === 0) {
1907
1791
  throw new Error(
1908
1792
  "edgeWrites(...) must declare at least one edge (w.putEdge / w.deleteEdge / w.updateKeyChangeEdge); an empty declaration writes nothing."
@@ -1944,6 +1828,13 @@ function assertRelationRoundTrips(entity, edgeFields, decl) {
1944
1828
  }
1945
1829
  return relation;
1946
1830
  }
1831
+ function assertDualEdge(forwardAdjacency, inverseAdjacency, decl) {
1832
+ if (inverseAdjacency === forwardAdjacency) {
1833
+ throw new Error(
1834
+ `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.`
1835
+ );
1836
+ }
1837
+ }
1947
1838
  function templateRecord(fields) {
1948
1839
  const out = {};
1949
1840
  for (const field of [...fields].sort()) {
@@ -1962,6 +1853,25 @@ function putItemTemplate(edgeFields) {
1962
1853
  return templateRecord(edgeFields);
1963
1854
  }
1964
1855
  function deriveEdgeWriteItemsFor(adjacencyClass, decl, lifecycle) {
1856
+ const forward = deriveOneEdgeRow(
1857
+ adjacencyClass,
1858
+ { targetFactory: decl.targetFactory, relationProperty: decl.relationProperty },
1859
+ lifecycle
1860
+ );
1861
+ if (decl.inverse === void 0) return forward;
1862
+ const inverseAdjacency = decl.inverse.adjacencyFactory();
1863
+ assertDualEdge(adjacencyClass, inverseAdjacency, decl);
1864
+ const inverse = deriveOneEdgeRow(
1865
+ inverseAdjacency,
1866
+ {
1867
+ targetFactory: decl.inverse.targetFactory,
1868
+ relationProperty: decl.inverse.relationProperty
1869
+ },
1870
+ lifecycle
1871
+ );
1872
+ return [...forward, ...inverse];
1873
+ }
1874
+ function deriveOneEdgeRow(adjacencyClass, decl, lifecycle) {
1965
1875
  const entity = MetadataRegistry.get(adjacencyClass);
1966
1876
  if (!entity.primaryKey) {
1967
1877
  throw new Error(
@@ -2029,387 +1939,6 @@ function deriveModelEdgeWriteItems(modelClass, lifecycle) {
2029
1939
  return deriveEdgeWriteItems(modelClass, writes, lifecycle);
2030
1940
  }
2031
1941
 
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
1942
  // src/runtime/command-runtime.ts
2414
1943
  function resolveCommandMethod(contract, methodName) {
2415
1944
  const method = contract.methods[methodName];
@@ -2658,6 +2187,13 @@ function renderOutboxEventItems(event, key, params, contextLabel) {
2658
2187
  return { Put: put };
2659
2188
  });
2660
2189
  }
2190
+ function renderMaintainOutboxItems(outbox, key, params, contextLabel) {
2191
+ return outbox.items.map((item) => {
2192
+ const Item = renderTemplateRecord(item.item, key, params, `${contextLabel} maintain-outbox Put`);
2193
+ const put = { TableName: TableMapping.resolve(item.tableName), Item };
2194
+ return { Put: put };
2195
+ });
2196
+ }
2661
2197
  function renderIdempotencyGuardItems(guard, key, params, contextLabel) {
2662
2198
  return guard.items.map((item) => {
2663
2199
  const Item = renderTemplateRecord(item.item, key, params, `${contextLabel} idempotency Put`);
@@ -2727,6 +2263,11 @@ function renderMaintainWriteItem(maintain, key, params, contextLabel, modelBySig
2727
2263
  `Contract runtime: ${contextLabel} is a 'counter' maintenance write with no \`counter\` payload \u2014 the compiler (#141) should have set it.`
2728
2264
  );
2729
2265
  }
2266
+ if (counter.op !== "count") {
2267
+ throw new Error(
2268
+ `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.`
2269
+ );
2270
+ }
2730
2271
  updateInput = {
2731
2272
  TableName,
2732
2273
  Key,
@@ -2738,6 +2279,11 @@ function renderMaintainWriteItem(maintain, key, params, contextLabel, modelBySig
2738
2279
  modelBySignature?.set(execItemKeySignature(out2), ownerClass.name);
2739
2280
  return out2;
2740
2281
  }
2282
+ if (maintain.kind === "membership") {
2283
+ throw new Error(
2284
+ `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.`
2285
+ );
2286
+ }
2741
2287
  const projection = buildMaintainProjection(maintain, key, params, contextLabel);
2742
2288
  if (maintain.kind === "collection") {
2743
2289
  const field = maintain.collection?.field;
@@ -2783,7 +2329,7 @@ function renderMaintainWriteItem(maintain, key, params, contextLabel, modelBySig
2783
2329
  modelBySignature?.set(execItemKeySignature(out), ownerClass.name);
2784
2330
  return out;
2785
2331
  }
2786
- async function executeReferentialTransaction(writes, edgeItems, updateItems, guardItemsRaw, outboxItems, idempotencyItems, maintainItems, checks, methodName, modelBySignature = /* @__PURE__ */ new Map(), retry, middleware) {
2332
+ async function executeReferentialTransaction(writes, edgeItems, updateItems, guardItemsRaw, outboxItems, idempotencyItems, maintainItems, maintainOutboxItems, checks, methodName, modelBySignature = /* @__PURE__ */ new Map(), retry, middleware) {
2787
2333
  const writeItems = writes.map((w) => {
2788
2334
  const options = w.condition !== void 0 ? { condition: w.condition } : void 0;
2789
2335
  let item;
@@ -2807,6 +2353,7 @@ async function executeReferentialTransaction(writes, edgeItems, updateItems, gua
2807
2353
  ...outboxItems,
2808
2354
  ...idempotencyItems,
2809
2355
  ...maintainItems,
2356
+ ...maintainOutboxItems,
2810
2357
  ...checkItems
2811
2358
  ],
2812
2359
  {
@@ -2874,7 +2421,7 @@ async function executeCommandMethod(contract, methodName, keyOrKeys, params = {}
2874
2421
  `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
2422
  );
2876
2423
  }
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;
2424
+ 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
2425
  if (hasDerivedEffects2) {
2879
2426
  throw new Error(
2880
2427
  `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 +2444,8 @@ async function executeCommandMethod(contract, methodName, keyOrKeys, params = {}
2897
2444
  const outboxEvents = method.outboxEvents ?? [];
2898
2445
  const idempotencyGuard = method.idempotencyGuard;
2899
2446
  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;
2447
+ const maintainOutbox = method.maintainOutbox ?? [];
2448
+ 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
2449
  const isMultiFragment = method.ops !== void 0 && method.ops.length > 1;
2902
2450
  if (isMultiFragment || hasDerivedEffects) {
2903
2451
  const ops = method.ops ?? [method.op];
@@ -2945,6 +2493,9 @@ async function executeCommandMethod(contract, methodName, keyOrKeys, params = {}
2945
2493
  modelBySignature
2946
2494
  )
2947
2495
  );
2496
+ const maintainOutboxItems = maintainOutbox.flatMap(
2497
+ (mo, i) => renderMaintainOutboxItems(mo, key, params, `command method '${methodName}' (maintain-outbox #${i})`)
2498
+ );
2948
2499
  const checks = conditionChecks.map((check, i) => ({
2949
2500
  check: renderConditionCheck(
2950
2501
  check,
@@ -2961,6 +2512,7 @@ async function executeCommandMethod(contract, methodName, keyOrKeys, params = {}
2961
2512
  outboxItems,
2962
2513
  idempotencyItems,
2963
2514
  maintainItems,
2515
+ maintainOutboxItems,
2964
2516
  checks,
2965
2517
  methodName,
2966
2518
  modelBySignature
@@ -2990,6 +2542,7 @@ function renderCompiledOp(op, ctx) {
2990
2542
  const outboxItems = [];
2991
2543
  const idempotencyItems = [];
2992
2544
  const maintainItems = [];
2545
+ const maintainOutboxItems = [];
2993
2546
  const checkItems = [];
2994
2547
  for (const edge of fragment.edgeWrites ?? []) {
2995
2548
  edgeItems.push(...renderEdgeWriteItems(edge, op.params, op.params, `${ctx} edge`, modelBySignature));
@@ -3013,10 +2566,15 @@ function renderCompiledOp(op, ctx) {
3013
2566
  renderMaintainWriteItem(m, op.params, op.params, `${ctx} maintain`, modelBySignature)
3014
2567
  );
3015
2568
  }
2569
+ for (const mo of fragment.maintainOutbox ?? []) {
2570
+ maintainOutboxItems.push(
2571
+ ...renderMaintainOutboxItems(mo, op.params, op.params, `${ctx} maintain-outbox`)
2572
+ );
2573
+ }
3016
2574
  for (const c of fragment.conditionChecks ?? []) {
3017
2575
  checkItems.push({ check: renderConditionCheck(c, op.params, op.params, `${ctx} requires`) });
3018
2576
  }
3019
- const hasDerivedEffects = edgeItems.length > 0 || updateItems.length > 0 || guardItems.length > 0 || outboxItems.length > 0 || idempotencyItems.length > 0 || maintainItems.length > 0 || checkItems.length > 0;
2577
+ 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
2578
  return {
3021
2579
  write,
3022
2580
  edgeItems,
@@ -3025,6 +2583,7 @@ function renderCompiledOp(op, ctx) {
3025
2583
  outboxItems,
3026
2584
  idempotencyItems,
3027
2585
  maintainItems,
2586
+ maintainOutboxItems,
3028
2587
  checkItems,
3029
2588
  modelBySignature,
3030
2589
  hasDerivedEffects
@@ -3070,6 +2629,7 @@ async function executeCompiledWriteOp(ops, options) {
3070
2629
  const outboxItems = [];
3071
2630
  const idempotencyItems = [];
3072
2631
  const maintainItems = [];
2632
+ const maintainOutboxItems = [];
3073
2633
  const checkItems = [];
3074
2634
  for (const r of renderedOps) {
3075
2635
  for (const [sig, name] of r.modelBySignature) modelBySignature.set(sig, name);
@@ -3079,6 +2639,7 @@ async function executeCompiledWriteOp(ops, options) {
3079
2639
  outboxItems.push(...r.outboxItems);
3080
2640
  idempotencyItems.push(...r.idempotencyItems);
3081
2641
  maintainItems.push(...r.maintainItems);
2642
+ maintainOutboxItems.push(...r.maintainOutboxItems);
3082
2643
  checkItems.push(...r.checkItems);
3083
2644
  }
3084
2645
  const hasDerivedEffects = renderedOps.some((r) => r.hasDerivedEffects);
@@ -3098,6 +2659,7 @@ async function executeCompiledWriteOp(ops, options) {
3098
2659
  outboxItems,
3099
2660
  idempotencyItems,
3100
2661
  maintainItems,
2662
+ maintainOutboxItems,
3101
2663
  checkItems,
3102
2664
  label,
3103
2665
  modelBySignature,
@@ -3196,6 +2758,7 @@ function rewrittenCompiledOp(write) {
3196
2758
  outboxItems: [],
3197
2759
  idempotencyItems: [],
3198
2760
  maintainItems: [],
2761
+ maintainOutboxItems: [],
3199
2762
  checkItems: [],
3200
2763
  modelBySignature: /* @__PURE__ */ new Map(),
3201
2764
  hasDerivedEffects: false
@@ -4080,6 +3643,7 @@ function buildPlannedCommandMethod(name, planned) {
4080
3643
  const idempotencyGuard = idempotencyGuards[0];
4081
3644
  const maintainWrites = plan2.fragments.flatMap((f) => f.maintainWrites ?? []);
4082
3645
  assertNoCrossFragmentMaintainCollision(name, maintainWrites);
3646
+ const maintainOutbox = plan2.fragments.flatMap((f) => f.maintainOutbox ?? []);
4083
3647
  return {
4084
3648
  __methodKind: "command",
4085
3649
  op,
@@ -4091,6 +3655,7 @@ function buildPlannedCommandMethod(name, planned) {
4091
3655
  ...outboxEvents.length > 0 ? { outboxEvents } : {},
4092
3656
  ...idempotencyGuard !== void 0 ? { idempotencyGuard } : {},
4093
3657
  ...maintainWrites.length > 0 ? { maintainWrites } : {},
3658
+ ...maintainOutbox.length > 0 ? { maintainOutbox } : {},
4094
3659
  inputArity: "either",
4095
3660
  // A return projection means the method returns the read-back entity; otherwise
4096
3661
  // it is a fire-and-forget write (`void`).
@@ -4387,8 +3952,9 @@ var MAINTAIN_ENTITY_PATH_RE = /^\$\.entity\.([A-Za-z_$][\w$]*)$/;
4387
3952
  var cachedMaintenanceGraph;
4388
3953
  function globalMaintenanceGraph() {
4389
3954
  const generation = MetadataRegistry.generation;
4390
- if (cachedMaintenanceGraph?.generation !== generation) {
4391
- cachedMaintenanceGraph = { graph: buildMaintenanceGraph(), generation };
3955
+ const viewGeneration = ViewRegistry.generation;
3956
+ if (cachedMaintenanceGraph?.generation !== generation || cachedMaintenanceGraph?.viewGeneration !== viewGeneration) {
3957
+ cachedMaintenanceGraph = { graph: buildMaintenanceGraph(), generation, viewGeneration };
4392
3958
  }
4393
3959
  return cachedMaintenanceGraph.graph;
4394
3960
  }
@@ -4453,11 +4019,7 @@ function assertNoCrossFragmentMaintainCollision(methodName, writes) {
4453
4019
  }
4454
4020
  function deriveOneMaintainWrite(fragment, item) {
4455
4021
  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
- }
4022
+ const updateMode = effect.updateMode === "stream" ? "stream" : "mutation";
4461
4023
  const ownerClass = effect.targetFactory();
4462
4024
  const ownerMeta = MetadataRegistry.get(
4463
4025
  ownerClass
@@ -4473,7 +4035,7 @@ function deriveOneMaintainWrite(fragment, item) {
4473
4035
  );
4474
4036
  }
4475
4037
  if (effect.kind === "counter") {
4476
- return deriveOneCounterWrite(fragment, item, effect, ownerClass, ownerMeta, ownerName, keyBinding);
4038
+ return deriveOneCounterWrite(fragment, item, effect, ownerClass, ownerMeta, ownerName, keyBinding, updateMode);
4477
4039
  }
4478
4040
  const projection = {};
4479
4041
  for (const [attr, transform] of Object.entries(effect.project)) {
@@ -4494,9 +4056,32 @@ function deriveOneMaintainWrite(fragment, item) {
4494
4056
  entity: { name: ownerName, modelClass: ownerClass },
4495
4057
  relationProperty: item.relationProperty,
4496
4058
  trigger: item.trigger,
4059
+ updateMode,
4497
4060
  keyBinding,
4498
4061
  projection
4499
4062
  };
4063
+ if (effect.kind === "membership") {
4064
+ if (updateMode !== "stream") {
4065
+ throw new Error(
4066
+ `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).`
4067
+ );
4068
+ }
4069
+ const predInput = maintainPathToInputField(
4070
+ fragment,
4071
+ item,
4072
+ "membership predicate",
4073
+ effect.predicate.path
4074
+ );
4075
+ return {
4076
+ ...base,
4077
+ kind: "membership",
4078
+ membership: {
4079
+ op: effect.predicate.op,
4080
+ inputField: predInput,
4081
+ ...effect.predicate.value !== void 0 ? { value: effect.predicate.value } : {}
4082
+ }
4083
+ };
4084
+ }
4500
4085
  if (effect.kind === "collection") {
4501
4086
  const orderBy = effect.collection.orderBy !== void 0 ? maintainPathToInputField(fragment, item, "collection `orderBy`", effect.collection.orderBy) : void 0;
4502
4087
  return {
@@ -4505,17 +4090,35 @@ function deriveOneMaintainWrite(fragment, item) {
4505
4090
  collection: {
4506
4091
  field: effect.collection.field,
4507
4092
  ...effect.collection.maxItems !== void 0 ? { maxItems: effect.collection.maxItems } : {},
4508
- ...orderBy !== void 0 ? { orderBy } : {}
4093
+ ...orderBy !== void 0 ? { orderBy } : {},
4094
+ ...orderBy !== void 0 && effect.collection.orderDir !== void 0 ? { orderDir: effect.collection.orderDir } : {}
4509
4095
  }
4510
4096
  };
4511
4097
  }
4512
4098
  return { ...base, kind: "snapshot" };
4513
4099
  }
4514
- function deriveOneCounterWrite(fragment, item, effect, ownerClass, ownerMeta, ownerName, keyBinding) {
4100
+ function deriveOneCounterWrite(fragment, item, effect, ownerClass, ownerMeta, ownerName, keyBinding, updateMode) {
4515
4101
  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
- );
4102
+ if (updateMode !== "stream") {
4103
+ throw new Error(
4104
+ `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.`
4105
+ );
4106
+ }
4107
+ if (!ownerMeta.primaryKey) {
4108
+ throw new Error(
4109
+ `mutation: the '${fragment.intent}' fragment on '${fragment.entity.name}' fires a max counter maintenance effect on '${ownerName}', which has no primary key.`
4110
+ );
4111
+ }
4112
+ return {
4113
+ entity: { name: ownerName, modelClass: ownerClass },
4114
+ relationProperty: item.relationProperty,
4115
+ trigger: item.trigger,
4116
+ updateMode,
4117
+ kind: "counter",
4118
+ keyBinding,
4119
+ projection: {},
4120
+ counter: { attribute: effect.attribute, op: "max", field: effect.value.field }
4121
+ };
4519
4122
  }
4520
4123
  if (effect.delta === void 0) {
4521
4124
  throw new Error(
@@ -4537,10 +4140,11 @@ function deriveOneCounterWrite(fragment, item, effect, ownerClass, ownerMeta, ow
4537
4140
  entity: { name: ownerName, modelClass: ownerClass },
4538
4141
  relationProperty: item.relationProperty,
4539
4142
  trigger: item.trigger,
4143
+ updateMode,
4540
4144
  kind: "counter",
4541
4145
  keyBinding,
4542
4146
  projection: {},
4543
- counter: { attribute: effect.attribute, delta: effect.delta }
4147
+ counter: { attribute: effect.attribute, op: "count", delta: effect.delta }
4544
4148
  };
4545
4149
  }
4546
4150
  var UNIQUE_GUARD_PK_PREFIX = "UNIQUE#";
@@ -4722,6 +4326,67 @@ function deriveIdempotencyGuard(fragment, effect) {
4722
4326
  ]
4723
4327
  };
4724
4328
  }
4329
+ var MAINT_OUTBOX_PK_PREFIX = "OUTBOX#MAINT#";
4330
+ var MAINT_OUTBOX_SK_PREFIX = "MAINT#";
4331
+ var MAINT_OUTBOX_OWNER_ATTR = "__maintOwner__";
4332
+ var MAINT_OUTBOX_RELATION_ATTR = "__maintRelation__";
4333
+ var MAINT_OUTBOX_TRIGGER_ATTR = "__maintTrigger__";
4334
+ var MAINT_OUTBOX_EVENT_VALUE = "maint";
4335
+ function deriveMaintainOutbox(fragment, write) {
4336
+ const keyFields = primaryKeyFields(fragment);
4337
+ const discriminator = `${write.entity.name}#${write.relationProperty}`;
4338
+ const keySegments = keyFields.map((f) => `#{${f}}`).join("");
4339
+ const pk = MAINT_OUTBOX_PK_PREFIX + discriminator + keySegments;
4340
+ const sk = MAINT_OUTBOX_SK_PREFIX + keyFields.map((f) => `{${f}}`).join("#");
4341
+ const item = {
4342
+ PK: pk,
4343
+ SK: sk,
4344
+ [OUTBOX_EVENT_NAME_ATTR]: MAINT_OUTBOX_EVENT_VALUE,
4345
+ [MAINT_OUTBOX_OWNER_ATTR]: write.entity.name,
4346
+ [MAINT_OUTBOX_RELATION_ATTR]: write.relationProperty,
4347
+ [MAINT_OUTBOX_TRIGGER_ATTR]: write.trigger
4348
+ };
4349
+ const sourceFields = /* @__PURE__ */ new Set();
4350
+ for (const inputField of Object.values(write.keyBinding)) sourceFields.add(inputField);
4351
+ for (const transform of Object.values(write.projection)) sourceFields.add(transform.inputField);
4352
+ if (write.collection?.orderBy !== void 0) sourceFields.add(write.collection.orderBy);
4353
+ if (write.counter !== void 0 && write.counter.op === "max") {
4354
+ sourceFields.add(write.counter.field);
4355
+ }
4356
+ if (write.membership !== void 0) sourceFields.add(write.membership.inputField);
4357
+ for (const field of sourceFields) {
4358
+ item[field] = `{${field}}`;
4359
+ }
4360
+ return {
4361
+ ownerEntity: write.entity.name,
4362
+ relationProperty: write.relationProperty,
4363
+ trigger: write.trigger,
4364
+ items: [
4365
+ {
4366
+ type: "Put",
4367
+ tableName: tableNameOf(fragment),
4368
+ entity: MARKER_ROW_ENTITY,
4369
+ item,
4370
+ // No `attribute_not_exists`: the row keys off the source entity key, which the
4371
+ // source `Put`'s own create guard already makes unique — the maintenance intent
4372
+ // is recorded atomically with the source row it derives from.
4373
+ literalKey: true
4374
+ }
4375
+ ]
4376
+ };
4377
+ }
4378
+ function splitMaintainByMode(fragment, writes) {
4379
+ const sync = [];
4380
+ const outbox = [];
4381
+ for (const write of writes) {
4382
+ if (write.updateMode === "stream") {
4383
+ outbox.push(deriveMaintainOutbox(fragment, write));
4384
+ } else {
4385
+ sync.push(write);
4386
+ }
4387
+ }
4388
+ return { sync, outbox };
4389
+ }
4725
4390
  function resolveLifecycle(fragment) {
4726
4391
  const writes = fragment.use ?? getEntityWrites(fragment.entity.modelClass);
4727
4392
  if (writes === void 0) return void 0;
@@ -4818,7 +4483,10 @@ function compileFragment(fragment, index = 0, resolveEntityRef = null, maintenan
4818
4483
  const outboxEvents = lifecycle?.effects.emits !== void 0 && lifecycle.effects.emits.length > 0 ? deriveOutboxEvents(fragment, lifecycle.effects.emits) : void 0;
4819
4484
  const idempotencyGuard = lifecycle?.effects.idempotency !== void 0 ? deriveIdempotencyGuard(fragment, lifecycle.effects.idempotency) : void 0;
4820
4485
  const maintainers = resolveMaintainers(fragment, maintenanceGraph);
4821
- const maintainWrites = maintainers.length > 0 ? deriveMaintainItems(fragment, maintainers) : void 0;
4486
+ const allMaintainWrites = maintainers.length > 0 ? deriveMaintainItems(fragment, maintainers) : void 0;
4487
+ const { sync: syncMaintainWrites, outbox: maintainOutboxAll } = allMaintainWrites !== void 0 ? splitMaintainByMode(fragment, allMaintainWrites) : { sync: [], outbox: [] };
4488
+ const maintainWrites = syncMaintainWrites.length > 0 ? syncMaintainWrites : void 0;
4489
+ const maintainOutbox = maintainOutboxAll.length > 0 ? maintainOutboxAll : void 0;
4822
4490
  return {
4823
4491
  op,
4824
4492
  keyFields,
@@ -4829,7 +4497,8 @@ function compileFragment(fragment, index = 0, resolveEntityRef = null, maintenan
4829
4497
  ...uniqueGuards !== void 0 ? { uniqueGuards } : {},
4830
4498
  ...outboxEvents !== void 0 ? { outboxEvents } : {},
4831
4499
  ...idempotencyGuard !== void 0 ? { idempotencyGuard } : {},
4832
- ...maintainWrites !== void 0 ? { maintainWrites } : {}
4500
+ ...maintainWrites !== void 0 ? { maintainWrites } : {},
4501
+ ...maintainOutbox !== void 0 ? { maintainOutbox } : {}
4833
4502
  };
4834
4503
  }
4835
4504
  function compileSingleFragmentPlan(plan2) {
@@ -5356,7 +5025,7 @@ function runBuildPass(params, build, scalarFields, arrayParams, pass, accessed,
5356
5025
  const recordWrite = (instr) => {
5357
5026
  currentBody.push(instr);
5358
5027
  };
5359
- const recorder3 = {
5028
+ const recorder2 = {
5360
5029
  put(model, item, options) {
5361
5030
  recordWrite({
5362
5031
  kind: "write",
@@ -5449,7 +5118,7 @@ function runBuildPass(params, build, scalarFields, arrayParams, pass, accessed,
5449
5118
  currentBody.push(block);
5450
5119
  }
5451
5120
  };
5452
- build(recorder3, pProxy);
5121
+ build(recorder2, pProxy);
5453
5122
  return instructions;
5454
5123
  }
5455
5124
  function defineTransaction(params, build) {
@@ -6897,6 +6566,11 @@ function buildMaintainWriteItems(maintainWrites) {
6897
6566
  );
6898
6567
  const keyCondition = { PK: pk };
6899
6568
  if (sk !== void 0) keyCondition.SK = sk;
6569
+ if (maintain.kind === "membership") {
6570
+ throw new Error(
6571
+ `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.`
6572
+ );
6573
+ }
6900
6574
  const projection = {};
6901
6575
  for (const [attr, transform] of Object.entries(maintain.projection)) {
6902
6576
  projection[attr] = {
@@ -6914,13 +6588,14 @@ function buildMaintainWriteItems(maintainWrites) {
6914
6588
  collection: {
6915
6589
  field: maintain.collection.field,
6916
6590
  ...maintain.collection.maxItems !== void 0 ? { maxItems: maintain.collection.maxItems } : {},
6917
- ...maintain.collection.orderBy !== void 0 ? { orderBy: maintain.collection.orderBy } : {}
6591
+ ...maintain.collection.orderBy !== void 0 ? { orderBy: maintain.collection.orderBy } : {},
6592
+ ...maintain.collection.orderBy !== void 0 && maintain.collection.orderDir !== void 0 ? { orderDir: maintain.collection.orderDir } : {}
6918
6593
  }
6919
6594
  } : {},
6920
6595
  // #141: a counter carries its scalar `ADD` (attribute + literal numeric delta);
6921
6596
  // the runtimes render `ADD #attr :delta` exactly as the self-lifecycle derived
6922
6597
  // counter (#85) does, so a same-row counter merge stays consistent across paths.
6923
- ...maintain.kind === "counter" && maintain.counter !== void 0 ? {
6598
+ ...maintain.kind === "counter" && maintain.counter !== void 0 && maintain.counter.op === "count" ? {
6924
6599
  counter: {
6925
6600
  attribute: maintain.counter.attribute,
6926
6601
  delta: String(maintain.counter.delta)
@@ -6961,6 +6636,17 @@ function buildOutboxEventItems(outboxEvents, entityMetadata) {
6961
6636
  }
6962
6637
  return { items, params };
6963
6638
  }
6639
+ function buildMaintainOutboxItems(maintainOutbox, entityMetadata) {
6640
+ const items = [];
6641
+ const params = {};
6642
+ for (const outbox of maintainOutbox) {
6643
+ for (const item of outbox.items) {
6644
+ items.push({ ...item, tableName: TableMapping.resolve(item.tableName) });
6645
+ collectTemplateParams(item.item, entityMetadata, params);
6646
+ }
6647
+ }
6648
+ return { items, params };
6649
+ }
6964
6650
  function buildIdempotencyGuardItems(guard, entityMetadata) {
6965
6651
  const items = [];
6966
6652
  const params = {};
@@ -6970,7 +6656,7 @@ function buildIdempotencyGuardItems(guard, entityMetadata) {
6970
6656
  }
6971
6657
  return { items, params };
6972
6658
  }
6973
- function synthesizeMutationTransaction(contractName, methodName, ops, checks, edgeWrites2, derivedUpdates, uniqueGuards, outboxEvents, idempotencyGuard, maintainWrites) {
6659
+ function synthesizeMutationTransaction(contractName, methodName, ops, checks, edgeWrites2, derivedUpdates, uniqueGuards, outboxEvents, idempotencyGuard, maintainWrites, maintainOutbox) {
6974
6660
  const label = opRefName(contractName, methodName);
6975
6661
  const base = composeFragmentTransaction(contractName, methodName, ops);
6976
6662
  const { items: edgeItemsRaw, params: edgeParams } = buildEdgeWriteItems(edgeWrites2);
@@ -6989,6 +6675,7 @@ function synthesizeMutationTransaction(contractName, methodName, ops, checks, ed
6989
6675
  );
6990
6676
  const { items: idempotencyItems, params: idempotencyParams } = idempotencyGuard !== void 0 ? buildIdempotencyGuardItems(idempotencyGuard, primaryMetadata) : { items: [], params: {} };
6991
6677
  const { items: maintainItems, params: maintainParams } = buildMaintainWriteItems(maintainWrites);
6678
+ const { items: maintainOutboxItems, params: maintainOutboxParams } = buildMaintainOutboxItems(maintainOutbox, primaryMetadata);
6992
6679
  const baseKeys = new Set(base.items.map(itemKeySignature));
6993
6680
  for (const update of updateItems) {
6994
6681
  if (baseKeys.has(itemKeySignature(update))) {
@@ -7012,7 +6699,8 @@ function synthesizeMutationTransaction(contractName, methodName, ops, checks, ed
7012
6699
  guardParams,
7013
6700
  outboxParams,
7014
6701
  idempotencyParams,
7015
- maintainParams
6702
+ maintainParams,
6703
+ maintainOutboxParams
7016
6704
  ]) {
7017
6705
  for (const [name, spec] of Object.entries(source)) params[name] = spec;
7018
6706
  }
@@ -7024,6 +6712,7 @@ function synthesizeMutationTransaction(contractName, methodName, ops, checks, ed
7024
6712
  ...outboxItems,
7025
6713
  ...idempotencyItems,
7026
6714
  ...maintainItems,
6715
+ ...maintainOutboxItems,
7027
6716
  ...checkItems
7028
6717
  ];
7029
6718
  if (items.length > MAX_TRANSACT_ITEMS) {
@@ -7130,7 +6819,8 @@ function serializeCommandContract(contractName, contract, commandSpecs, transact
7130
6819
  const outboxEvents = method.outboxEvents ?? [];
7131
6820
  const idempotencyGuard = method.idempotencyGuard;
7132
6821
  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;
6822
+ const maintainOutbox = method.maintainOutbox ?? [];
6823
+ 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
6824
  const isMultiFragment = method.ops !== void 0 && method.ops.length > 1;
7135
6825
  const promotedToTransaction = isMultiFragment || hasDerivedEffects;
7136
6826
  if (promotedToTransaction) {
@@ -7149,7 +6839,8 @@ function serializeCommandContract(contractName, contract, commandSpecs, transact
7149
6839
  uniqueGuards,
7150
6840
  outboxEvents,
7151
6841
  idempotencyGuard,
7152
- maintainWrites
6842
+ maintainWrites,
6843
+ maintainOutbox
7153
6844
  )
7154
6845
  ) : (
7155
6846
  // #90: writes only.
@@ -7315,17 +7006,6 @@ export {
7315
7006
  BatchGetResult,
7316
7007
  executeBatchGet,
7317
7008
  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
7009
  OLD_VALUE_NAMESPACE,
7330
7010
  EDGE_WRITES_MARKER,
7331
7011
  isEdgeWritesDefinition,
@@ -7334,7 +7014,6 @@ export {
7334
7014
  deriveEdgeWriteItems,
7335
7015
  getEdgeWrites,
7336
7016
  deriveModelEdgeWriteItems,
7337
- buildMaintenanceGraph,
7338
7017
  resolveLifecycle,
7339
7018
  compileFragment,
7340
7019
  compileSingleFragmentPlan,