bonescript-compiler 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/LICENSE +21 -21
  2. package/dist/algorithm_catalog.js +166 -166
  3. package/dist/cli.d.ts +2 -1
  4. package/dist/cli.js +75 -543
  5. package/dist/cli.js.map +1 -1
  6. package/dist/commands/check.d.ts +5 -0
  7. package/dist/commands/check.js +34 -0
  8. package/dist/commands/check.js.map +1 -0
  9. package/dist/commands/compile.d.ts +5 -0
  10. package/dist/commands/compile.js +215 -0
  11. package/dist/commands/compile.js.map +1 -0
  12. package/dist/commands/debug.d.ts +5 -0
  13. package/dist/commands/debug.js +59 -0
  14. package/dist/commands/debug.js.map +1 -0
  15. package/dist/commands/diff.d.ts +5 -0
  16. package/dist/commands/diff.js +125 -0
  17. package/dist/commands/diff.js.map +1 -0
  18. package/dist/commands/fmt.d.ts +5 -0
  19. package/dist/commands/fmt.js +49 -0
  20. package/dist/commands/fmt.js.map +1 -0
  21. package/dist/commands/init.d.ts +5 -0
  22. package/dist/commands/init.js +96 -0
  23. package/dist/commands/init.js.map +1 -0
  24. package/dist/commands/ir.d.ts +5 -0
  25. package/dist/commands/ir.js +27 -0
  26. package/dist/commands/ir.js.map +1 -0
  27. package/dist/commands/lex.d.ts +5 -0
  28. package/dist/commands/lex.js +21 -0
  29. package/dist/commands/lex.js.map +1 -0
  30. package/dist/commands/parse.d.ts +5 -0
  31. package/dist/commands/parse.js +30 -0
  32. package/dist/commands/parse.js.map +1 -0
  33. package/dist/commands/test.d.ts +5 -0
  34. package/dist/commands/test.js +61 -0
  35. package/dist/commands/test.js.map +1 -0
  36. package/dist/commands/verify_determinism.d.ts +5 -0
  37. package/dist/commands/verify_determinism.js +64 -0
  38. package/dist/commands/verify_determinism.js.map +1 -0
  39. package/dist/commands/watch.d.ts +5 -0
  40. package/dist/commands/watch.js +50 -0
  41. package/dist/commands/watch.js.map +1 -0
  42. package/dist/emit_auth.d.ts +6 -0
  43. package/dist/emit_auth.js +69 -0
  44. package/dist/emit_auth.js.map +1 -0
  45. package/dist/emit_capability.d.ts +13 -0
  46. package/dist/emit_capability.js +292 -128
  47. package/dist/emit_capability.js.map +1 -1
  48. package/dist/emit_composition.js +37 -3
  49. package/dist/emit_composition.js.map +1 -1
  50. package/dist/emit_database.d.ts +7 -0
  51. package/dist/emit_database.js +74 -0
  52. package/dist/emit_database.js.map +1 -0
  53. package/dist/emit_deploy.js +162 -162
  54. package/dist/emit_events.d.ts +1 -0
  55. package/dist/emit_events.js +342 -275
  56. package/dist/emit_events.js.map +1 -1
  57. package/dist/emit_full.js +135 -95
  58. package/dist/emit_full.js.map +1 -1
  59. package/dist/emit_index.d.ts +6 -0
  60. package/dist/emit_index.js +157 -0
  61. package/dist/emit_index.js.map +1 -0
  62. package/dist/emit_maintenance.js +249 -249
  63. package/dist/emit_models.d.ts +12 -0
  64. package/dist/emit_models.js +171 -0
  65. package/dist/emit_models.js.map +1 -0
  66. package/dist/emit_openapi.d.ts +9 -0
  67. package/dist/emit_openapi.js +308 -0
  68. package/dist/emit_openapi.js.map +1 -0
  69. package/dist/emit_package.d.ts +7 -0
  70. package/dist/emit_package.js +70 -0
  71. package/dist/emit_package.js.map +1 -0
  72. package/dist/emit_router.d.ts +12 -0
  73. package/dist/emit_router.js +390 -0
  74. package/dist/emit_router.js.map +1 -0
  75. package/dist/emit_runtime.d.ts +17 -11
  76. package/dist/emit_runtime.js +29 -686
  77. package/dist/emit_runtime.js.map +1 -1
  78. package/dist/emit_sourcemap.js +66 -66
  79. package/dist/emit_tests.js +37 -0
  80. package/dist/emit_tests.js.map +1 -1
  81. package/dist/emitter.js +34 -5
  82. package/dist/emitter.js.map +1 -1
  83. package/dist/extension_manager.d.ts +2 -2
  84. package/dist/extension_manager.js +6 -3
  85. package/dist/extension_manager.js.map +1 -1
  86. package/dist/lowering.d.ts +5 -14
  87. package/dist/lowering.js +47 -417
  88. package/dist/lowering.js.map +1 -1
  89. package/dist/lowering_channels.d.ts +11 -0
  90. package/dist/lowering_channels.js +102 -0
  91. package/dist/lowering_channels.js.map +1 -0
  92. package/dist/lowering_entities.d.ts +11 -0
  93. package/dist/lowering_entities.js +222 -0
  94. package/dist/lowering_entities.js.map +1 -0
  95. package/dist/lowering_helpers.d.ts +13 -0
  96. package/dist/lowering_helpers.js +76 -0
  97. package/dist/lowering_helpers.js.map +1 -0
  98. package/dist/module_loader.d.ts +2 -2
  99. package/dist/module_loader.js +20 -23
  100. package/dist/module_loader.js.map +1 -1
  101. package/dist/scaffold.d.ts +2 -2
  102. package/dist/scaffold.js +316 -319
  103. package/dist/scaffold.js.map +1 -1
  104. package/dist/typechecker.js +32 -13
  105. package/dist/typechecker.js.map +1 -1
  106. package/dist/verifier.d.ts +5 -0
  107. package/dist/verifier.js +140 -2
  108. package/dist/verifier.js.map +1 -1
  109. package/package.json +62 -52
  110. package/src/algorithm_catalog.ts +345 -345
  111. package/src/ast.ts +334 -334
  112. package/src/cli.ts +98 -624
  113. package/src/commands/check.ts +33 -0
  114. package/src/commands/compile.ts +191 -0
  115. package/src/commands/debug.ts +33 -0
  116. package/src/commands/diff.ts +108 -0
  117. package/src/commands/fmt.ts +22 -0
  118. package/src/commands/init.ts +72 -0
  119. package/src/commands/ir.ts +23 -0
  120. package/src/commands/lex.ts +17 -0
  121. package/src/commands/parse.ts +24 -0
  122. package/src/commands/test.ts +36 -0
  123. package/src/commands/verify_determinism.ts +66 -0
  124. package/src/commands/watch.ts +25 -0
  125. package/src/emit_auth.ts +67 -0
  126. package/src/emit_batch.ts +140 -140
  127. package/src/emit_capability.ts +617 -436
  128. package/src/emit_composition.ts +229 -196
  129. package/src/emit_database.ts +75 -0
  130. package/src/emit_deploy.ts +190 -190
  131. package/src/emit_events.ts +377 -307
  132. package/src/emit_extras.ts +240 -240
  133. package/src/emit_full.ts +351 -309
  134. package/src/emit_index.ts +161 -0
  135. package/src/emit_maintenance.ts +459 -459
  136. package/src/emit_models.ts +176 -0
  137. package/src/emit_openapi.ts +318 -0
  138. package/src/emit_package.ts +69 -0
  139. package/src/emit_router.ts +409 -0
  140. package/src/emit_runtime.ts +17 -728
  141. package/src/emit_sourcemap.ts +140 -140
  142. package/src/emit_tests.ts +246 -205
  143. package/src/emit_websocket.ts +229 -229
  144. package/src/emitter.ts +31 -5
  145. package/src/extension_manager.ts +189 -187
  146. package/src/formatter.ts +297 -297
  147. package/src/index.ts +88 -88
  148. package/src/ir.ts +215 -215
  149. package/src/lexer.ts +630 -630
  150. package/src/lowering.ts +142 -556
  151. package/src/lowering_channels.ts +107 -0
  152. package/src/lowering_entities.ts +248 -0
  153. package/src/lowering_helpers.ts +75 -0
  154. package/src/module_loader.ts +112 -114
  155. package/src/optimizer.ts +196 -196
  156. package/src/parse_decls.ts +409 -409
  157. package/src/parse_decls2.ts +244 -244
  158. package/src/parse_expr.ts +197 -197
  159. package/src/parse_types.ts +54 -54
  160. package/src/parser.ts +1 -1
  161. package/src/parser_base.ts +57 -57
  162. package/src/parser_recovery.ts +153 -153
  163. package/src/scaffold.ts +372 -375
  164. package/src/solver.ts +330 -330
  165. package/src/typechecker.ts +30 -15
  166. package/src/types.ts +122 -122
  167. package/src/verifier.ts +151 -4
