bonescript-compiler 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/LICENSE +21 -21
  2. package/dist/algorithm_catalog.js +166 -166
  3. package/dist/cli.d.ts +1 -2
  4. package/dist/cli.js +543 -75
  5. package/dist/cli.js.map +1 -1
  6. package/dist/emit_capability.d.ts +0 -13
  7. package/dist/emit_capability.js +128 -292
  8. package/dist/emit_capability.js.map +1 -1
  9. package/dist/emit_composition.js +3 -37
  10. package/dist/emit_composition.js.map +1 -1
  11. package/dist/emit_deploy.js +162 -162
  12. package/dist/emit_events.d.ts +0 -1
  13. package/dist/emit_events.js +275 -342
  14. package/dist/emit_events.js.map +1 -1
  15. package/dist/emit_full.js +106 -268
  16. package/dist/emit_full.js.map +1 -1
  17. package/dist/emit_maintenance.js +249 -249
  18. package/dist/emit_runtime.d.ts +11 -17
  19. package/dist/emit_runtime.js +688 -29
  20. package/dist/emit_runtime.js.map +1 -1
  21. package/dist/emit_sourcemap.js +66 -66
  22. package/dist/emit_tests.js +0 -37
  23. package/dist/emit_tests.js.map +1 -1
  24. package/dist/emitter.js +16 -82
  25. package/dist/emitter.js.map +1 -1
  26. package/dist/extension_manager.d.ts +2 -2
  27. package/dist/extension_manager.js +3 -6
  28. package/dist/extension_manager.js.map +1 -1
  29. package/dist/ir.d.ts +0 -4
  30. package/dist/lowering.d.ts +14 -5
  31. package/dist/lowering.js +417 -66
  32. package/dist/lowering.js.map +1 -1
  33. package/dist/module_loader.d.ts +2 -2
  34. package/dist/module_loader.js +23 -20
  35. package/dist/module_loader.js.map +1 -1
  36. package/dist/optimizer.js +1 -1
  37. package/dist/optimizer.js.map +1 -1
  38. package/dist/scaffold.d.ts +2 -2
  39. package/dist/scaffold.js +319 -315
  40. package/dist/scaffold.js.map +1 -1
  41. package/dist/source_map.js.map +1 -0
  42. package/dist/test.js.map +1 -0
  43. package/dist/test_typechecker.d.ts +5 -0
  44. package/dist/test_typechecker.js +126 -0
  45. package/dist/test_typechecker.js.map +1 -0
  46. package/dist/typechecker.d.ts +0 -5
  47. package/dist/typechecker.js +13 -68
  48. package/dist/typechecker.js.map +1 -1
  49. package/dist/verifier.d.ts +1 -5
  50. package/dist/verifier.js +35 -140
  51. package/dist/verifier.js.map +1 -1
  52. package/package.json +52 -62
  53. package/src/algorithm_catalog.ts +345 -345
  54. package/src/ast.d.ts +244 -0
  55. package/src/ast.ts +334 -334
  56. package/src/cli.ts +624 -98
  57. package/src/emit_batch.ts +140 -140
  58. package/src/emit_capability.ts +436 -617
  59. package/src/emit_composition.ts +196 -229
  60. package/src/emit_deploy.ts +190 -190
  61. package/src/emit_events.ts +307 -377
  62. package/src/emit_extras.ts +240 -240
  63. package/src/emit_full.ts +309 -475
  64. package/src/emit_maintenance.ts +459 -459
  65. package/src/emit_runtime.ts +730 -17
  66. package/src/emit_sourcemap.ts +140 -140
  67. package/src/emit_tests.ts +205 -246
  68. package/src/emit_websocket.ts +229 -229
  69. package/src/emitter.ts +578 -642
  70. package/src/extension_manager.ts +187 -189
  71. package/src/formatter.ts +297 -297
  72. package/src/index.ts +88 -88
  73. package/src/ir.ts +215 -216
  74. package/src/lexer.d.ts +195 -0
  75. package/src/lexer.ts +630 -630
  76. package/src/lowering.ts +556 -168
  77. package/src/module_loader.ts +114 -112
  78. package/src/optimizer.ts +196 -196
  79. package/src/parse_decls.d.ts +13 -0
  80. package/src/parse_decls.ts +409 -409
  81. package/src/parse_decls2.d.ts +13 -0
  82. package/src/parse_decls2.ts +244 -244
  83. package/src/parse_expr.d.ts +7 -0
  84. package/src/parse_expr.ts +197 -197
  85. package/src/parse_types.d.ts +6 -0
  86. package/src/parse_types.ts +54 -54
  87. package/src/parser.d.ts +10 -0
  88. package/src/parser.ts +1 -1
  89. package/src/parser_base.d.ts +19 -0
  90. package/src/parser_base.ts +57 -57
  91. package/src/parser_recovery.ts +153 -153
  92. package/src/scaffold.ts +375 -371
  93. package/src/solver.ts +330 -330
  94. package/src/typechecker.d.ts +52 -0
  95. package/src/typechecker.ts +591 -657
  96. package/src/types.d.ts +38 -0
  97. package/src/types.ts +122 -122
  98. package/src/verifier.ts +46 -152
  99. package/README.md +0 -382
  100. package/dist/commands/check.d.ts +0 -5
  101. package/dist/commands/check.js +0 -34
  102. package/dist/commands/check.js.map +0 -1
  103. package/dist/commands/compile.d.ts +0 -5
  104. package/dist/commands/compile.js +0 -215
  105. package/dist/commands/compile.js.map +0 -1
  106. package/dist/commands/debug.d.ts +0 -5
  107. package/dist/commands/debug.js +0 -59
  108. package/dist/commands/debug.js.map +0 -1
  109. package/dist/commands/diff.d.ts +0 -5
  110. package/dist/commands/diff.js +0 -125
  111. package/dist/commands/diff.js.map +0 -1
  112. package/dist/commands/fmt.d.ts +0 -5
  113. package/dist/commands/fmt.js +0 -49
  114. package/dist/commands/fmt.js.map +0 -1
  115. package/dist/commands/init.d.ts +0 -5
  116. package/dist/commands/init.js +0 -96
  117. package/dist/commands/init.js.map +0 -1
  118. package/dist/commands/ir.d.ts +0 -5
  119. package/dist/commands/ir.js +0 -27
  120. package/dist/commands/ir.js.map +0 -1
  121. package/dist/commands/lex.d.ts +0 -5
  122. package/dist/commands/lex.js +0 -21
  123. package/dist/commands/lex.js.map +0 -1
  124. package/dist/commands/parse.d.ts +0 -5
  125. package/dist/commands/parse.js +0 -30
  126. package/dist/commands/parse.js.map +0 -1
  127. package/dist/commands/test.d.ts +0 -5
  128. package/dist/commands/test.js +0 -61
  129. package/dist/commands/test.js.map +0 -1
  130. package/dist/commands/verify_determinism.d.ts +0 -5
  131. package/dist/commands/verify_determinism.js +0 -64
  132. package/dist/commands/verify_determinism.js.map +0 -1
  133. package/dist/commands/watch.d.ts +0 -5
  134. package/dist/commands/watch.js +0 -50
  135. package/dist/commands/watch.js.map +0 -1
  136. package/dist/emit_auth.d.ts +0 -18
  137. package/dist/emit_auth.js +0 -507
  138. package/dist/emit_auth.js.map +0 -1
  139. package/dist/emit_database.d.ts +0 -7
  140. package/dist/emit_database.js +0 -74
  141. package/dist/emit_database.js.map +0 -1
  142. package/dist/emit_index.d.ts +0 -6
  143. package/dist/emit_index.js +0 -202
  144. package/dist/emit_index.js.map +0 -1
  145. package/dist/emit_models.d.ts +0 -12
  146. package/dist/emit_models.js +0 -171
  147. package/dist/emit_models.js.map +0 -1
  148. package/dist/emit_openapi.d.ts +0 -9
  149. package/dist/emit_openapi.js +0 -308
  150. package/dist/emit_openapi.js.map +0 -1
  151. package/dist/emit_package.d.ts +0 -7
  152. package/dist/emit_package.js +0 -70
  153. package/dist/emit_package.js.map +0 -1
  154. package/dist/emit_router.d.ts +0 -12
  155. package/dist/emit_router.js +0 -390
  156. package/dist/emit_router.js.map +0 -1
  157. package/dist/lowering_channels.d.ts +0 -11
  158. package/dist/lowering_channels.js +0 -103
  159. package/dist/lowering_channels.js.map +0 -1
  160. package/dist/lowering_entities.d.ts +0 -11
  161. package/dist/lowering_entities.js +0 -232
  162. package/dist/lowering_entities.js.map +0 -1
  163. package/dist/lowering_helpers.d.ts +0 -13
  164. package/dist/lowering_helpers.js +0 -76
  165. package/dist/lowering_helpers.js.map +0 -1
  166. package/src/commands/check.ts +0 -33
  167. package/src/commands/compile.ts +0 -191
  168. package/src/commands/debug.ts +0 -33
  169. package/src/commands/diff.ts +0 -108
  170. package/src/commands/fmt.ts +0 -22
  171. package/src/commands/init.ts +0 -72
  172. package/src/commands/ir.ts +0 -23
  173. package/src/commands/lex.ts +0 -17
  174. package/src/commands/parse.ts +0 -24
  175. package/src/commands/test.ts +0 -36
  176. package/src/commands/verify_determinism.ts +0 -66
  177. package/src/commands/watch.ts +0 -25
  178. package/src/emit_auth.ts +0 -513
  179. package/src/emit_database.ts +0 -75
  180. package/src/emit_index.ts +0 -210
  181. package/src/emit_models.ts +0 -176
  182. package/src/emit_openapi.ts +0 -318
  183. package/src/emit_package.ts +0 -69
  184. package/src/emit_router.ts +0 -409
  185. package/src/lowering_channels.ts +0 -108
  186. package/src/lowering_entities.ts +0 -258
  187. package/src/lowering_helpers.ts +0 -75
