bonescript-compiler 0.2.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 -0
- package/dist/algorithm_catalog.d.ts +32 -0
- package/dist/algorithm_catalog.js +323 -0
- package/dist/algorithm_catalog.js.map +1 -0
- package/dist/ast.d.ts +244 -0
- package/dist/ast.js +8 -0
- package/dist/ast.js.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +605 -0
- package/dist/cli.js.map +1 -0
- package/dist/emit_batch.d.ts +7 -0
- package/dist/emit_batch.js +133 -0
- package/dist/emit_batch.js.map +1 -0
- package/dist/emit_capability.d.ts +7 -0
- package/dist/emit_capability.js +376 -0
- package/dist/emit_capability.js.map +1 -0
- package/dist/emit_composition.d.ts +22 -0
- package/dist/emit_composition.js +184 -0
- package/dist/emit_composition.js.map +1 -0
- package/dist/emit_deploy.d.ts +9 -0
- package/dist/emit_deploy.js +191 -0
- package/dist/emit_deploy.js.map +1 -0
- package/dist/emit_events.d.ts +14 -0
- package/dist/emit_events.js +305 -0
- package/dist/emit_events.js.map +1 -0
- package/dist/emit_extras.d.ts +12 -0
- package/dist/emit_extras.js +234 -0
- package/dist/emit_extras.js.map +1 -0
- package/dist/emit_full.d.ts +13 -0
- package/dist/emit_full.js +273 -0
- package/dist/emit_full.js.map +1 -0
- package/dist/emit_maintenance.d.ts +16 -0
- package/dist/emit_maintenance.js +442 -0
- package/dist/emit_maintenance.js.map +1 -0
- package/dist/emit_runtime.d.ts +13 -0
- package/dist/emit_runtime.js +691 -0
- package/dist/emit_runtime.js.map +1 -0
- package/dist/emit_sourcemap.d.ts +29 -0
- package/dist/emit_sourcemap.js +123 -0
- package/dist/emit_sourcemap.js.map +1 -0
- package/dist/emit_tests.d.ts +15 -0
- package/dist/emit_tests.js +185 -0
- package/dist/emit_tests.js.map +1 -0
- package/dist/emit_websocket.d.ts +6 -0
- package/dist/emit_websocket.js +223 -0
- package/dist/emit_websocket.js.map +1 -0
- package/dist/emitter.d.ts +25 -0
- package/dist/emitter.js +511 -0
- package/dist/emitter.js.map +1 -0
- package/dist/extension_manager.d.ts +38 -0
- package/dist/extension_manager.js +170 -0
- package/dist/extension_manager.js.map +1 -0
- package/dist/formatter.d.ts +34 -0
- package/dist/formatter.js +317 -0
- package/dist/formatter.js.map +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +113 -0
- package/dist/index.js.map +1 -0
- package/dist/ir.d.ts +168 -0
- package/dist/ir.js +10 -0
- package/dist/ir.js.map +1 -0
- package/dist/lexer.d.ts +195 -0
- package/dist/lexer.js +619 -0
- package/dist/lexer.js.map +1 -0
- package/dist/lowering.d.ts +25 -0
- package/dist/lowering.js +500 -0
- package/dist/lowering.js.map +1 -0
- package/dist/module_loader.d.ts +25 -0
- package/dist/module_loader.js +126 -0
- package/dist/module_loader.js.map +1 -0
- package/dist/optimizer.d.ts +26 -0
- package/dist/optimizer.js +158 -0
- package/dist/optimizer.js.map +1 -0
- package/dist/parse_decls.d.ts +13 -0
- package/dist/parse_decls.js +442 -0
- package/dist/parse_decls.js.map +1 -0
- package/dist/parse_decls2.d.ts +13 -0
- package/dist/parse_decls2.js +295 -0
- package/dist/parse_decls2.js.map +1 -0
- package/dist/parse_expr.d.ts +7 -0
- package/dist/parse_expr.js +197 -0
- package/dist/parse_expr.js.map +1 -0
- package/dist/parse_types.d.ts +6 -0
- package/dist/parse_types.js +51 -0
- package/dist/parse_types.js.map +1 -0
- package/dist/parser.d.ts +10 -0
- package/dist/parser.js +62 -0
- package/dist/parser.js.map +1 -0
- package/dist/parser_base.d.ts +19 -0
- package/dist/parser_base.js +50 -0
- package/dist/parser_base.js.map +1 -0
- package/dist/parser_recovery.d.ts +26 -0
- package/dist/parser_recovery.js +140 -0
- package/dist/parser_recovery.js.map +1 -0
- package/dist/scaffold.d.ts +13 -0
- package/dist/scaffold.js +376 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/solver.d.ts +26 -0
- package/dist/solver.js +281 -0
- package/dist/solver.js.map +1 -0
- package/dist/typechecker.d.ts +52 -0
- package/dist/typechecker.js +534 -0
- package/dist/typechecker.js.map +1 -0
- package/dist/types.d.ts +38 -0
- package/dist/types.js +85 -0
- package/dist/types.js.map +1 -0
- package/dist/verifier.d.ts +46 -0
- package/dist/verifier.js +307 -0
- package/dist/verifier.js.map +1 -0
- package/package.json +52 -0
- package/src/algorithm_catalog.ts +345 -0
- package/src/ast.ts +334 -0
- package/src/cli.ts +624 -0
- package/src/emit_batch.ts +140 -0
- package/src/emit_capability.ts +436 -0
- package/src/emit_composition.ts +196 -0
- package/src/emit_deploy.ts +190 -0
- package/src/emit_events.ts +307 -0
- package/src/emit_extras.ts +240 -0
- package/src/emit_full.ts +309 -0
- package/src/emit_maintenance.ts +459 -0
- package/src/emit_runtime.ts +731 -0
- package/src/emit_sourcemap.ts +140 -0
- package/src/emit_tests.ts +205 -0
- package/src/emit_websocket.ts +229 -0
- package/src/emitter.ts +566 -0
- package/src/extension_manager.ts +187 -0
- package/src/formatter.ts +297 -0
- package/src/index.ts +88 -0
- package/src/ir.ts +215 -0
- package/src/lexer.ts +630 -0
- package/src/lowering.ts +556 -0
- package/src/module_loader.ts +114 -0
- package/src/optimizer.ts +196 -0
- package/src/parse_decls.ts +409 -0
- package/src/parse_decls2.ts +244 -0
- package/src/parse_expr.ts +197 -0
- package/src/parse_types.ts +54 -0
- package/src/parser.ts +64 -0
- package/src/parser_base.ts +57 -0
- package/src/parser_recovery.ts +153 -0
- package/src/scaffold.ts +375 -0
- package/src/solver.ts +330 -0
- package/src/typechecker.ts +591 -0
- package/src/types.ts +122 -0
- package/src/verifier.ts +348 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BoneScript Source Map Emitter
|
|
3
|
+
* Generates a .bone.map file that maps generated TS line numbers back to .bone source.
|
|
4
|
+
* Also generates a debug wrapper that intercepts runtime errors and annotates them.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as IR from "./ir";
|
|
8
|
+
|
|
9
|
+
export interface BoneSourceMap {
|
|
10
|
+
version: 1;
|
|
11
|
+
source_hash: string;
|
|
12
|
+
source_file: string;
|
|
13
|
+
mappings: BoneMapping[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BoneMapping {
|
|
17
|
+
generated_file: string;
|
|
18
|
+
generated_line: number;
|
|
19
|
+
bone_line: number;
|
|
20
|
+
bone_column: number;
|
|
21
|
+
description: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Emit a source map JSON file for the compiled system.
|
|
26
|
+
* Maps IR nodes to their approximate .bone source locations.
|
|
27
|
+
*/
|
|
28
|
+
export function emitSourceMapFile(system: IR.IRSystem, sourceFile: string): string {
|
|
29
|
+
const mappings: BoneMapping[] = [];
|
|
30
|
+
|
|
31
|
+
// Map each module to a rough line estimate
|
|
32
|
+
// (Real line numbers would require storing loc in IR — this is a best-effort approximation)
|
|
33
|
+
let estimatedLine = 1;
|
|
34
|
+
for (const mod of system.modules) {
|
|
35
|
+
mappings.push({
|
|
36
|
+
generated_file: `src/routes/${mod.name.replace(/Service$/, "").toLowerCase()}.ts`,
|
|
37
|
+
generated_line: 1,
|
|
38
|
+
bone_line: estimatedLine,
|
|
39
|
+
bone_column: 1,
|
|
40
|
+
description: `Module: ${mod.name}`,
|
|
41
|
+
});
|
|
42
|
+
estimatedLine += 10;
|
|
43
|
+
|
|
44
|
+
for (const iface of mod.interfaces) {
|
|
45
|
+
for (const method of iface.methods) {
|
|
46
|
+
mappings.push({
|
|
47
|
+
generated_file: `src/routes/${mod.name.replace(/Service$/, "").toLowerCase()}.ts`,
|
|
48
|
+
generated_line: estimatedLine,
|
|
49
|
+
bone_line: estimatedLine,
|
|
50
|
+
bone_column: 3,
|
|
51
|
+
description: `Capability: ${method.name}`,
|
|
52
|
+
});
|
|
53
|
+
estimatedLine += 5;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const map: BoneSourceMap = {
|
|
59
|
+
version: 1,
|
|
60
|
+
source_hash: system.source_hash,
|
|
61
|
+
source_file: sourceFile,
|
|
62
|
+
mappings,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return JSON.stringify(map, null, 2);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Emit a debug error handler that annotates runtime errors with .bone context.
|
|
70
|
+
* Reads the source map at runtime to provide dual-stack traces.
|
|
71
|
+
*/
|
|
72
|
+
export function emitDebugHandler(system: IR.IRSystem): string {
|
|
73
|
+
return `// Generated by BoneScript compiler. DO NOT EDIT.
|
|
74
|
+
// Runtime error handler with .bone source annotation.
|
|
75
|
+
|
|
76
|
+
import * as fs from "fs";
|
|
77
|
+
import * as path from "path";
|
|
78
|
+
|
|
79
|
+
interface BoneMapping {
|
|
80
|
+
generated_file: string;
|
|
81
|
+
generated_line: number;
|
|
82
|
+
bone_line: number;
|
|
83
|
+
bone_column: number;
|
|
84
|
+
description: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface BoneSourceMap {
|
|
88
|
+
version: number;
|
|
89
|
+
source_hash: string;
|
|
90
|
+
source_file: string;
|
|
91
|
+
mappings: BoneMapping[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let sourceMap: BoneSourceMap | null = null;
|
|
95
|
+
|
|
96
|
+
function loadSourceMap(): BoneSourceMap | null {
|
|
97
|
+
if (sourceMap) return sourceMap;
|
|
98
|
+
const mapPath = path.join(__dirname, "..", "${system.name}.bone.map");
|
|
99
|
+
if (!fs.existsSync(mapPath)) return null;
|
|
100
|
+
try {
|
|
101
|
+
sourceMap = JSON.parse(fs.readFileSync(mapPath, "utf-8"));
|
|
102
|
+
return sourceMap;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function annotateBoneError(err: Error, generatedFile: string): string {
|
|
109
|
+
const map = loadSourceMap();
|
|
110
|
+
if (!map) return err.stack || err.message;
|
|
111
|
+
|
|
112
|
+
// Extract line number from stack trace
|
|
113
|
+
const lineMatch = err.stack?.match(new RegExp(generatedFile.replace(/[/\\\\]/g, "[/\\\\\\\\]") + ":(\\\\d+)"));
|
|
114
|
+
if (!lineMatch) return err.stack || err.message;
|
|
115
|
+
|
|
116
|
+
const generatedLine = parseInt(lineMatch[1], 10);
|
|
117
|
+
const mapping = map.mappings
|
|
118
|
+
.filter(m => m.generated_file.includes(generatedFile) && m.generated_line <= generatedLine)
|
|
119
|
+
.sort((a, b) => b.generated_line - a.generated_line)[0];
|
|
120
|
+
|
|
121
|
+
if (!mapping) return err.stack || err.message;
|
|
122
|
+
|
|
123
|
+
return [
|
|
124
|
+
\`BoneScript source: \${map.source_file}:\${mapping.bone_line}:\${mapping.bone_column}\`,
|
|
125
|
+
\` Context: \${mapping.description}\`,
|
|
126
|
+
\`Generated TS: \${generatedFile}:\${generatedLine}\`,
|
|
127
|
+
\`\`,
|
|
128
|
+
err.stack || err.message,
|
|
129
|
+
].join("\\n");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Global error handler — annotates unhandled errors with .bone context
|
|
133
|
+
process.on("uncaughtException", (err: Error) => {
|
|
134
|
+
const annotated = annotateBoneError(err, err.stack?.split("\\n")[1]?.match(/\\((.+):\\d+:\\d+\\)/)?.[1] || "");
|
|
135
|
+
console.error("[BoneScript] Unhandled error:");
|
|
136
|
+
console.error(annotated);
|
|
137
|
+
// process.exit(1); // disabled - errors should not crash the server
|
|
138
|
+
});
|
|
139
|
+
`;
|
|
140
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BoneScript Test Emitter
|
|
3
|
+
* Generates regression tests derived from capability declarations.
|
|
4
|
+
* Implements spec/10 §7 (Regression Tests).
|
|
5
|
+
*
|
|
6
|
+
* For each capability, generates:
|
|
7
|
+
* - Happy path test (valid preconditions → effects applied)
|
|
8
|
+
* - Precondition failure test (invalid state → 422)
|
|
9
|
+
* - Idempotency test (if idempotent: true)
|
|
10
|
+
* For each state machine:
|
|
11
|
+
* - Valid transition tests
|
|
12
|
+
* - Invalid transition rejection tests
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as IR from "./ir";
|
|
16
|
+
|
|
17
|
+
function toSnakeCase(s: string): string {
|
|
18
|
+
return s.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function emitTestSuite(system: IR.IRSystem): string {
|
|
22
|
+
const lines: string[] = [];
|
|
23
|
+
|
|
24
|
+
lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
|
|
25
|
+
lines.push(`// Regression tests derived from capability declarations.`);
|
|
26
|
+
lines.push(`// Run: npx ts-node src/tests.ts`);
|
|
27
|
+
lines.push(``);
|
|
28
|
+
lines.push(`import * as http from "http";`);
|
|
29
|
+
lines.push(``);
|
|
30
|
+
lines.push(`const BASE_URL = process.env.TEST_BASE_URL || "http://localhost:3000";`);
|
|
31
|
+
lines.push(`const AUTH_TOKEN = process.env.TEST_AUTH_TOKEN || "";`);
|
|
32
|
+
lines.push(``);
|
|
33
|
+
lines.push(`let passed = 0;`);
|
|
34
|
+
lines.push(`let failed = 0;`);
|
|
35
|
+
lines.push(``);
|
|
36
|
+
|
|
37
|
+
// Test helper
|
|
38
|
+
lines.push(`async function request(method: string, path: string, body?: any): Promise<{ status: number; data: any }> {`);
|
|
39
|
+
lines.push(` const url = new URL(path, BASE_URL);`);
|
|
40
|
+
lines.push(` const res = await fetch(url.toString(), {`);
|
|
41
|
+
lines.push(` method,`);
|
|
42
|
+
lines.push(` headers: {`);
|
|
43
|
+
lines.push(` "Content-Type": "application/json",`);
|
|
44
|
+
lines.push(` ...(AUTH_TOKEN ? { "Authorization": \`Bearer \${AUTH_TOKEN}\` } : {}),`);
|
|
45
|
+
lines.push(` },`);
|
|
46
|
+
lines.push(` body: body ? JSON.stringify(body) : undefined,`);
|
|
47
|
+
lines.push(` });`);
|
|
48
|
+
lines.push(` const data = await res.json().catch(() => ({}));`);
|
|
49
|
+
lines.push(` return { status: res.status, data };`);
|
|
50
|
+
lines.push(`}`);
|
|
51
|
+
lines.push(``);
|
|
52
|
+
|
|
53
|
+
lines.push(`async function test(name: string, fn: () => Promise<void>): Promise<void> {`);
|
|
54
|
+
lines.push(` try {`);
|
|
55
|
+
lines.push(` await fn();`);
|
|
56
|
+
lines.push(` console.log(\` ✓ \${name}\`);`);
|
|
57
|
+
lines.push(` passed++;`);
|
|
58
|
+
lines.push(` } catch (e: any) {`);
|
|
59
|
+
lines.push(` console.log(\` ✗ \${name}: \${e.message}\`);`);
|
|
60
|
+
lines.push(` failed++;`);
|
|
61
|
+
lines.push(` }`);
|
|
62
|
+
lines.push(`}`);
|
|
63
|
+
lines.push(``);
|
|
64
|
+
|
|
65
|
+
lines.push(`function assert(condition: boolean, message: string): void {`);
|
|
66
|
+
lines.push(` if (!condition) throw new Error(message);`);
|
|
67
|
+
lines.push(`}`);
|
|
68
|
+
lines.push(``);
|
|
69
|
+
lines.push(`(async () => {`);
|
|
70
|
+
|
|
71
|
+
// Generate tests for each module
|
|
72
|
+
for (const mod of system.modules) {
|
|
73
|
+
if (mod.kind !== "api_service" || mod.models.length === 0) continue;
|
|
74
|
+
|
|
75
|
+
const model = mod.models[0];
|
|
76
|
+
const tablePath = `/${toSnakeCase(model.name)}s`;
|
|
77
|
+
|
|
78
|
+
lines.push(`// ─── ${mod.name} Tests ─────────────────────────────────────────────────────`);
|
|
79
|
+
lines.push(``);
|
|
80
|
+
|
|
81
|
+
// CRUD tests
|
|
82
|
+
lines.push(`console.log("\\n${mod.name} — CRUD");`);
|
|
83
|
+
lines.push(``);
|
|
84
|
+
|
|
85
|
+
lines.push(`let __${toSnakeCase(model.name)}_id: string;`);
|
|
86
|
+
lines.push(``);
|
|
87
|
+
|
|
88
|
+
// Create test
|
|
89
|
+
const createFields = model.fields.filter(f =>
|
|
90
|
+
!["id", "created_at", "updated_at"].includes(f.name)
|
|
91
|
+
);
|
|
92
|
+
const samplePayload = createFields.reduce((acc, f) => {
|
|
93
|
+
acc[f.name] = sampleValue(f.type);
|
|
94
|
+
return acc;
|
|
95
|
+
}, {} as Record<string, any>);
|
|
96
|
+
|
|
97
|
+
lines.push(`await test("POST ${tablePath} — creates entity", async () => {`);
|
|
98
|
+
lines.push(` const { status, data } = await request("POST", "${tablePath}", ${JSON.stringify(samplePayload)});`);
|
|
99
|
+
lines.push(` assert(status === 201, \`Expected 201, got \${status}: \${JSON.stringify(data)}\`);`);
|
|
100
|
+
lines.push(` assert(data.id, "Response must have id");`);
|
|
101
|
+
lines.push(` __${toSnakeCase(model.name)}_id = data.id;`);
|
|
102
|
+
lines.push(`});`);
|
|
103
|
+
lines.push(``);
|
|
104
|
+
|
|
105
|
+
// Read test
|
|
106
|
+
lines.push(`await test("GET ${tablePath}/:id — reads entity", async () => {`);
|
|
107
|
+
lines.push(` const { status, data } = await request("GET", \`${tablePath}/\${__${toSnakeCase(model.name)}_id}\`);`);
|
|
108
|
+
lines.push(` assert(status === 200, \`Expected 200, got \${status}\`);`);
|
|
109
|
+
lines.push(` assert(data.id === __${toSnakeCase(model.name)}_id, "ID must match");`);
|
|
110
|
+
lines.push(`});`);
|
|
111
|
+
lines.push(``);
|
|
112
|
+
|
|
113
|
+
// List test
|
|
114
|
+
lines.push(`await test("GET ${tablePath} — lists entities", async () => {`);
|
|
115
|
+
lines.push(` const { status, data } = await request("GET", "${tablePath}");`);
|
|
116
|
+
lines.push(` assert(status === 200, \`Expected 200, got \${status}\`);`);
|
|
117
|
+
lines.push(` assert(Array.isArray(data.items), "Response must have items array");`);
|
|
118
|
+
lines.push(` assert(typeof data.total === "number", "Response must have total");`);
|
|
119
|
+
lines.push(`});`);
|
|
120
|
+
lines.push(``);
|
|
121
|
+
|
|
122
|
+
// Auth test
|
|
123
|
+
lines.push(`await test("GET ${tablePath} — rejects unauthenticated", async () => {`);
|
|
124
|
+
lines.push(` const res = await fetch(\`\${BASE_URL}${tablePath}\`);`);
|
|
125
|
+
lines.push(` assert(res.status === 401, \`Expected 401, got \${res.status}\`);`);
|
|
126
|
+
lines.push(`});`);
|
|
127
|
+
lines.push(``);
|
|
128
|
+
|
|
129
|
+
// Capability tests
|
|
130
|
+
for (const iface of mod.interfaces) {
|
|
131
|
+
for (const method of iface.methods) {
|
|
132
|
+
if (["create", "read", "update", "delete", "list"].includes(method.name)) continue;
|
|
133
|
+
|
|
134
|
+
const endpoint = `${tablePath}/${method.name.replace(/_/g, "-")}`;
|
|
135
|
+
lines.push(`await test("POST ${endpoint} — capability executes", async () => {`);
|
|
136
|
+
lines.push(` const { status, data } = await request("POST", "${endpoint}", {`);
|
|
137
|
+
lines.push(` ${toSnakeCase(model.name)}_id: __${toSnakeCase(model.name)}_id,`);
|
|
138
|
+
lines.push(` });`);
|
|
139
|
+
lines.push(` // Capability may return 200 (success) or 422 (precondition failed) — both are valid`);
|
|
140
|
+
lines.push(` assert([200, 422].includes(status), \`Expected 200 or 422, got \${status}: \${JSON.stringify(data)}\`);`);
|
|
141
|
+
lines.push(`});`);
|
|
142
|
+
lines.push(``);
|
|
143
|
+
|
|
144
|
+
if (method.preconditions.length > 0) {
|
|
145
|
+
lines.push(`await test("POST ${endpoint} — returns 401 without auth", async () => {`);
|
|
146
|
+
lines.push(` const res = await fetch(\`\${BASE_URL}${endpoint}\`, { method: "POST" });`);
|
|
147
|
+
lines.push(` assert(res.status === 401, \`Expected 401, got \${res.status}\`);`);
|
|
148
|
+
lines.push(`});`);
|
|
149
|
+
lines.push(``);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// State machine tests
|
|
155
|
+
for (const sm of mod.state_machines) {
|
|
156
|
+
lines.push(`// State machine: ${sm.entity}`);
|
|
157
|
+
lines.push(`await test("PUT ${tablePath}/:id — rejects invalid state transition", async () => {`);
|
|
158
|
+
lines.push(` const { status, data } = await request("PUT", \`${tablePath}/\${__${toSnakeCase(model.name)}_id}\`, {`);
|
|
159
|
+
lines.push(` state: "__invalid_state__",`);
|
|
160
|
+
lines.push(` });`);
|
|
161
|
+
lines.push(` assert(status === 422, \`Expected 422 for invalid transition, got \${status}\`);`);
|
|
162
|
+
lines.push(` assert(data.error?.code === "INVALID_TRANSITION", "Error code must be INVALID_TRANSITION");`);
|
|
163
|
+
lines.push(`});`);
|
|
164
|
+
lines.push(``);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Delete test (last, so ID is still valid)
|
|
168
|
+
lines.push(`await test("DELETE ${tablePath}/:id — deletes entity", async () => {`);
|
|
169
|
+
lines.push(` const { status } = await request("DELETE", \`${tablePath}/\${__${toSnakeCase(model.name)}_id}\`);`);
|
|
170
|
+
lines.push(` assert(status === 204, \`Expected 204, got \${status}\`);`);
|
|
171
|
+
lines.push(`});`);
|
|
172
|
+
lines.push(``);
|
|
173
|
+
|
|
174
|
+
// 404 after delete
|
|
175
|
+
lines.push(`await test("GET ${tablePath}/:id — returns 404 after delete", async () => {`);
|
|
176
|
+
lines.push(` const { status } = await request("GET", \`${tablePath}/\${__${toSnakeCase(model.name)}_id}\`);`);
|
|
177
|
+
lines.push(` assert(status === 404, \`Expected 404, got \${status}\`);`);
|
|
178
|
+
lines.push(`});`);
|
|
179
|
+
lines.push(``);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Summary
|
|
183
|
+
lines.push(`console.log(\`\\n${"═".repeat(40)}\`);`);
|
|
184
|
+
lines.push(`console.log(\`Results: \${passed} passed, \${failed} failed\`);`);
|
|
185
|
+
lines.push(`console.log("═".repeat(40));`);
|
|
186
|
+
lines.push(`if (failed > 0) process.exit(1);`);
|
|
187
|
+
lines.push(`})().catch(e => { console.error(e); process.exit(1); });`);
|
|
188
|
+
|
|
189
|
+
return lines.join("\n");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function sampleValue(type: string): any {
|
|
193
|
+
switch (type) {
|
|
194
|
+
case "string": return "test_value";
|
|
195
|
+
case "uint": case "int": return 1;
|
|
196
|
+
case "float": return 1.0;
|
|
197
|
+
case "bool": return true;
|
|
198
|
+
case "uuid": return "00000000-0000-0000-0000-000000000001";
|
|
199
|
+
case "timestamp": return "2024-01-01T00:00:00.000Z";
|
|
200
|
+
case "json": return {};
|
|
201
|
+
default:
|
|
202
|
+
if (type.startsWith("list<") || type.startsWith("set<")) return [];
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BoneScript WebSocket Runtime Emitter
|
|
3
|
+
* Generates runnable WebSocket servers for `channel` declarations.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as IR from "./ir";
|
|
7
|
+
|
|
8
|
+
function toSnakeCase(s: string): string {
|
|
9
|
+
return s.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function toCamelCase(s: string): string {
|
|
13
|
+
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ─── WebSocket Server ────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export function emitWebSocketServer(system: IR.IRSystem): string {
|
|
19
|
+
const channels = system.modules.filter(m => m.kind === "realtime_service");
|
|
20
|
+
if (channels.length === 0) return "";
|
|
21
|
+
|
|
22
|
+
const lines: string[] = [];
|
|
23
|
+
lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
|
|
24
|
+
lines.push(`// WebSocket server for realtime channels`);
|
|
25
|
+
lines.push(``);
|
|
26
|
+
lines.push(`import { WebSocketServer, WebSocket } from "ws";`);
|
|
27
|
+
lines.push(`import { IncomingMessage, Server } from "http";`);
|
|
28
|
+
lines.push(`import jwt from "jsonwebtoken";`);
|
|
29
|
+
lines.push(`import { eventBus } from "./events";`);
|
|
30
|
+
lines.push(`import { logger } from "./logger";`);
|
|
31
|
+
lines.push(``);
|
|
32
|
+
lines.push(`const JWT_SECRET = process.env.JWT_SECRET || "bonescript-dev-secret-change-in-production";`);
|
|
33
|
+
lines.push(``);
|
|
34
|
+
// Redis pub/sub for multi-instance support
|
|
35
|
+
lines.push(`// Redis pub/sub for multi-instance WebSocket broadcasting`);
|
|
36
|
+
lines.push(`let redisSub: any = null;`);
|
|
37
|
+
lines.push(`let redisPub: any = null;`);
|
|
38
|
+
lines.push(`if (process.env.REDIS_URL) {`);
|
|
39
|
+
lines.push(` try {`);
|
|
40
|
+
lines.push(` const { createClient } = require("redis");`);
|
|
41
|
+
lines.push(` redisSub = createClient({ url: process.env.REDIS_URL });`);
|
|
42
|
+
lines.push(` redisPub = createClient({ url: process.env.REDIS_URL });`);
|
|
43
|
+
lines.push(` Promise.all([redisSub.connect(), redisPub.connect()]).then(() => {`);
|
|
44
|
+
lines.push(` logger.info("redis_connected", { event: "startup" });`);
|
|
45
|
+
lines.push(` }).catch((e: any) => {`);
|
|
46
|
+
lines.push(` logger.error("redis_connect_failed", { event: "startup", metadata: { error: e.message } });`);
|
|
47
|
+
lines.push(` redisSub = null; redisPub = null;`);
|
|
48
|
+
lines.push(` });`);
|
|
49
|
+
lines.push(` } catch { redisSub = null; redisPub = null; }`);
|
|
50
|
+
lines.push(`}`);
|
|
51
|
+
|
|
52
|
+
// Per-channel client registry
|
|
53
|
+
lines.push(`interface Client {`);
|
|
54
|
+
lines.push(` socket: WebSocket;`);
|
|
55
|
+
lines.push(` user_id: string;`);
|
|
56
|
+
lines.push(` channel: string;`);
|
|
57
|
+
lines.push(` topics: Set<string>;`);
|
|
58
|
+
lines.push(`}`);
|
|
59
|
+
lines.push(``);
|
|
60
|
+
lines.push(`const clients: Map<string, Set<Client>> = new Map();`);
|
|
61
|
+
lines.push(``);
|
|
62
|
+
|
|
63
|
+
// Channel configs
|
|
64
|
+
lines.push(`const CHANNELS: Record<string, { ordering: string; persistence: string; max_size: number }> = {`);
|
|
65
|
+
for (const ch of channels) {
|
|
66
|
+
lines.push(` "${ch.name}": {`);
|
|
67
|
+
lines.push(` ordering: "${ch.config["ordering"] || "fifo"}",`);
|
|
68
|
+
lines.push(` persistence: "${ch.config["persistence"] || "none"}",`);
|
|
69
|
+
lines.push(` max_size: ${ch.config["max_size"] || 10000},`);
|
|
70
|
+
lines.push(` },`);
|
|
71
|
+
}
|
|
72
|
+
lines.push(`};`);
|
|
73
|
+
lines.push(``);
|
|
74
|
+
|
|
75
|
+
// Message buffer for persistence
|
|
76
|
+
lines.push(`const messageBuffers: Map<string, any[]> = new Map();`);
|
|
77
|
+
lines.push(``);
|
|
78
|
+
lines.push(`function getBuffer(channel: string): any[] {`);
|
|
79
|
+
lines.push(` const buf = messageBuffers.get(channel);`);
|
|
80
|
+
lines.push(` if (buf) return buf;`);
|
|
81
|
+
lines.push(` const fresh: any[] = [];`);
|
|
82
|
+
lines.push(` messageBuffers.set(channel, fresh);`);
|
|
83
|
+
lines.push(` return fresh;`);
|
|
84
|
+
lines.push(`}`);
|
|
85
|
+
lines.push(``);
|
|
86
|
+
lines.push(`function persistMessage(channel: string, msg: any) {`);
|
|
87
|
+
lines.push(` const cfg = CHANNELS[channel];`);
|
|
88
|
+
lines.push(` if (!cfg || cfg.persistence === "none") return;`);
|
|
89
|
+
lines.push(` const buf = getBuffer(channel);`);
|
|
90
|
+
lines.push(` buf.push(msg);`);
|
|
91
|
+
lines.push(``);
|
|
92
|
+
lines.push(` // Honor persistence config (last_N, full)`);
|
|
93
|
+
lines.push(` const match = cfg.persistence.match(/^last_(\\d+)$/);`);
|
|
94
|
+
lines.push(` if (match) {`);
|
|
95
|
+
lines.push(` const limit = parseInt(match[1], 10);`);
|
|
96
|
+
lines.push(` while (buf.length > limit) buf.shift();`);
|
|
97
|
+
lines.push(` } else if (buf.length > cfg.max_size) {`);
|
|
98
|
+
lines.push(` buf.shift();`);
|
|
99
|
+
lines.push(` }`);
|
|
100
|
+
lines.push(`}`);
|
|
101
|
+
lines.push(``);
|
|
102
|
+
|
|
103
|
+
// Setup function
|
|
104
|
+
lines.push(`export function setupWebSocketServer(httpServer: Server): WebSocketServer {`);
|
|
105
|
+
lines.push(` const wss = new WebSocketServer({ server: httpServer, path: "/ws" });`);
|
|
106
|
+
lines.push(``);
|
|
107
|
+
lines.push(` wss.on("connection", (socket: WebSocket, req: IncomingMessage) => {`);
|
|
108
|
+
lines.push(` const url = new URL(req.url || "/", \`http://\${req.headers.host}\`);`);
|
|
109
|
+
lines.push(` const channel = url.searchParams.get("channel") || "";`);
|
|
110
|
+
lines.push(` const token = url.searchParams.get("token") || "";`);
|
|
111
|
+
lines.push(``);
|
|
112
|
+
lines.push(` if (!CHANNELS[channel]) {`);
|
|
113
|
+
lines.push(` socket.send(JSON.stringify({ type: "error", message: "Unknown channel: " + channel }));`);
|
|
114
|
+
lines.push(` socket.close();`);
|
|
115
|
+
lines.push(` return;`);
|
|
116
|
+
lines.push(` }`);
|
|
117
|
+
lines.push(``);
|
|
118
|
+
lines.push(` let userId: string;`);
|
|
119
|
+
lines.push(` try {`);
|
|
120
|
+
lines.push(` const decoded = jwt.verify(token, JWT_SECRET) as { sub: string };`);
|
|
121
|
+
lines.push(` userId = decoded.sub;`);
|
|
122
|
+
lines.push(` } catch {`);
|
|
123
|
+
lines.push(` socket.send(JSON.stringify({ type: "error", message: "Authentication failed" }));`);
|
|
124
|
+
lines.push(` socket.close();`);
|
|
125
|
+
lines.push(` return;`);
|
|
126
|
+
lines.push(` }`);
|
|
127
|
+
lines.push(``);
|
|
128
|
+
lines.push(` const client: Client = { socket, user_id: userId, channel, topics: new Set() };`);
|
|
129
|
+
lines.push(` const set = clients.get(channel) || new Set();`);
|
|
130
|
+
lines.push(` set.add(client);`);
|
|
131
|
+
lines.push(` clients.set(channel, set);`);
|
|
132
|
+
lines.push(``);
|
|
133
|
+
lines.push(` console.log(\`[ws] User \${userId} connected to \${channel} (\${set.size} active)\`);`);
|
|
134
|
+
lines.push(``);
|
|
135
|
+
lines.push(` // Send buffered history`);
|
|
136
|
+
lines.push(` const history = messageBuffers.get(channel) || [];`);
|
|
137
|
+
lines.push(` for (const msg of history) {`);
|
|
138
|
+
lines.push(` socket.send(JSON.stringify(msg));`);
|
|
139
|
+
lines.push(` }`);
|
|
140
|
+
lines.push(``);
|
|
141
|
+
lines.push(` socket.send(JSON.stringify({ type: "connected", channel, history_size: history.length }));`);
|
|
142
|
+
lines.push(``);
|
|
143
|
+
lines.push(` socket.on("message", (data) => {`);
|
|
144
|
+
lines.push(` try {`);
|
|
145
|
+
lines.push(` const msg = JSON.parse(data.toString());`);
|
|
146
|
+
lines.push(``);
|
|
147
|
+
lines.push(` if (msg.type === "subscribe" && msg.topic) {`);
|
|
148
|
+
lines.push(` client.topics.add(msg.topic);`);
|
|
149
|
+
lines.push(` return;`);
|
|
150
|
+
lines.push(` }`);
|
|
151
|
+
lines.push(` if (msg.type === "unsubscribe" && msg.topic) {`);
|
|
152
|
+
lines.push(` client.topics.delete(msg.topic);`);
|
|
153
|
+
lines.push(` return;`);
|
|
154
|
+
lines.push(` }`);
|
|
155
|
+
lines.push(``);
|
|
156
|
+
lines.push(` // Broadcast`);
|
|
157
|
+
lines.push(` const broadcast = {`);
|
|
158
|
+
lines.push(` type: msg.type || "message",`);
|
|
159
|
+
lines.push(` payload: msg.payload || msg,`);
|
|
160
|
+
lines.push(` from: userId,`);
|
|
161
|
+
lines.push(` timestamp: new Date().toISOString(),`);
|
|
162
|
+
lines.push(` };`);
|
|
163
|
+
lines.push(``);
|
|
164
|
+
lines.push(` persistMessage(channel, broadcast);`);
|
|
165
|
+
lines.push(` broadcastToChannel(channel, broadcast, client);`);
|
|
166
|
+
lines.push(` } catch (e: any) {`);
|
|
167
|
+
lines.push(` socket.send(JSON.stringify({ type: "error", message: e.message }));`);
|
|
168
|
+
lines.push(` }`);
|
|
169
|
+
lines.push(` });`);
|
|
170
|
+
lines.push(``);
|
|
171
|
+
lines.push(` socket.on("close", () => {`);
|
|
172
|
+
lines.push(` const set = clients.get(channel);`);
|
|
173
|
+
lines.push(` if (set) set.delete(client);`);
|
|
174
|
+
lines.push(` console.log(\`[ws] User \${userId} disconnected from \${channel}\`);`);
|
|
175
|
+
lines.push(` });`);
|
|
176
|
+
lines.push(``);
|
|
177
|
+
lines.push(` // Heartbeat`);
|
|
178
|
+
lines.push(` const heartbeat = setInterval(() => {`);
|
|
179
|
+
lines.push(` if (socket.readyState === WebSocket.OPEN) {`);
|
|
180
|
+
lines.push(` socket.ping();`);
|
|
181
|
+
lines.push(` } else {`);
|
|
182
|
+
lines.push(` clearInterval(heartbeat);`);
|
|
183
|
+
lines.push(` }`);
|
|
184
|
+
lines.push(` }, 30000);`);
|
|
185
|
+
lines.push(` });`);
|
|
186
|
+
lines.push(``);
|
|
187
|
+
lines.push(` // Bridge: forward eventBus events to WebSocket clients`);
|
|
188
|
+
for (const ch of channels) {
|
|
189
|
+
lines.push(` // Channel '${ch.name}' bridge`);
|
|
190
|
+
}
|
|
191
|
+
lines.push(``);
|
|
192
|
+
// Redis subscription for cross-instance delivery
|
|
193
|
+
lines.push(` // Subscribe to Redis channels for cross-instance delivery`);
|
|
194
|
+
lines.push(` if (redisSub) {`);
|
|
195
|
+
for (const ch of channels) {
|
|
196
|
+
lines.push(` redisSub.subscribe("ws:${ch.name}", (message: string) => {`);
|
|
197
|
+
lines.push(` const set = clients.get("${ch.name}");`);
|
|
198
|
+
lines.push(` if (!set) return;`);
|
|
199
|
+
lines.push(` for (const client of set) {`);
|
|
200
|
+
lines.push(` if (client.socket.readyState === WebSocket.OPEN) client.socket.send(message);`);
|
|
201
|
+
lines.push(` }`);
|
|
202
|
+
lines.push(` }).catch(() => {});`);
|
|
203
|
+
}
|
|
204
|
+
lines.push(` }`);
|
|
205
|
+
lines.push(` return wss;`);
|
|
206
|
+
lines.push(`}`);
|
|
207
|
+
lines.push(``);
|
|
208
|
+
lines.push(`function broadcastToChannel(channel: string, message: any, exclude?: Client) {`);
|
|
209
|
+
lines.push(` const set = clients.get(channel);`);
|
|
210
|
+
lines.push(` const data = JSON.stringify(message);`);
|
|
211
|
+
lines.push(` // Broadcast to local clients`);
|
|
212
|
+
lines.push(` if (set) {`);
|
|
213
|
+
lines.push(` for (const client of set) {`);
|
|
214
|
+
lines.push(` if (client === exclude) continue;`);
|
|
215
|
+
lines.push(` if (client.socket.readyState === WebSocket.OPEN) {`);
|
|
216
|
+
lines.push(` client.socket.send(data);`);
|
|
217
|
+
lines.push(` }`);
|
|
218
|
+
lines.push(` }`);
|
|
219
|
+
lines.push(` }`);
|
|
220
|
+
lines.push(` // Publish to Redis for cross-instance delivery`);
|
|
221
|
+
lines.push(` if (redisPub) {`);
|
|
222
|
+
lines.push(` redisPub.publish(\`ws:\${channel}\`, data).catch(() => {});`);
|
|
223
|
+
lines.push(` }`);
|
|
224
|
+
lines.push(`}`);
|
|
225
|
+
lines.push(``);
|
|
226
|
+
lines.push(`export { broadcastToChannel };`);
|
|
227
|
+
|
|
228
|
+
return lines.join("\n");
|
|
229
|
+
}
|