@@ -3,48 +3,46 @@
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).
6
19
  */
7
20
  Object.defineProperty(exports, "__esModule", { value: true });
8
21
  exports.emitCapabilityBody = void 0;
9
22
  function parseExprStr(s) {
10
23
  s = s.trim();
11
- // Strip outer parens
12
- if (s.startsWith("(") && s.endsWith(")")) {
24
+ if (s.startsWith("(") && s.endsWith(")"))
13
25
  return parseExprStr(s.slice(1, -1));
14
- }
15
- // String literal
16
- if (s.startsWith('"') && s.endsWith('"')) {
26
+ if (s.startsWith('"') && s.endsWith('"'))
17
27
  return { kind: "literal", value: s.slice(1, -1), raw: s };
18
- }
19
- // Number literal
20
- if (/^-?\d+(\.\d+)?$/.test(s)) {
28
+ if (/^-?\d+(\.\d+)?$/.test(s))
21
29
  return { kind: "literal", value: s, raw: s };
22
- }
23
- // Boolean
24
- if (s === "true" || s === "false") {
30
+ if (s === "true" || s === "false")
25
31
  return { kind: "literal", value: s, raw: s };
26
- }
27
- // Binary operators (check in precedence order, right-to-left to handle left-assoc)
28
32
  const binOps = [" or ", " and ", " == ", " != ", " >= ", " <= ", " > ", " < ", " in ", " contains ", " + ", " - ", " * ", " / "];
29
33
  for (const op of binOps) {
30
34
  const idx = findBinOp(s, op);
31
35
  if (idx !== -1) {
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 };
36
+ return { kind: "binop", op: op.trim(), left: parseExprStr(s.slice(0, idx)), right: parseExprStr(s.slice(idx + op.length)) };
35
37
  }
36
38
  }