@@ -3,46 +3,48 @@
3
3
  * BoneScript Capability Body Emitter
4
4
  *
5
5
  * Translates IR effects and preconditions into real TypeScript + SQL.
6
- *
7
- * Performance strategies applied (PERF-003):
8
- *
9
- * 1. Entity fetches — same-table fetches batched into WHERE id = ANY($1::uuid[])
10
- * and resolved with a Map lookup. Different-table fetches run in parallel via
11
- * Promise.all rather than sequentially.
12
- *
13
- * 2. Effect batching — multiple effects targeting the same entity+table are
14
- * collapsed into a single UPDATE ... SET a=$1, b=$2 ... WHERE id = $n
15
- * instead of one UPDATE per field.
16
- *
17
- * 3. LIST queries — combined with COUNT(*) OVER() window function to avoid a
18
- * separate COUNT(*) round-trip (handled in emit_router.ts).
19
6
  */
20
7
  Object.defineProperty(exports, "__esModule", { value: true });
21
8
  exports.emitCapabilityBody = void 0;
22
9
  function parseExprStr(s) {
23
10
  s = s.trim();
24
- if (s.startsWith("(") && s.endsWith(")"))
11
+ // Strip outer parens
12
+ if (s.startsWith("(") && s.endsWith(")")) {
25
13
  return parseExprStr(s.slice(1, -1));
26
- if (s.startsWith('"') && s.endsWith('"'))
14
+ }
15
+ // String literal
16
+ if (s.startsWith('"') && s.endsWith('"')) {
27
17
  return { kind: "literal", value: s.slice(1, -1), raw: s };
28
- if (/^-?\d+(\.\d+)?$/.test(s))
18
+ }
19
+ // Number literal
20
+ if (/^-?\d+(\.\d+)?$/.test(s)) {
29
21
  return { kind: "literal", value: s, raw: s };
30
- if (s === "true" || s === "false")
22
+ }
23
+ // Boolean
24
+ if (s === "true" || s === "false") {
31
25
  return { kind: "literal", value: s, raw: s };
26
+ }
27
+ // Binary operators (check in precedence order, right-to-left to handle left-assoc)
32
28
  const binOps = [" or ", " and ", " == ", " != ", " >= ", " <= ", " > ", " < ", " in ", " contains ", " + ", " - ", " * ", " / "];
33
29
  for (const op of binOps) {
34
30
  const idx = findBinOp(s, op);
35
31
  if (idx !== -1) {
36
- return { kind: "binop", op: op.trim(), left: parseExprStr(s.slice(0, idx)), right: parseExprStr(s.slice(idx + op.length)) };
32
+ const left = parseExprStr(s.slice(0, idx));
33
+ const right = parseExprStr(s.slice(idx + op.length));
34
+ return { kind: "binop", op: op.trim(), left, right };
37
35
  }
38
36
  }
37
+ // Function call: name(args)
39
38
  const callMatch = s.match(/^(\w+)\((.*)?\)$/);
40
39
  if (callMatch) {
41
40
  const args = callMatch[2] ? splitArgs(callMatch[2]).map(parseExprStr) : [];
42
41
  return { kind: "call", name: callMatch[1], args };
43
42
  }
44
- if (/^[\w.]+$/.test(s))
43
+ // Field reference: a.b.c
44
+ if (/^[\w.]+$/.test(s)) {
45
45
  return { kind: "field", path: s.split(".") };
46
+ }
47
+ // Fallback: treat as opaque literal
46
48
  return { kind: "literal", value: s, raw: s };
47
49
  }
