bonescript-compiler 0.5.3 → 0.5.5

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