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.
- package/LICENSE +21 -21
- package/dist/algorithm_catalog.js +166 -166
- package/dist/cli.d.ts +1 -2
- package/dist/cli.js +617 -75
- package/dist/cli.js.map +1 -1
- package/dist/emit_capability.d.ts +0 -13
- package/dist/emit_capability.js +134 -296
- 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 +167 -165
- package/dist/emit_deploy.js.map +1 -1
- package/dist/emit_events.d.ts +0 -1
- package/dist/emit_events.js +275 -325
- package/dist/emit_events.js.map +1 -1
- package/dist/emit_extras.js +5 -3
- package/dist/emit_extras.js.map +1 -1
- package/dist/emit_full.js +112 -272
- package/dist/emit_full.js.map +1 -1
- package/dist/emit_maintenance.js +249 -249
- package/dist/emit_nakama.d.ts +23 -0
- package/dist/emit_nakama.js +510 -0
- package/dist/emit_nakama.js.map +1 -0
- 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 +12 -47
- package/dist/emit_tests.js.map +1 -1
- package/dist/emit_websocket.js +3 -0
- package/dist/emit_websocket.js.map +1 -1
- package/dist/emitter.js +49 -94
- package/dist/emitter.js.map +1 -1
- package/dist/extension_manager.d.ts +2 -2
- package/dist/extension_manager.js +20 -9
- package/dist/extension_manager.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -1
- package/dist/index.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 +3 -6
- 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/solver.js +1 -1
- package/dist/solver.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 -7
- package/dist/typechecker.js +16 -103
- package/dist/typechecker.js.map +1 -1
- package/dist/verifier.d.ts +1 -5
- package/dist/verifier.js +38 -142
- package/dist/verifier.js.map +1 -1
- package/package.json +53 -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 +704 -98
- package/src/emit_batch.ts +140 -140
- package/src/emit_capability.ts +436 -613
- package/src/emit_composition.ts +196 -229
- package/src/emit_deploy.ts +190 -187
- package/src/emit_events.ts +307 -362
- package/src/emit_extras.ts +240 -237
- package/src/emit_full.ts +309 -472
- package/src/emit_maintenance.ts +459 -459
- package/src/emit_nakama.ts +576 -0
- package/src/emit_runtime.ts +730 -17
- package/src/emit_sourcemap.ts +140 -140
- package/src/emit_tests.ts +205 -243
- package/src/emit_websocket.ts +229 -226
- package/src/emitter.ts +578 -626
- package/src/extension_manager.ts +187 -177
- package/src/formatter.ts +297 -297
- package/src/index.ts +90 -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 -199
- 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 -700
- package/src/types.d.ts +38 -0
- package/src/types.ts +122 -122
- package/src/verifier.ts +49 -154
- 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 -123
- 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 -72
- 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 -306
- package/dist/emit_openapi.js.map +0 -1
- package/dist/emit_package.d.ts +0 -7
- package/dist/emit_package.js +0 -68
- package/dist/emit_package.js.map +0 -1
- package/dist/emit_router.d.ts +0 -12
- package/dist/emit_router.js +0 -389
- 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 -105
- 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 -72
- package/src/emit_index.ts +0 -210
- package/src/emit_models.ts +0 -176
- package/src/emit_openapi.ts +0 -315
- package/src/emit_package.ts +0 -66
- package/src/emit_router.ts +0 -408
- 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,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
|
-
|
|
11
|
+
// Strip outer parens
|
|
12
|
+
if (s.startsWith("(") && s.endsWith(")")) {
|
|
26
13
|
return parseExprStr(s.slice(1, -1));
|
|
27
|
-
|
|
14
|
+
}
|
|
15
|
+
// String literal
|
|
16
|
+
if (s.startsWith('"') && s.endsWith('"')) {
|
|
28
17
|
return { kind: "literal", value: s.slice(1, -1), raw: s };
|
|
29
|
-
|
|
18
|
+
}
|
|
19
|
+
// Number literal
|
|
20
|
+
if (/^-?\d+(\.\d+)?$/.test(s)) {
|
|
30
21
|
return { kind: "literal", value: s, raw: s };
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
89
|
-
allModels.set(model.name.toLowerCase(),
|
|
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({
|
|
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":
|
|
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
|
-
//
|
|
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 =>
|
|
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 =>
|
|
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 =
|
|
190
|
-
const
|
|
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
|
-
//
|
|
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,
|
|
202
|
-
|
|
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
|
|
217
|
-
|
|
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
|
|
226
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
|
247
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
283
|
+
const isEntityFetch = fetches.some(f => f.paramName === p.name);
|
|
284
|
+
return (isPrimitive || isListOrSet) && !isEntityFetch;
|
|
327
285
|
});
|
|
328
286
|
if (primitiveParams.length > 0) {
|
|
329
|
-
|
|
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
|
|
291
|
+
// 1. Fetch entities referenced in preconditions/effects
|
|
333
292
|
if (fetches.length > 0) {
|
|
334
293
|
lines.push(`${indent}// Fetch entities`);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if (
|
|
339
|
-
|
|
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
|
-
|
|
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
|
|
318
|
+
// 3. Effects (applied in declaration order, each in its own query)
|
|
431
319
|
if (method.effects.length > 0) {
|
|
432
|
-
lines.push(`${indent}// Effects (
|
|
433
|
-
const
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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 =
|
|
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(", ")} }`;
|