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.
- package/LICENSE +21 -21
- package/dist/algorithm_catalog.js +166 -166
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +75 -543
- package/dist/cli.js.map +1 -1
- package/dist/commands/check.d.ts +5 -0
- package/dist/commands/check.js +34 -0
- package/dist/commands/check.js.map +1 -0
- package/dist/commands/compile.d.ts +5 -0
- package/dist/commands/compile.js +215 -0
- package/dist/commands/compile.js.map +1 -0
- package/dist/commands/debug.d.ts +5 -0
- package/dist/commands/debug.js +59 -0
- package/dist/commands/debug.js.map +1 -0
- package/dist/commands/diff.d.ts +5 -0
- package/dist/commands/diff.js +125 -0
- package/dist/commands/diff.js.map +1 -0
- package/dist/commands/fmt.d.ts +5 -0
- package/dist/commands/fmt.js +49 -0
- package/dist/commands/fmt.js.map +1 -0
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.js +96 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/ir.d.ts +5 -0
- package/dist/commands/ir.js +27 -0
- package/dist/commands/ir.js.map +1 -0
- package/dist/commands/lex.d.ts +5 -0
- package/dist/commands/lex.js +21 -0
- package/dist/commands/lex.js.map +1 -0
- package/dist/commands/parse.d.ts +5 -0
- package/dist/commands/parse.js +30 -0
- package/dist/commands/parse.js.map +1 -0
- package/dist/commands/test.d.ts +5 -0
- package/dist/commands/test.js +61 -0
- package/dist/commands/test.js.map +1 -0
- package/dist/commands/verify_determinism.d.ts +5 -0
- package/dist/commands/verify_determinism.js +64 -0
- package/dist/commands/verify_determinism.js.map +1 -0
- package/dist/commands/watch.d.ts +5 -0
- package/dist/commands/watch.js +50 -0
- package/dist/commands/watch.js.map +1 -0
- package/dist/emit_auth.d.ts +6 -0
- package/dist/emit_auth.js +69 -0
- package/dist/emit_auth.js.map +1 -0
- package/dist/emit_capability.d.ts +13 -0
- package/dist/emit_capability.js +292 -128
- package/dist/emit_capability.js.map +1 -1
- package/dist/emit_composition.js +37 -3
- package/dist/emit_composition.js.map +1 -1
- package/dist/emit_database.d.ts +7 -0
- package/dist/emit_database.js +74 -0
- package/dist/emit_database.js.map +1 -0
- package/dist/emit_deploy.js +162 -162
- package/dist/emit_events.d.ts +1 -0
- package/dist/emit_events.js +342 -275
- package/dist/emit_events.js.map +1 -1
- package/dist/emit_full.js +135 -95
- package/dist/emit_full.js.map +1 -1
- package/dist/emit_index.d.ts +6 -0
- package/dist/emit_index.js +157 -0
- package/dist/emit_index.js.map +1 -0
- package/dist/emit_maintenance.js +249 -249
- package/dist/emit_models.d.ts +12 -0
- package/dist/emit_models.js +171 -0
- package/dist/emit_models.js.map +1 -0
- package/dist/emit_openapi.d.ts +9 -0
- package/dist/emit_openapi.js +308 -0
- package/dist/emit_openapi.js.map +1 -0
- package/dist/emit_package.d.ts +7 -0
- package/dist/emit_package.js +70 -0
- package/dist/emit_package.js.map +1 -0
- package/dist/emit_router.d.ts +12 -0
- package/dist/emit_router.js +390 -0
- package/dist/emit_router.js.map +1 -0
- package/dist/emit_runtime.d.ts +17 -11
- package/dist/emit_runtime.js +29 -686
- package/dist/emit_runtime.js.map +1 -1
- package/dist/emit_sourcemap.js +66 -66
- package/dist/emit_tests.js +37 -0
- package/dist/emit_tests.js.map +1 -1
- package/dist/emitter.js +34 -5
- package/dist/emitter.js.map +1 -1
- package/dist/extension_manager.d.ts +2 -2
- package/dist/extension_manager.js +6 -3
- package/dist/extension_manager.js.map +1 -1
- package/dist/lowering.d.ts +5 -14
- package/dist/lowering.js +47 -417
- package/dist/lowering.js.map +1 -1
- package/dist/lowering_channels.d.ts +11 -0
- package/dist/lowering_channels.js +102 -0
- package/dist/lowering_channels.js.map +1 -0
- package/dist/lowering_entities.d.ts +11 -0
- package/dist/lowering_entities.js +222 -0
- package/dist/lowering_entities.js.map +1 -0
- package/dist/lowering_helpers.d.ts +13 -0
- package/dist/lowering_helpers.js +76 -0
- package/dist/lowering_helpers.js.map +1 -0
- package/dist/module_loader.d.ts +2 -2
- package/dist/module_loader.js +20 -23
- package/dist/module_loader.js.map +1 -1
- package/dist/scaffold.d.ts +2 -2
- package/dist/scaffold.js +316 -319
- package/dist/scaffold.js.map +1 -1
- package/dist/typechecker.js +32 -13
- package/dist/typechecker.js.map +1 -1
- package/dist/verifier.d.ts +5 -0
- package/dist/verifier.js +140 -2
- package/dist/verifier.js.map +1 -1
- package/package.json +62 -52
- package/src/algorithm_catalog.ts +345 -345
- package/src/ast.ts +334 -334
- package/src/cli.ts +98 -624
- package/src/commands/check.ts +33 -0
- package/src/commands/compile.ts +191 -0
- package/src/commands/debug.ts +33 -0
- package/src/commands/diff.ts +108 -0
- package/src/commands/fmt.ts +22 -0
- package/src/commands/init.ts +72 -0
- package/src/commands/ir.ts +23 -0
- package/src/commands/lex.ts +17 -0
- package/src/commands/parse.ts +24 -0
- package/src/commands/test.ts +36 -0
- package/src/commands/verify_determinism.ts +66 -0
- package/src/commands/watch.ts +25 -0
- package/src/emit_auth.ts +67 -0
- package/src/emit_batch.ts +140 -140
- package/src/emit_capability.ts +617 -436
- package/src/emit_composition.ts +229 -196
- package/src/emit_database.ts +75 -0
- package/src/emit_deploy.ts +190 -190
- package/src/emit_events.ts +377 -307
- package/src/emit_extras.ts +240 -240
- package/src/emit_full.ts +351 -309
- package/src/emit_index.ts +161 -0
- package/src/emit_maintenance.ts +459 -459
- package/src/emit_models.ts +176 -0
- package/src/emit_openapi.ts +318 -0
- package/src/emit_package.ts +69 -0
- package/src/emit_router.ts +409 -0
- package/src/emit_runtime.ts +17 -728
- package/src/emit_sourcemap.ts +140 -140
- package/src/emit_tests.ts +246 -205
- package/src/emit_websocket.ts +229 -229
- package/src/emitter.ts +31 -5
- package/src/extension_manager.ts +189 -187
- package/src/formatter.ts +297 -297
- package/src/index.ts +88 -88
- package/src/ir.ts +215 -215
- package/src/lexer.ts +630 -630
- package/src/lowering.ts +142 -556
- package/src/lowering_channels.ts +107 -0
- package/src/lowering_entities.ts +248 -0
- package/src/lowering_helpers.ts +75 -0
- package/src/module_loader.ts +112 -114
- package/src/optimizer.ts +196 -196
- package/src/parse_decls.ts +409 -409
- package/src/parse_decls2.ts +244 -244
- package/src/parse_expr.ts +197 -197
- package/src/parse_types.ts +54 -54
- package/src/parser.ts +1 -1
- package/src/parser_base.ts +57 -57
- package/src/parser_recovery.ts +153 -153
- package/src/scaffold.ts +372 -375
- package/src/solver.ts +330 -330
- package/src/typechecker.ts +30 -15
- package/src/types.ts +122 -122
- package/src/verifier.ts +151 -4
package/dist/emit_capability.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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];
|
|
183
|
-
const fieldName = targetParts[1];
|
|
184
|
-
const nestedPath = targetParts.slice(2);
|
|
185
|
-
//
|
|
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
|
|
200
|
-
const valueTs = exprToTsInner(valueExpr);
|
|
192
|
+
const valueTs = exprToTsInner(parseExprStr(effect.value));
|
|
201
193
|
const idParam = `req.body.${entityParam}_id || req.params.id`;
|
|
202
|
-
//
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
|
432
|
+
// 3. Effects — batch same-entity updates into single UPDATEs
|
|
319
433
|
if (method.effects.length > 0) {
|
|
320
|
-
lines.push(`${indent}// Effects (
|
|
321
|
-
const
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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(", ")} }`;
|