48
50
  function findBinOp(s, op) {
@@ -53,8 +55,9 @@ function findBinOp(s, op) {
53
55
  depth++;
54
56
  else if (ch === ")" || ch === "]")
55
57
  depth--;
56
- else if (depth === 0 && s.slice(i, i + op.length) === op)
58
+ else if (depth === 0 && s.slice(i, i + op.length) === op) {
57
59
  return i;
60
+ }
58
61
  }
59
62
  return -1;
60
63
  }
@@ -84,7 +87,8 @@ function toSnakeCase(s) {
84
87
  function getEntityFetches(method, mod, system) {
85
88
  const fetches = [];
86
89
  const seen = new Set();
87
- const allModels = new Map();
90
+ // Build a map of all entity names → table names across the whole system
91
+ const allModels = new Map(); // entityName → tableName
88
92
  for (const m of system.modules) {
89
93
  for (const model of m.models) {
90
94
  allModels.set(model.name, toSnakeCase(model.name) + "s");
@@ -95,12 +99,16 @@ function getEntityFetches(method, mod, system) {
95
99
  const tableName = allModels.get(param.type) || allModels.get(param.type.toLowerCase());
96
100
  if (tableName && !seen.has(param.name)) {
97
101
  seen.add(param.name);
98
- fetches.push({ paramName: param.name, entityType: param.type, tableName, idField: param.name + "_id" });
102
+ fetches.push({
103
+ paramName: param.name,
104
+ entityType: param.type,
105
+ tableName,
106
+ idField: param.name + "_id",
107
+ });
99
108
  }
100
109
  }
101
110
  return fetches;
102
111
  }
103
- // ─── Precondition Compiler ────────────────────────────────────────────────────
104
112
  function compilePrecondition(expr, indent) {
105
113
  const condition = exprToTs(expr, true);
106
114
  const description = exprToDescription(expr).replace(/"/g, '\\"');
@@ -125,6 +133,7 @@ function exprToTsInner(expr) {
125
133
  return expr.raw;
126
134
  return expr.value;
127
135
  case "field":
136
+ // Convert field path to JS property access
128
137
  return expr.path.join("?.");
129
138
  case "binop": {
130
139
  const l = exprToTsInner(expr.left);
@@ -136,6 +145,15 @@ function exprToTsInner(expr) {
136
145
  case "or": return `(${l} || ${r})`;
137
146
  case "in": return `[${r}].flat().includes(${l})`;
138
147
  case "contains": return `${l}?.includes(${r})`;
148
+ case ">":
149
+ case "<":
150
+ case ">=":
151
+ case "<=":
152
+ case "+":
153
+ case "-":
154
+ case "*":
155
+ case "/":
156
+ return `${l} ${expr.op} ${r}`;
139
157
  default: return `${l} ${expr.op} ${r}`;
140
158
  }
141
159
  }
@@ -149,271 +167,137 @@ function exprToDescription(expr) {
149
167
  switch (expr.kind) {
150
168
  case "literal": return expr.raw;
151
169
  case "field": return expr.path.join(".");
152
- case "binop": return `${exprToDescription(expr.left)} ${expr.op} ${exprToDescription(expr.right)}`;
170
+ case "binop": {
171
+ const l = exprToDescription(expr.left);
172
+ const r = exprToDescription(expr.right);
173
+ return `${l} ${expr.op} ${r}`;
174
+ }
153
175
  case "call": return `${expr.name}(${expr.args.map(exprToDescription).join(", ")})`;
154
176
  }
155
177
  }
156
- /**
157
- * Compile a single effect into a structured form.
158
- * Returns null if the effect target can't be resolved.
159
- */
160
- function compileEffect(effect, mod, system, paramIdx, method) {
178
+ function compileEffect(effect, mod, system, paramIdx) {
161
179
  const targetParts = effect.target.split(".");
162
180
  if (targetParts.length < 2)
163
181
  return null;
164
- const entityParam = targetParts[0];
165
- const fieldName = targetParts[1];
166
- const nestedPath = targetParts.slice(2);
167
- // Resolve the model: first try matching by param name entity type via method inputs,
168
- // then fall back to matching by entity name directly.
182
+ const entityParam = targetParts[0]; // e.g., "item" or "trade"
183
+ const fieldName = targetParts[1]; // e.g., "quantity" or "offered_items"
184
+ const nestedPath = targetParts.slice(2); // e.g., ["owner_id"] for nested JSONB
185
+ // Find the model for this entity param search across all modules
169
186
  const model = (() => {
170
- // If we have method context, resolve the param name to its entity type
171
- if (method) {
172
- const param = method.input.find(p => p.name === entityParam);
173
- if (param) {
174
- for (const m of system.modules) {
175
- const found = m.models.find(mdl => mdl.name === param.type || mdl.name.toLowerCase() === param.type.toLowerCase());
176
- if (found)
177
- return found;
178
- }
179
- }
180
- }
181
- // Fall back to matching by entity name directly
182
187
  for (const m of system.modules) {
183
- const found = m.models.find(mdl => toSnakeCase(mdl.name) === entityParam || mdl.name.toLowerCase() === entityParam.toLowerCase());
188
+ const found = m.models.find(mdl => toSnakeCase(mdl.name) === entityParam ||
189
+ mdl.name.toLowerCase() === entityParam.toLowerCase());
184
190
  if (found)
185
191
  return found;
186
192
  }
187
- return mod.models.find(m => toSnakeCase(m.name) === entityParam || m.name.toLowerCase() === entityParam.toLowerCase());
193
+ return mod.models.find(m => toSnakeCase(m.name) === entityParam ||
194
+ m.name.toLowerCase() === entityParam.toLowerCase());
188
195
  })();
189
196
  if (!model)
190
197
  return null;
191
198
  const tableName = toSnakeCase(model.name) + "s";
192
- const valueTs = exprToTsInner(parseExprStr(effect.value));
199
+ const valueExpr = parseExprStr(effect.value);
200
+ const valueTs = exprToTsInner(valueExpr);
193
201
  const idParam = `req.body.${entityParam}_id || req.params.id`;
194
- // JSONB nested path must be standalone (jsonb_set can't be batched cleanly)
202
+ // Detect if the param is a list type (bulk operation)
203
+ const isBulk = effect.target.includes("[]") ||
204
+ (entityParam.endsWith("s") && !model.name.toLowerCase().endsWith("s"));
205
+ const bulkIdParam = `req.body.${entityParam}_ids || req.body.${entityParam}?.map((x: any) => x.id)`;
206
+ const whereClause = isBulk
207
+ ? `WHERE id = ANY($2::uuid[])`
208
+ : `WHERE id = ${`$${2}`}`;
209
+ // Handle nested JSONB path: trade.offered_items.owner_id
195
210
  if (nestedPath.length > 0) {
211
+ const jsonbField = fieldName;
212
+ const jsonbPath = nestedPath.join(".");
196
213
  const p1 = `$${paramIdx.n++}`;
197
214
  const p2 = `$${paramIdx.n++}`;
215
+ // Use jsonb_set to update nested path
198
216
  const jsonbPathLiteral = `'{${nestedPath.join(",")}}'`;
199
- // Use to_jsonb($1) directly — casting via ::text loses type information for
200
- // non-string values (numbers, booleans, objects). to_jsonb() handles all
201
- // PostgreSQL types correctly without an intermediate text cast.
202
217
  return {
203
- tableName, entityParam, idParam,
204
- assignments: [],
218
+ sql: `UPDATE ${tableName} SET ${jsonbField} = jsonb_set(COALESCE(${jsonbField}, '{}'), ${jsonbPathLiteral}, to_jsonb(${p1}::text), true), updated_at = NOW() WHERE id = ${p2} RETURNING *`,
219
+ params: [valueTs, idParam],
205
220
  description: `${effect.target} = ${effect.value}`,
206
- standalone: {
207
- sql: `UPDATE ${tableName} SET ${fieldName} = jsonb_set(COALESCE(${fieldName}, '{}'), ${jsonbPathLiteral}, to_jsonb(${p1}), true), updated_at = NOW() WHERE id = ${p2} RETURNING *`,
208
- params: [valueTs, idParam],
209
- },
210
221
  };
211
222
  }
212
- const fieldType = model.fields.find(f => f.name === fieldName)?.type || "";
213
- const isNumeric = ["uint", "int", "float"].includes(fieldType);
214
223
  switch (effect.op) {
215
224
  case "assign": {
216
225
  const p1 = `$${paramIdx.n++}`;
226
+ const p2 = `$${paramIdx.n++}`;
217
227
  return {
218
- tableName, entityParam, idParam,
219
- assignments: [{ column: fieldName, placeholder: p1, tsValue: valueTs }],
228
+ sql: `UPDATE ${tableName} SET ${fieldName} = ${p1}, updated_at = NOW() WHERE id = ${p2} RETURNING *`,
229
+ params: [valueTs, idParam],
220
230
  description: `${effect.target} = ${effect.value}`,
221
231
  };
222
232
  }
223
233
  case "add": {
224
234
  const p1 = `$${paramIdx.n++}`;
235
+ const p2 = `$${paramIdx.n++}`;
236
+ const fieldType = model.fields.find(f => f.name === fieldName)?.type || "";
237
+ const isNumeric = ["uint", "int", "float"].includes(fieldType);
225
238
  if (isNumeric) {
226
239
  return {
227
- tableName, entityParam, idParam,
228
- assignments: [{ column: `${fieldName} = ${fieldName} + `, placeholder: p1, tsValue: valueTs }],
240
+ sql: `UPDATE ${tableName} SET ${fieldName} = ${fieldName} + ${p1}, updated_at = NOW() WHERE id = ${p2} RETURNING *`,
241
+ params: [valueTs, idParam],
229
242
  description: `${effect.target} += ${effect.value}`,
230
243
  };
231
244
  }
232
- // Array append — standalone
233
- const p2 = `$${paramIdx.n++}`;
234
- return {
235
- tableName, entityParam, idParam,
236
- assignments: [],
237
- description: `${effect.target} += ${effect.value}`,
238
- standalone: {
245
+ else {
246
+ return {
239
247
  sql: `UPDATE ${tableName} SET ${fieldName} = ${fieldName} || jsonb_build_array(${p1}::jsonb), updated_at = NOW() WHERE id = ${p2} RETURNING *`,
240
248
  params: [valueTs, idParam],
241
- },
242
- };
249
+ description: `${effect.target} += ${effect.value}`,
250
+ };
251
+ }
243
252
  }
244
253
  case "remove": {
245
254
  const p1 = `$${paramIdx.n++}`;
255
+ const p2 = `$${paramIdx.n++}`;
256
+ const fieldType = model.fields.find(f => f.name === fieldName)?.type || "";
257
+ const isNumeric = ["uint", "int", "float"].includes(fieldType);
246
258
  if (isNumeric) {
247
259
  return {
248
- tableName, entityParam, idParam,
249
- assignments: [{ column: `${fieldName} = ${fieldName} - `, placeholder: p1, tsValue: valueTs }],
260
+ sql: `UPDATE ${tableName} SET ${fieldName} = ${fieldName} - ${p1}, updated_at = NOW() WHERE id = ${p2} RETURNING *`,
261
+ params: [valueTs, idParam],
250
262
  description: `${effect.target} -= ${effect.value}`,
251
263
  };
252
264
  }
253
- // Array remove — standalone
254
- const p2 = `$${paramIdx.n++}`;
255
- return {
256
- tableName, entityParam, idParam,
257
- assignments: [],
258
- description: `${effect.target} -= ${effect.value}`,
259
- standalone: {
265
+ else {
266
+ return {
260
267
  sql: `UPDATE ${tableName} SET ${fieldName} = (SELECT jsonb_agg(elem) FROM jsonb_array_elements(${fieldName}) elem WHERE elem != ${p1}::jsonb), updated_at = NOW() WHERE id = ${p2} RETURNING *`,
261
268
  params: [valueTs, idParam],
262
- },
263
- };
264
- }
265
- }
266
- }
267
- function batchEffects(compiled) {
268
- const batches = new Map(); // key: `${tableName}::${entityParam}::${idParam}`
269
- const standalones = [];
270
- // Re-number parameters globally across all batches
271
- let globalParamN = 1;
272
- for (const effect of compiled) {
273
- if (!effect)
274
- continue;
275
- if (effect.standalone) {
276
- // Re-number the standalone params
277
- let sql = effect.standalone.sql;
278
- const params = effect.standalone.params;
279
- const renumbered = [];
280
- let localN = 1;
281
- for (const p of params) {
282
- sql = sql.replace(`$${localN}`, `$${globalParamN}`);
283
- renumbered.push(p);
284
- globalParamN++;
285
- localN++;
286
- }
287
- standalones.push({ sql, params: renumbered, description: effect.description });
288
- continue;
289
- }
290
- if (effect.assignments.length === 0)
291
- continue;
292
- const key = `${effect.tableName}::${effect.entityParam}::${effect.idParam}`;
293
- if (!batches.has(key)) {
294
- batches.set(key, {
295
- tableName: effect.tableName,
296
- entityParam: effect.entityParam,
297
- idParam: effect.idParam,
298
- setClauses: [],
299
- paramValues: [],
300
- descriptions: [],
301
- });
302
- }
303
- const batch = batches.get(key);
304
- for (const { column, placeholder, tsValue } of effect.assignments) {
305
- // column may be "fieldName" (assign) or "fieldName = fieldName + " (numeric add/remove)
306
- if (column.includes(" = ")) {
307
- // Numeric add/remove: column is already "field = field + " — append placeholder
308
- batch.setClauses.push(`${column}$${globalParamN}`);
309
- }
310
- else {
311
- batch.setClauses.push(`${column} = $${globalParamN}`);
269
+ description: `${effect.target} -= ${effect.value}`,
270
+ };
312
271
  }
313
- batch.paramValues.push(tsValue);
314
- globalParamN++;
315
272
  }
316
- batch.descriptions.push(effect.description);
317
273
  }
318
- return { batches: Array.from(batches.values()), standalones };
319
274
  }
320
275
  // ─── Main Capability Body Emitter ─────────────────────────────────────────────
321
276
  function emitCapabilityBody(method, mod, system, indent = " ") {
322
277
  const lines = [];
323
278
  const fetches = getEntityFetches(method, mod, system);
324
- // 0. Destructure primitive params
279
+ // 0. Destructure primitive params from req.body
325
280
  const primitiveParams = method.input.filter(p => {
326
281
  const isPrimitive = ["string", "uint", "int", "float", "bool", "timestamp", "uuid", "bytes", "json"].includes(p.type);
327
282
  const isListOrSet = p.type.startsWith("list<") || p.type.startsWith("set<");
328
- return (isPrimitive || isListOrSet) && !fetches.some(f => f.paramName === p.name);
283
+ const isEntityFetch = fetches.some(f => f.paramName === p.name);
284
+ return (isPrimitive || isListOrSet) && !isEntityFetch;
329
285
  });
330
286
  if (primitiveParams.length > 0) {
331
- lines.push(`${indent}const { ${primitiveParams.map(p => p.name).join(", ")} } = req.body;`);
287
+ const destructured = primitiveParams.map(p => p.name).join(", ");
288
+ lines.push(`${indent}const { ${destructured} } = req.body;`);
332
289
  lines.push(``);
333
290
  }
334
- // 1. Fetch entities batch same-table fetches, parallelize different-table fetches
291
+ // 1. Fetch entities referenced in preconditions/effects
335
292
  if (fetches.length > 0) {
336
293
  lines.push(`${indent}// Fetch entities`);
337
- // Group fetches by table
338
- const byTable = new Map();
339
- for (const f of fetches) {
340
- if (!byTable.has(f.tableName))
341
- byTable.set(f.tableName, []);
342
- byTable.get(f.tableName).push(f);
343
- }
344
- const fetchGroups = Array.from(byTable.entries());
345
- if (fetchGroups.length === 1 && fetchGroups[0][1].length === 1) {
346
- // Single fetch — simple queryOne
347
- const f = fetchGroups[0][1][0];
348
- const idExpr = `req.body.${f.idField} || req.params.id`;
349
- lines.push(`${indent}const ${f.paramName} = await queryOne(\`SELECT * FROM ${f.tableName} WHERE id = $1\`, [${idExpr}]);`);
350
- lines.push(`${indent}if (!${f.paramName}) {`);
351
- lines.push(`${indent} return res.status(404).json({ error: { code: "NOT_FOUND", message: "${f.paramName} not found" } });`);
294
+ for (const fetch of fetches) {
295
+ const idExpr = `req.body.${fetch.idField} || req.params.id`;
296
+ lines.push(`${indent}const ${fetch.paramName} = await queryOne(\`SELECT * FROM ${fetch.tableName} WHERE id = $1\`, [${idExpr}]);`);
297
+ lines.push(`${indent}if (!${fetch.paramName}) {`);
298
+ lines.push(`${indent} return res.status(404).json({ error: { code: "NOT_FOUND", message: "${fetch.paramName} not found" } });`);
352
299
  lines.push(`${indent}}`);
353
300
  }
354
- else if (fetchGroups.length === 1 && fetchGroups[0][1].length > 1) {
355
- // Multiple fetches from the SAME table — batch into WHERE id = ANY($1::uuid[])
356
- const [tableName, group] = fetchGroups[0];
357
- const idExprs = group.map(f => `req.body.${f.idField} || req.params.id`);
358
- lines.push(`${indent}// Batch fetch: ${group.map(f => f.paramName).join(", ")} from ${tableName} in one query`);
359
- lines.push(`${indent}const __ids_${tableName} = [${idExprs.join(", ")}];`);
360
- lines.push(`${indent}const __rows_${tableName} = await query(\`SELECT * FROM ${tableName} WHERE id = ANY($1::uuid[])\`, [__ids_${tableName}]);`);
361
- lines.push(`${indent}const __map_${tableName} = new Map(__rows_${tableName}.map((r: any) => [r.id, r]));`);
362
- for (const f of group) {
363
- const idExpr = `req.body.${f.idField} || req.params.id`;
364
- lines.push(`${indent}const ${f.paramName} = __map_${tableName}.get(${idExpr}) ?? null;`);
365
- lines.push(`${indent}if (!${f.paramName}) {`);
366
- lines.push(`${indent} return res.status(404).json({ error: { code: "NOT_FOUND", message: "${f.paramName} not found" } });`);
367
- lines.push(`${indent}}`);
368
- }
369
- }
370
- else {
371
- // Multiple fetches from DIFFERENT tables — run in parallel with Promise.all
372
- lines.push(`${indent}// Parallel fetch from ${fetchGroups.length} tables`);
373
- const resultVars = [];
374
- const fetchExprs = [];
375
- for (const [tableName, group] of fetchGroups) {
376
- if (group.length === 1) {
377
- const f = group[0];
378
- const idExpr = `req.body.${f.idField} || req.params.id`;
379
- resultVars.push(`__r_${f.paramName}`);
380
- fetchExprs.push(`queryOne(\`SELECT * FROM ${tableName} WHERE id = $1\`, [${idExpr}])`);
381
- }
382
- else {
383
- // Same-table batch within a multi-table parallel fetch
384
- const idExprs = group.map(f => `req.body.${f.idField} || req.params.id`);
385
- resultVars.push(`__rows_${tableName}`);
386
- fetchExprs.push(`query(\`SELECT * FROM ${tableName} WHERE id = ANY($1::uuid[])\`, [[${idExprs.join(", ")}]])`);
387
- }
388
- }
389
- lines.push(`${indent}const [${resultVars.join(", ")}] = await Promise.all([`);
390
- for (const expr of fetchExprs)
391
- lines.push(`${indent} ${expr},`);
392
- lines.push(`${indent}]);`);
393
- // Unpack results
394
- let resultIdx = 0;
395
- for (const [tableName, group] of fetchGroups) {
396
- if (group.length === 1) {
397
- const f = group[0];
398
- lines.push(`${indent}const ${f.paramName} = ${resultVars[resultIdx]};`);
399
- lines.push(`${indent}if (!${f.paramName}) {`);
400
- lines.push(`${indent} return res.status(404).json({ error: { code: "NOT_FOUND", message: "${f.paramName} not found" } });`);
401
- lines.push(`${indent}}`);
402
- }
403
- else {
404
- const mapVar = `__map_${tableName}`;
405
- lines.push(`${indent}const ${mapVar} = new Map((${resultVars[resultIdx]} as any[]).map((r: any) => [r.id, r]));`);
406
- for (const f of group) {
407
- const idExpr = `req.body.${f.idField} || req.params.id`;
408
- lines.push(`${indent}const ${f.paramName} = ${mapVar}.get(${idExpr}) ?? null;`);
409
- lines.push(`${indent}if (!${f.paramName}) {`);
410
- lines.push(`${indent} return res.status(404).json({ error: { code: "NOT_FOUND", message: "${f.paramName} not found" } });`);
411
- lines.push(`${indent}}`);
412
- }
413
- }
414
- resultIdx++;
415
- }
416
- }
417
301
  lines.push(``);
418
302
  }
419
303
  // 2. Precondition checks
@@ -421,87 +305,36 @@ function emitCapabilityBody(method, mod, system, indent = " ") {
421
305
  lines.push(`${indent}// Preconditions`);
422
306
  for (const pre of method.preconditions) {
423
307
  try {
424
- lines.push(compilePrecondition(parseExprStr(pre.expression), indent));
308
+ const expr = parseExprStr(pre.expression);
309
+ lines.push(compilePrecondition(expr, indent));
425
310
  }
426
311
  catch {
312
+ // Fallback: emit as comment if parsing fails
427
313
  lines.push(`${indent}// CHECK: ${pre.description}`);
428
314
  }
429
315
  }
430
316
  lines.push(``);
431
317
  }
432
- // 3. Effects batch same-entity updates into single UPDATEs
318
+ // 3. Effects (applied in declaration order, each in its own query)
433
319
  if (method.effects.length > 0) {
434
- lines.push(`${indent}// Effects (batched by entity to minimise round-trips)`);
435
- const paramIdx = { n: 1 };
436
- const compiled = method.effects.map(e => compileEffect(e, mod, system, paramIdx, method));
437
- const { batches, standalones } = batchEffects(compiled);
438
- // Emit batched UPDATEs
439
- for (const batch of batches) {
440
- if (batch.setClauses.length === 0)
441
- continue;
442
- const idParamN = paramIdx.n++;
443
- const setClauses = batch.setClauses.join(", ");
444
- const sql = `UPDATE ${batch.tableName} SET ${setClauses}, updated_at = NOW() WHERE id = $${idParamN} RETURNING *`;
445
- const params = [...batch.paramValues, batch.idParam].join(", ");
446
- const resultVar = `__upd_${batch.entityParam}`;
447
- lines.push(`${indent}// ${batch.descriptions.join("; ")}`);
448
- lines.push(`${indent}const ${resultVar} = await query(\`${sql}\`, [${params}]);`);
449
- lines.push(`${indent}if (!${resultVar} || ${resultVar}.length === 0) {`);
450
- lines.push(`${indent} throw new Error("Update failed for ${batch.entityParam}");`);
451
- lines.push(`${indent}}`);
452
- }
453
- // Emit standalone effects (JSONB, array ops)
454
- for (const s of standalones) {
455
- const resultVar = `__eff_${standalones.indexOf(s)}`;
456
- lines.push(`${indent}// ${s.description}`);
457
- lines.push(`${indent}const ${resultVar} = await query(\`${s.sql}\`, [${s.params.join(", ")}]);`);
458
- lines.push(`${indent}if (!${resultVar} || ${resultVar}.length === 0) {`);
459
- lines.push(`${indent} throw new Error("Effect failed: ${s.description.replace(/"/g, '\\"')}");`);
460
- lines.push(`${indent}}`);
461
- }
462
- // Fallback for effects that couldn't be compiled.
463
- // Collection-level effects (e.g. list<T>.field = value) are not yet supported
464
- // by the batch compiler — emit a clear TODO rather than silently dropping them.
465
- // Effects that reference a completely unknown model are a hard error.
320
+ lines.push(`${indent}// Effects (applied in declaration order)`);
321
+ const effectResults = [];
466
322
  for (const effect of method.effects) {
467
- const paramIdx2 = { n: 1 };
468
- if (!compileEffect(effect, mod, system, paramIdx2, method)) {
469
- const targetParts = effect.target.split(".");
470
- const paramName = targetParts[0];
471
- const param = method.input.find(p => p.name === paramName);
472
- const isCollectionEffect = param && (param.type.startsWith("list<") || param.type.startsWith("set<"));
473
- if (isCollectionEffect) {
474
- // Collection-level effects: apply the field update to all items in the collection
475
- // using a single batched UPDATE ... WHERE id = ANY($ids::uuid[])
476
- const innerType = param.type.replace(/^(list|set)<(.+)>$/, "$2");
477
- // Find the model for the inner element type
478
- let elemModel;
479
- for (const m of system.modules) {
480
- elemModel = m.models.find(mdl => mdl.name === innerType || mdl.name.toLowerCase() === innerType.toLowerCase());
481
- if (elemModel)
482
- break;
483
- }
484
- const tableName = elemModel ? (elemModel.name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase() + "s") : (innerType.toLowerCase() + "s");
485
- const fieldName = targetParts[1];
486
- const valueTs = targetParts[1] ? effect.value : "null";
487
- const opSql = effect.op === "add"
488
- ? `${fieldName} = ${fieldName} + $1`
489
- : effect.op === "remove"
490
- ? `${fieldName} = ${fieldName} - $1`
491
- : `${fieldName} = $1`;
492
- lines.push(`${indent}// Collection effect: ${effect.target} ${effect.op === "assign" ? "=" : effect.op === "add" ? "+=" : "-="} ${effect.value}`);
493
- lines.push(`${indent}if (${paramName} && ${paramName}.length > 0) {`);
494
- lines.push(`${indent} const __ids_${paramName} = ${paramName}.map((x: any) => x.id ?? x);`);
495
- lines.push(`${indent} await query(`);
496
- lines.push(`${indent} \`UPDATE ${tableName} SET ${opSql}, updated_at = NOW() WHERE id = ANY($2::uuid[])\`,`);
497
- lines.push(`${indent} [${effect.value}, __ids_${paramName}],`);
498
- lines.push(`${indent} );`);
499
- lines.push(`${indent}}`);
500
- }
501
- else {
502
- throw new Error(`Unsupported effect in method '${method.name}': target '${effect.target}' could not be resolved to a known model field. ` +
503
- `Ensure the effect target matches a declared entity field (e.g. 'entityName.fieldName').`);
504
- }
323
+ // Each effect gets its own parameter numbering starting at 1
324
+ const paramIdx = { n: 1 };
325
+ const compiled = compileEffect(effect, mod, system, paramIdx);
326
+ if (compiled) {
327
+ const resultVar = `__effect_${effectResults.length}`;
328
+ effectResults.push(resultVar);
329
+ lines.push(`${indent}const ${resultVar} = await query(\`${compiled.sql}\`, [${compiled.params.join(", ")}]);`);
330
+ lines.push(`${indent}if (!${resultVar} || ${resultVar}.length === 0) {`);
331
+ lines.push(`${indent} throw new Error("Effect failed: ${compiled.description.replace(/"/g, '\\"')}");`);
332
+ lines.push(`${indent}}`);
333
+ }
334
+ else {
335
+ // Fallback for complex effects we can't compile
336
+ lines.push(`${indent}// EFFECT: ${effect.target} ${effect.op === "assign" ? "=" : effect.op === "add" ? "+=" : "-="} ${effect.value}`);
337
+ lines.push(`${indent}// TODO: Implement this effect manually`);
505
338
  }
506
339
  }
507
340
  lines.push(``);
@@ -532,7 +365,10 @@ function emitCapabilityBody(method, mod, system, indent = " ") {
532
365
  }
533
366
  exports.emitCapabilityBody = emitCapabilityBody;
534
367
  function buildEventPayload(method, fetches) {
535
- const fields = fetches.map(f => `${f.paramName}_id: ${f.paramName}?.id`);
368
+ const fields = [];
369
+ for (const fetch of fetches) {
370
+ fields.push(`${fetch.paramName}_id: ${fetch.paramName}?.id`);
371
+ }
536
372
  fields.push(`timestamp: new Date().toISOString()`);
537
373
  fields.push(`actor_id: auth.actor_id`);
538
374
  return `{ ${fields.join(", ")} }`;