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