37
- // Function call: name(args)
38
39
  const callMatch = s.match(/^(\w+)\((.*)?\)$/);
39
40
  if (callMatch) {
40
41
  const args = callMatch[2] ? splitArgs(callMatch[2]).map(parseExprStr) : [];
41
42
  return { kind: "call", name: callMatch[1], args };
42
43
  }
43
- // Field reference: a.b.c
44
- if (/^[\w.]+$/.test(s)) {
44
+ if (/^[\w.]+$/.test(s))
45
45
  return { kind: "field", path: s.split(".") };
46
- }
47
- // Fallback: treat as opaque literal
48
46
  return { kind: "literal", value: s, raw: s };
49
47
  }
50
48
  function findBinOp(s, op) {
@@ -55,9 +53,8 @@ function findBinOp(s, op) {
55
53
  depth++;
56
54
  else if (ch === ")" || ch === "]")
57
55
  depth--;
58
- else if (depth === 0 && s.slice(i, i + op.length) === op) {
56
+ else if (depth === 0 && s.slice(i, i + op.length) === op)
59
57
  return i;
60
- }
61
58
  }
62
59
  return -1;
63
60
  }
@@ -87,8 +84,7 @@ function toSnakeCase(s) {
87
84
  function getEntityFetches(method, mod, system) {
88
85
  const fetches = [];
89
86
  const seen = new Set();
90
- // Build a map of all entity names → table names across the whole system
91
- const allModels = new Map(); // entityName → tableName
87
+ const allModels = new Map();
92
88
  for (const m of system.modules) {
93
89
  for (const model of m.models) {
94
90
  allModels.set(model.name, toSnakeCase(model.name) + "s");
@@ -99,16 +95,12 @@ function getEntityFetches(method, mod, system) {
99
95
  const tableName = allModels.get(param.type) || allModels.get(param.type.toLowerCase());
100
96
  if (tableName && !seen.has(param.name)) {
101
97
  seen.add(param.name);
102
- fetches.push({
103
- paramName: param.name,
104
- entityType: param.type,
105
- tableName,
106
- idField: param.name + "_id",
107
- });
98
+ fetches.push({ paramName: param.name, entityType: param.type, tableName, idField: param.name + "_id" });
108
99
  }
109
100
  }
110
101
  return fetches;
111
102
  }
103
+ // ─── Precondition Compiler ────────────────────────────────────────────────────
112
104
  function compilePrecondition(expr, indent) {
113
105
  const condition = exprToTs(expr, true);
114
106
  const description = exprToDescription(expr).replace(/"/g, '\\"');
@@ -133,7 +125,6 @@ function exprToTsInner(expr) {
133
125
  return expr.raw;
134
126
  return expr.value;
135
127
  case "field":
136
- // Convert field path to JS property access
137
128
  return expr.path.join("?.");
138
129
  case "binop": {
139
130
  const l = exprToTsInner(expr.left);
@@ -145,15 +136,6 @@ function exprToTsInner(expr) {
145
136
  case "or": return `(${l} || ${r})`;
146
137
  case "in": return `[${r}].flat().includes(${l})`;
147
138
  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}`;
157
139
  default: return `${l} ${expr.op} ${r}`;
158
140
  }
159
141
  }
@@ -167,137 +149,271 @@ function exprToDescription(expr) {
167
149
  switch (expr.kind) {
168
150
  case "literal": return expr.raw;
169
151
  case "field": return expr.path.join(".");
170
- case "binop": {
171
- const l = exprToDescription(expr.left);
172
- const r = exprToDescription(expr.right);
173
- return `${l} ${expr.op} ${r}`;
174
- }
152
+ case "binop": return `${exprToDescription(expr.left)} ${expr.op} ${exprToDescription(expr.right)}`;
175
153
  case "call": return `${expr.name}(${expr.args.map(exprToDescription).join(", ")})`;
176
154
  }
177
155
  }
178
- function compileEffect(effect, mod, system, paramIdx) {
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) {
179
161
  const targetParts = effect.target.split(".");
180
162
  if (targetParts.length < 2)
181
163
  return null;
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
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.
186
169
  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
187
182
  for (const m of system.modules) {
188
- const found = m.models.find(mdl => toSnakeCase(mdl.name) === entityParam ||
189
- mdl.name.toLowerCase() === entityParam.toLowerCase());
183
+ const found = m.models.find(mdl => toSnakeCase(mdl.name) === entityParam || mdl.name.toLowerCase() === entityParam.toLowerCase());
190
184
  if (found)
191
185
  return found;
192
186
  }
193
- return mod.models.find(m => toSnakeCase(m.name) === entityParam ||
194
- m.name.toLowerCase() === entityParam.toLowerCase());
187
+ return mod.models.find(m => toSnakeCase(m.name) === entityParam || m.name.toLowerCase() === entityParam.toLowerCase());
195
188
  })();
196
189
  if (!model)
197
190
  return null;
198
191
  const tableName = toSnakeCase(model.name) + "s";
199
- const valueExpr = parseExprStr(effect.value);
200
- const valueTs = exprToTsInner(valueExpr);
192
+ const valueTs = exprToTsInner(parseExprStr(effect.value));
201
193
  const idParam = `req.body.${entityParam}_id || req.params.id`;
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
194
+ // JSONB nested path must be standalone (jsonb_set can't be batched cleanly)
210
195
  if (nestedPath.length > 0) {
211
- const jsonbField = fieldName;
212
- const jsonbPath = nestedPath.join(".");
213
196
  const p1 = `$${paramIdx.n++}`;
214
197
  const p2 = `$${paramIdx.n++}`;
215
- // Use jsonb_set to update nested path
216
198
  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.
217
202
  return {
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],
203
+ tableName, entityParam, idParam,
204
+ assignments: [],
220
205
  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
+ },
221
210
  };
222
211
  }
212
+ const fieldType = model.fields.find(f => f.name === fieldName)?.type || "";
213
+ const isNumeric = ["uint", "int", "float"].includes(fieldType);
223
214
  switch (effect.op) {
224
215
  case "assign": {
225
216
  const p1 = `$${paramIdx.n++}`;
226
- const p2 = `$${paramIdx.n++}`;
227
217
  return {
228
- sql: `UPDATE ${tableName} SET ${fieldName} = ${p1}, updated_at = NOW() WHERE id = ${p2} RETURNING *`,
229
- params: [valueTs, idParam],
218
+ tableName, entityParam, idParam,
219
+ assignments: [{ column: fieldName, placeholder: p1, tsValue: valueTs }],
230
220
  description: `${effect.target} = ${effect.value}`,
231
221
  };
232
222
  }
233
223
  case "add": {
234
224
  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);
238
225
  if (isNumeric) {
239
226
  return {
240
- sql: `UPDATE ${tableName} SET ${fieldName} = ${fieldName} + ${p1}, updated_at = NOW() WHERE id = ${p2} RETURNING *`,
241
- params: [valueTs, idParam],
227
+ tableName, entityParam, idParam,
228
+ assignments: [{ column: `${fieldName} = ${fieldName} + `, placeholder: p1, tsValue: valueTs }],
242
229
  description: `${effect.target} += ${effect.value}`,
243
230
  };
244
231
  }
245
- else {
246
- return {
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: {
247
239
  sql: `UPDATE ${tableName} SET ${fieldName} = ${fieldName} || jsonb_build_array(${p1}::jsonb), updated_at = NOW() WHERE id = ${p2} RETURNING *`,
248
240
  params: [valueTs, idParam],
249
- description: `${effect.target} += ${effect.value}`,
250
- };
251
- }
241
+ },
242
+ };
252
243
  }
253
244
  case "remove": {
254
245
  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);
258
246
  if (isNumeric) {
259
247
  return {
260
- sql: `UPDATE ${tableName} SET ${fieldName} = ${fieldName} - ${p1}, updated_at = NOW() WHERE id = ${p2} RETURNING *`,
261
- params: [valueTs, idParam],
248
+ tableName, entityParam, idParam,
249
+ assignments: [{ column: `${fieldName} = ${fieldName} - `, placeholder: p1, tsValue: valueTs }],
262
250
  description: `${effect.target} -= ${effect.value}`,
263
251
  };
264
252
  }
265
- else {
266
- return {
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: {
267
260
  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 *`,
268
261
  params: [valueTs, idParam],
269
- description: `${effect.target} -= ${effect.value}`,
270
- };
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++;
271
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
+ });
272
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}`);
312
+ }
313
+ batch.paramValues.push(tsValue);
314
+ globalParamN++;
315
+ }
316
+ batch.descriptions.push(effect.description);
273
317
  }
318
+ return { batches: Array.from(batches.values()), standalones };
274
319
  }
275
320
  // ─── Main Capability Body Emitter ─────────────────────────────────────────────
276
321
  function emitCapabilityBody(method, mod, system, indent = " ") {
277
322
  const lines = [];
278
323
  const fetches = getEntityFetches(method, mod, system);
279
- // 0. Destructure primitive params from req.body
324
+ // 0. Destructure primitive params
280
325
  const primitiveParams = method.input.filter(p => {
281
326
  const isPrimitive = ["string", "uint", "int", "float", "bool", "timestamp", "uuid", "bytes", "json"].includes(p.type);
282
327
  const isListOrSet = p.type.startsWith("list<") || p.type.startsWith("set<");
283
- const isEntityFetch = fetches.some(f => f.paramName === p.name);
284
- return (isPrimitive || isListOrSet) && !isEntityFetch;
328
+ return (isPrimitive || isListOrSet) && !fetches.some(f => f.paramName === p.name);
285
329
  });
286
330
  if (primitiveParams.length > 0) {
287
- const destructured = primitiveParams.map(p => p.name).join(", ");
288
- lines.push(`${indent}const { ${destructured} } = req.body;`);
331
+ lines.push(`${indent}const { ${primitiveParams.map(p => p.name).join(", ")} } = req.body;`);
289
332
  lines.push(``);
290
333
  }
291
- // 1. Fetch entities referenced in preconditions/effects
334
+ // 1. Fetch entities batch same-table fetches, parallelize different-table fetches
292
335
  if (fetches.length > 0) {
293
336
  lines.push(`${indent}// Fetch entities`);
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" } });`);
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" } });`);
299
352
  lines.push(`${indent}}`);
300
353
  }
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
+ }
301
417
  lines.push(``);
302
418
  }
303
419
  // 2. Precondition checks
@@ -305,36 +421,87 @@ function emitCapabilityBody(method, mod, system, indent = " ") {
305
421
  lines.push(`${indent}// Preconditions`);
306
422
  for (const pre of method.preconditions) {
307
423
  try {
308
- const expr = parseExprStr(pre.expression);
309
- lines.push(compilePrecondition(expr, indent));
424
+ lines.push(compilePrecondition(parseExprStr(pre.expression), indent));
310
425
  }
311
426
  catch {
312
- // Fallback: emit as comment if parsing fails
313
427
  lines.push(`${indent}// CHECK: ${pre.description}`);
314
428
  }
315
429
  }
316
430
  lines.push(``);
317
431
  }
318
- // 3. Effects (applied in declaration order, each in its own query)
432
+ // 3. Effects batch same-entity updates into single UPDATEs
319
433
  if (method.effects.length > 0) {
320
- lines.push(`${indent}// Effects (applied in declaration order)`);
321
- const effectResults = [];
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.
322
466
  for (const effect of method.effects) {
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`);
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
+ }
338
505
  }
339
506
  }
340
507
  lines.push(``);
@@ -365,10 +532,7 @@ function emitCapabilityBody(method, mod, system, indent = " ") {
365
532
  }
366
533
  exports.emitCapabilityBody = emitCapabilityBody;
367
534
  function buildEventPayload(method, fetches) {
368
- const fields = [];
369
- for (const fetch of fetches) {
370
- fields.push(`${fetch.paramName}_id: ${fetch.paramName}?.id`);
371
- }
535
+ const fields = fetches.map(f => `${f.paramName}_id: ${f.paramName}?.id`);
372
536
  fields.push(`timestamp: new Date().toISOString()`);
373
537
  fields.push(`actor_id: auth.actor_id`);
374
538
  return `{ ${fields.join(", ")} }`;