bonescript-compiler 0.5.7 → 0.6.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/dist/ast.d.ts +2 -0
- package/dist/cli.js +53 -9
- package/dist/cli.js.map +1 -1
- package/dist/emit_admin.d.ts +5 -0
- package/dist/emit_admin.js +341 -35
- package/dist/emit_admin.js.map +1 -1
- package/dist/emit_audit.js +40 -6
- package/dist/emit_audit.js.map +1 -1
- package/dist/emit_capability.js +14 -0
- package/dist/emit_capability.js.map +1 -1
- package/dist/emit_cron.d.ts +6 -0
- package/dist/emit_cron.js +66 -0
- package/dist/emit_cron.js.map +1 -0
- package/dist/emit_full.d.ts +6 -1
- package/dist/emit_full.js +38 -7
- package/dist/emit_full.js.map +1 -1
- package/dist/emit_graphql.d.ts +6 -0
- package/dist/emit_graphql.js +140 -0
- package/dist/emit_graphql.js.map +1 -0
- package/dist/emit_maintenance.js +35 -3
- package/dist/emit_maintenance.js.map +1 -1
- package/dist/emit_notify.d.ts +6 -0
- package/dist/emit_notify.js +85 -0
- package/dist/emit_notify.js.map +1 -0
- package/dist/emit_runtime.d.ts +18 -1
- package/dist/emit_runtime.js +217 -32
- package/dist/emit_runtime.js.map +1 -1
- package/dist/emit_websocket.js +22 -2
- package/dist/emit_websocket.js.map +1 -1
- package/dist/emit_zod.js +12 -1
- package/dist/emit_zod.js.map +1 -1
- package/dist/formatter.d.ts +1 -0
- package/dist/formatter.js +10 -2
- package/dist/formatter.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/ir.d.ts +2 -0
- package/dist/lexer.d.ts +1 -0
- package/dist/lexer.js +4 -0
- package/dist/lexer.js.map +1 -1
- package/dist/lowering.js +2 -0
- package/dist/lowering.js.map +1 -1
- package/dist/parse_decls.js +36 -1
- package/dist/parse_decls.js.map +1 -1
- package/dist/scaffold.js +3 -1
- package/dist/scaffold.js.map +1 -1
- package/dist/typechecker.js +9 -0
- package/dist/typechecker.js.map +1 -1
- package/package.json +1 -1
- package/src/ast.ts +2 -0
- package/src/cli.ts +59 -11
- package/src/emit_admin.ts +343 -35
- package/src/emit_audit.ts +42 -6
- package/src/emit_capability.ts +13 -0
- package/src/emit_cron.ts +70 -0
- package/src/emit_full.ts +45 -7
- package/src/emit_graphql.ts +161 -0
- package/src/emit_maintenance.ts +35 -3
- package/src/emit_notify.ts +88 -0
- package/src/emit_runtime.ts +229 -32
- package/src/emit_websocket.ts +22 -2
- package/src/emit_zod.ts +11 -1
- package/src/formatter.ts +9 -2
- package/src/index.ts +3 -0
- package/src/ir.ts +2 -0
- package/src/lexer.ts +2 -0
- package/src/lowering.ts +5 -3
- package/src/parse_decls.ts +31 -1
- package/src/scaffold.ts +3 -1
- package/src/typechecker.ts +10 -0
package/src/emit_runtime.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import * as IR from "./ir";
|
|
7
|
+
import { createHash } from "crypto";
|
|
7
8
|
import { emitCapabilityBody } from "./emit_capability";
|
|
8
9
|
|
|
9
10
|
function toSnakeCase(s: string): string {
|
|
@@ -47,26 +48,31 @@ export function emitPackageJson(system: IR.IRSystem): string {
|
|
|
47
48
|
migrate: "ts-node src/migrate.ts",
|
|
48
49
|
},
|
|
49
50
|
dependencies: {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
// Pinned to specific patch versions. Update consciously — a bump should
|
|
52
|
+
// be paired with a quick read of the release notes / advisories.
|
|
53
|
+
express: "4.22.2", // 4.22.x patches qs / path-to-regexp DoS
|
|
54
|
+
pg: "8.13.1",
|
|
55
|
+
ioredis: "5.4.1",
|
|
56
|
+
ws: "8.18.0", // 8.17.1 patched CVE-2024-37890 (DoS)
|
|
57
|
+
uuid: "10.0.0",
|
|
55
58
|
cors: "2.8.5",
|
|
56
|
-
helmet: "
|
|
57
|
-
"express-rate-limit": "7.
|
|
59
|
+
helmet: "8.0.0",
|
|
60
|
+
"express-rate-limit": "7.5.0",
|
|
58
61
|
jsonwebtoken: "9.0.2",
|
|
59
|
-
dotenv: "16.
|
|
62
|
+
dotenv: "16.4.7",
|
|
63
|
+
"node-cron": "3.0.3",
|
|
64
|
+
zod: "3.23.8",
|
|
60
65
|
},
|
|
61
66
|
devDependencies: {
|
|
62
67
|
"@types/express": "4.17.21",
|
|
63
|
-
"@types/node": "
|
|
64
|
-
"@types/pg": "8.10
|
|
65
|
-
"@types/ws": "8.5.
|
|
68
|
+
"@types/node": "20.14.0",
|
|
69
|
+
"@types/pg": "8.11.10",
|
|
70
|
+
"@types/ws": "8.5.13",
|
|
66
71
|
"@types/cors": "2.8.17",
|
|
67
|
-
"@types/jsonwebtoken": "9.0.
|
|
68
|
-
"@types/uuid": "
|
|
69
|
-
|
|
72
|
+
"@types/jsonwebtoken": "9.0.7",
|
|
73
|
+
"@types/uuid": "10.0.0",
|
|
74
|
+
"@types/node-cron": "3.0.11",
|
|
75
|
+
typescript: "5.6.3",
|
|
70
76
|
"ts-node": "10.9.2",
|
|
71
77
|
},
|
|
72
78
|
};
|
|
@@ -143,6 +149,7 @@ export function emitAuthMiddleware(system: IR.IRSystem): string {
|
|
|
143
149
|
return `// Generated by BoneScript compiler. DO NOT EDIT.
|
|
144
150
|
import { Request, Response, NextFunction } from "express";
|
|
145
151
|
import jwt from "jsonwebtoken";
|
|
152
|
+
import { v4 as uuid } from "uuid";
|
|
146
153
|
|
|
147
154
|
// JWT_SECRET must be set in production. The server will refuse to start without it
|
|
148
155
|
// when NODE_ENV is "production" to prevent accidental use of a weak fallback.
|
|
@@ -168,23 +175,45 @@ export interface AuthContext {
|
|
|
168
175
|
trace_id: string;
|
|
169
176
|
}
|
|
170
177
|
|
|
178
|
+
// Trace IDs are server-generated. We accept a client-supplied X-Trace-Id only
|
|
179
|
+
// if it parses as a UUID, so callers can correlate their own logs without
|
|
180
|
+
// being able to forge correlation IDs in audit / event records.
|
|
181
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
182
|
+
function resolveTraceId(req: Request): string {
|
|
183
|
+
const supplied = req.headers["x-trace-id"];
|
|
184
|
+
if (typeof supplied === "string" && UUID_RE.test(supplied)) return supplied;
|
|
185
|
+
return uuid();
|
|
186
|
+
}
|
|
187
|
+
|
|
171
188
|
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
|
172
189
|
const header = req.headers.authorization;
|
|
190
|
+
const traceId = resolveTraceId(req);
|
|
173
191
|
if (!header || !header.startsWith("Bearer ")) {
|
|
174
|
-
(req as any).auth = { authenticated: false, actor_id: null, trace_id:
|
|
192
|
+
(req as any).auth = { authenticated: false, actor_id: null, trace_id: traceId };
|
|
175
193
|
next();
|
|
176
194
|
return;
|
|
177
195
|
}
|
|
178
196
|
try {
|
|
179
197
|
const token = header.slice(7);
|
|
180
|
-
|
|
198
|
+
// Pin algorithms to HS256 and require an expiration. Without pinning,
|
|
199
|
+
// jsonwebtoken would accept any algorithm including RS/HS confusion attacks.
|
|
200
|
+
// maxAge is a safety net even if exp is set far in the future.
|
|
201
|
+
const decoded = jwt.verify(token, JWT_SECRET, {
|
|
202
|
+
algorithms: ["HS256"],
|
|
203
|
+
maxAge: process.env.JWT_MAX_AGE || "1h",
|
|
204
|
+
}) as { sub?: unknown };
|
|
205
|
+
if (typeof decoded.sub !== "string" || decoded.sub.length === 0) {
|
|
206
|
+
(req as any).auth = { authenticated: false, actor_id: null, trace_id: traceId };
|
|
207
|
+
next();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
181
210
|
(req as any).auth = {
|
|
182
211
|
authenticated: true,
|
|
183
212
|
actor_id: decoded.sub,
|
|
184
|
-
trace_id:
|
|
213
|
+
trace_id: traceId,
|
|
185
214
|
};
|
|
186
215
|
} catch {
|
|
187
|
-
(req as any).auth = { authenticated: false, actor_id: null, trace_id:
|
|
216
|
+
(req as any).auth = { authenticated: false, actor_id: null, trace_id: traceId };
|
|
188
217
|
}
|
|
189
218
|
next();
|
|
190
219
|
}
|
|
@@ -224,6 +253,9 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
224
253
|
}
|
|
225
254
|
lines.push(`import { logger } from "../logger";`);
|
|
226
255
|
lines.push(`import { counter } from "../metrics";`);
|
|
256
|
+
// Zod schemas for input validation. The Create / Update derivatives are
|
|
257
|
+
// generated alongside the full model schema in src/schemas.ts.
|
|
258
|
+
lines.push(`import { ${toPascalCase(entityModel.name)}CreateSchema, ${toPascalCase(entityModel.name)}UpdateSchema } from "../schemas";`);
|
|
227
259
|
lines.push(`import * as __algorithms from "../algorithms";`);
|
|
228
260
|
lines.push(`const { shortestPath, topologicalSort, binarySearch, bipartiteMatching, roundRobin, weightedAverage, percentile, rankBy, consistentHash } = __algorithms as any;`);
|
|
229
261
|
lines.push(``);
|
|
@@ -263,9 +295,15 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
263
295
|
lines.push(`// CREATE`);
|
|
264
296
|
const __crudMiddlewares = modRateLimit > 0 ? "__routeRateLimit, requireAuth" : "requireAuth";
|
|
265
297
|
lines.push(`${toCamelCase(routeBase)}Router.post("/", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
|
|
298
|
+
lines.push(` // Validate the request body against the generated Zod schema.`);
|
|
299
|
+
lines.push(` const __parsed = ${toPascalCase(entityModel.name)}CreateSchema.safeParse(req.body);`);
|
|
300
|
+
lines.push(` if (!__parsed.success) {`);
|
|
301
|
+
lines.push(` return res.status(400).json({ error: { code: "VALIDATION_FAILED", message: "Invalid request body", issues: __parsed.error.flatten() } });`);
|
|
302
|
+
lines.push(` }`);
|
|
303
|
+
lines.push(` const __body = __parsed.data;`);
|
|
266
304
|
lines.push(` try {`);
|
|
267
305
|
lines.push(` const id = uuid();`);
|
|
268
|
-
lines.push(` const { ${insertFields.map(f => f.name).join(", ")} } =
|
|
306
|
+
lines.push(` const { ${insertFields.map(f => f.name).join(", ")} } = __body as any;`);
|
|
269
307
|
if (mod.state_machines.length > 0) {
|
|
270
308
|
lines.push(` const state = ${mod.state_machines[0].entity.toUpperCase()}_INITIAL;`);
|
|
271
309
|
}
|
|
@@ -329,10 +367,35 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
329
367
|
lines.push(`});`);
|
|
330
368
|
lines.push(``);
|
|
331
369
|
|
|
332
|
-
// UPDATE — with state machine enforcement
|
|
370
|
+
// UPDATE — with state machine enforcement and column allow-list
|
|
371
|
+
// The allow-list is derived from the IR model so generated SQL never
|
|
372
|
+
// interpolates user-supplied keys as identifiers.
|
|
373
|
+
const updatableColumns = entityModel.fields
|
|
374
|
+
.filter(f => !["id", "created_at", "updated_at"].includes(f.name))
|
|
375
|
+
.map(f => f.name);
|
|
376
|
+
// Entities with a state machine carry a `state` column not present in the
|
|
377
|
+
// IR field list. Include it so PUT can drive transitions.
|
|
378
|
+
if (mod.state_machines.length > 0 && !updatableColumns.includes("state")) {
|
|
379
|
+
updatableColumns.push("state");
|
|
380
|
+
}
|
|
333
381
|
lines.push(`// UPDATE`);
|
|
382
|
+
lines.push(`const __${toCamelCase(routeBase)}Updatable = new Set<string>(${JSON.stringify(updatableColumns)});`);
|
|
334
383
|
lines.push(`${toCamelCase(routeBase)}Router.put("/:id", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
|
|
335
|
-
lines.push(`
|
|
384
|
+
lines.push(` // 1. Validate body shape and types via Zod (UpdateSchema is partial).`);
|
|
385
|
+
lines.push(` const __parsed = ${toPascalCase(entityModel.name)}UpdateSchema.safeParse(req.body);`);
|
|
386
|
+
lines.push(` if (!__parsed.success) {`);
|
|
387
|
+
lines.push(` return res.status(400).json({ error: { code: "VALIDATION_FAILED", message: "Invalid request body", issues: __parsed.error.flatten() } });`);
|
|
388
|
+
lines.push(` }`);
|
|
389
|
+
lines.push(` // 2. Reject unknown columns (defense in depth — Zod would already strip them).`);
|
|
390
|
+
lines.push(` const __unknown = Object.keys(req.body || {}).filter(k => !__${toCamelCase(routeBase)}Updatable.has(k));`);
|
|
391
|
+
lines.push(` if (__unknown.length > 0) {`);
|
|
392
|
+
lines.push(` return res.status(400).json({ error: { code: "UNKNOWN_FIELDS", message: \`Unknown fields: \${__unknown.join(", ")}\`, fields: __unknown } });`);
|
|
393
|
+
lines.push(` }`);
|
|
394
|
+
lines.push(` // 3. Use the Zod-parsed object as the update set.`);
|
|
395
|
+
lines.push(` const fields: Record<string, unknown> = __parsed.data as Record<string, unknown>;`);
|
|
396
|
+
lines.push(` if (Object.keys(fields).length === 0) {`);
|
|
397
|
+
lines.push(` return res.status(400).json({ error: { code: "NO_FIELDS", message: "No updatable fields supplied" } });`);
|
|
398
|
+
lines.push(` }`);
|
|
336
399
|
if (mod.state_machines.length > 0) {
|
|
337
400
|
const sm = mod.state_machines[0];
|
|
338
401
|
lines.push(` // State machine enforcement`);
|
|
@@ -345,8 +408,9 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
345
408
|
lines.push(` if (!tr.ok) return res.status(422).json({ error: { code: "INVALID_TRANSITION", message: \`Cannot transition from \${current.state} to \${fields.state}\` } });`);
|
|
346
409
|
lines.push(` }`);
|
|
347
410
|
}
|
|
348
|
-
lines.push(` const
|
|
349
|
-
lines.push(` const
|
|
411
|
+
lines.push(` const keys = Object.keys(fields);`);
|
|
412
|
+
lines.push(` const sets = keys.map((k, i) => \`\${k} = $\${i + 2}\`).join(", ");`);
|
|
413
|
+
lines.push(` const values = keys.map(k => fields[k]);`);
|
|
350
414
|
lines.push(` const sql = \`UPDATE ${tableName} SET \${sets}, updated_at = NOW() WHERE id = $1 RETURNING *\`;`);
|
|
351
415
|
lines.push(` const rows = await query(sql, [req.params.id, ...values]);`);
|
|
352
416
|
lines.push(` if (rows.length === 0) return res.status(404).json({ error: { code: "NOT_FOUND", message: "Not found" } });`);
|
|
@@ -576,6 +640,7 @@ export function emitIndex(system: IR.IRSystem): string {
|
|
|
576
640
|
);
|
|
577
641
|
if (hasBatch) {
|
|
578
642
|
lines.push(`import { startBatchWorker } from "./batch";`);
|
|
643
|
+
lines.push(`import { startCronJobs } from "./cron";`);
|
|
579
644
|
}
|
|
580
645
|
if (hasWebSocket) {
|
|
581
646
|
lines.push(`import { setupWebSocketServer } from "./websocket";`);
|
|
@@ -594,6 +659,14 @@ export function emitIndex(system: IR.IRSystem): string {
|
|
|
594
659
|
lines.push(`const httpServer = createServer(app);`);
|
|
595
660
|
lines.push(`const PORT = parseInt(process.env.PORT || "3000");`);
|
|
596
661
|
lines.push(``);
|
|
662
|
+
lines.push(`// Trust the immediate hop's X-Forwarded-* headers when running behind a`);
|
|
663
|
+
lines.push(`// load balancer / k8s ingress. Override with TRUST_PROXY=<n> for multi-hop`);
|
|
664
|
+
lines.push(`// (e.g. "2" for ingress + service mesh). Set to "false" to disable.`);
|
|
665
|
+
lines.push(`const __trust = process.env.TRUST_PROXY ?? "loopback, linklocal, uniquelocal";`);
|
|
666
|
+
lines.push(`if (__trust === "false") app.set("trust proxy", false);`);
|
|
667
|
+
lines.push(`else if (/^\\d+$/.test(__trust)) app.set("trust proxy", Number(__trust));`);
|
|
668
|
+
lines.push(`else app.set("trust proxy", __trust);`);
|
|
669
|
+
lines.push(``);
|
|
597
670
|
lines.push(`// Middleware`);
|
|
598
671
|
lines.push(`app.use(helmet());`);
|
|
599
672
|
lines.push(`// CORS: restrict to ALLOWED_ORIGINS env var (comma-separated). Defaults to same-origin only.`);
|
|
@@ -653,6 +726,8 @@ export function emitIndex(system: IR.IRSystem): string {
|
|
|
653
726
|
if (hasBatch) {
|
|
654
727
|
lines.push(` // Start batch executor`);
|
|
655
728
|
lines.push(` startBatchWorker();`);
|
|
729
|
+
lines.push(` // Start cron jobs`);
|
|
730
|
+
lines.push(` startCronJobs();`);
|
|
656
731
|
}
|
|
657
732
|
lines.push(` console.log(\`[${system.name}] Running on port \${PORT}\`);`);
|
|
658
733
|
lines.push(` console.log(\` HTTP routes:\`);`);
|
|
@@ -694,23 +769,145 @@ export function emitIndex(system: IR.IRSystem): string {
|
|
|
694
769
|
|
|
695
770
|
// ─── Migration Script ─────────────────────────────────────────────────────────
|
|
696
771
|
|
|
697
|
-
|
|
772
|
+
/**
|
|
773
|
+
* A migration block paired with a stable identifier and content checksum.
|
|
774
|
+
* - `id` is a human-readable, ordered name (e.g. "0001_create_sellers").
|
|
775
|
+
* - `checksum` is a sha256 of the SQL body — used by the ledger to detect
|
|
776
|
+
* tampering with already-applied migrations.
|
|
777
|
+
*/
|
|
778
|
+
export interface MigrationBlock {
|
|
779
|
+
id: string;
|
|
780
|
+
checksum: string;
|
|
781
|
+
sql: string;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Build deterministic migration blocks from raw schema strings.
|
|
786
|
+
* The order of `schemas` is preserved; each block gets a zero-padded prefix
|
|
787
|
+
* so lexicographic sort matches insertion order.
|
|
788
|
+
*/
|
|
789
|
+
export function buildMigrationBlocks(schemas: string[]): MigrationBlock[] {
|
|
790
|
+
const blocks: MigrationBlock[] = [];
|
|
791
|
+
schemas.forEach((sql, i) => {
|
|
792
|
+
const checksum = createHash("sha256").update(sql).digest("hex");
|
|
793
|
+
// Try to extract the module name from the schema header for a friendlier id.
|
|
794
|
+
const moduleMatch = sql.match(/^--\s*Module:\s*(\S+)/m);
|
|
795
|
+
const tableMatch = sql.match(/CREATE TABLE IF NOT EXISTS\s+(\w+)/i);
|
|
796
|
+
const slug = (tableMatch?.[1] || moduleMatch?.[1] || `block_${i}`)
|
|
797
|
+
.replace(/[^a-zA-Z0-9_]/g, "_")
|
|
798
|
+
.toLowerCase();
|
|
799
|
+
const id = `${String(i).padStart(4, "0")}_${slug}`;
|
|
800
|
+
blocks.push({ id, checksum, sql });
|
|
801
|
+
});
|
|
802
|
+
return blocks;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
export function emitMigration(_system: IR.IRSystem, schemas: string[]): string {
|
|
806
|
+
const blocks = buildMigrationBlocks(schemas);
|
|
807
|
+
|
|
698
808
|
const lines: string[] = [];
|
|
699
809
|
lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
|
|
810
|
+
lines.push(`// Migration runner with a schema_migrations ledger.`);
|
|
811
|
+
lines.push(`// Each block is hashed at compile time; runs are skipped if the same`);
|
|
812
|
+
lines.push(`// (id, checksum) pair has already been recorded as applied.`);
|
|
700
813
|
lines.push(`require("dotenv").config();`);
|
|
814
|
+
lines.push(`import * as fs from "fs";`);
|
|
815
|
+
lines.push(`import * as path from "path";`);
|
|
816
|
+
lines.push(`import { createHash } from "crypto";`);
|
|
701
817
|
lines.push(`import { pool } from "./db";`);
|
|
702
818
|
lines.push(``);
|
|
819
|
+
lines.push(`interface Block { id: string; checksum: string; sql: string; }`);
|
|
820
|
+
lines.push(``);
|
|
821
|
+
lines.push(`const GENERATED_BLOCKS: Block[] = [`);
|
|
822
|
+
for (const b of blocks) {
|
|
823
|
+
const escaped = b.sql.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$/g, "\\$");
|
|
824
|
+
lines.push(` {`);
|
|
825
|
+
lines.push(` id: ${JSON.stringify(b.id)},`);
|
|
826
|
+
lines.push(` checksum: ${JSON.stringify(b.checksum)},`);
|
|
827
|
+
lines.push(` sql: \`${escaped}\`,`);
|
|
828
|
+
lines.push(` },`);
|
|
829
|
+
}
|
|
830
|
+
lines.push(`];`);
|
|
831
|
+
lines.push(``);
|
|
832
|
+
lines.push(`/**`);
|
|
833
|
+
lines.push(` * Pick up any hand-authored migrations dropped into migrations/_manual/.`);
|
|
834
|
+
lines.push(` * Files are picked up in lexicographic order; \`bonec diff --write\` writes`);
|
|
835
|
+
lines.push(` * them with a numeric prefix so insertion order is stable.`);
|
|
836
|
+
lines.push(` */`);
|
|
837
|
+
lines.push(`function loadManualBlocks(): Block[] {`);
|
|
838
|
+
lines.push(` const dir = path.resolve(__dirname, "..", "migrations", "_manual");`);
|
|
839
|
+
lines.push(` if (!fs.existsSync(dir)) return [];`);
|
|
840
|
+
lines.push(` return fs.readdirSync(dir)`);
|
|
841
|
+
lines.push(` .filter(f => f.endsWith(".sql"))`);
|
|
842
|
+
lines.push(` .sort()`);
|
|
843
|
+
lines.push(` .map(f => {`);
|
|
844
|
+
lines.push(` const sql = fs.readFileSync(path.join(dir, f), "utf-8");`);
|
|
845
|
+
lines.push(` return {`);
|
|
846
|
+
lines.push(` id: "manual_" + f.replace(/\\.sql$/, ""),`);
|
|
847
|
+
lines.push(` checksum: createHash("sha256").update(sql).digest("hex"),`);
|
|
848
|
+
lines.push(` sql,`);
|
|
849
|
+
lines.push(` };`);
|
|
850
|
+
lines.push(` });`);
|
|
851
|
+
lines.push(`}`);
|
|
852
|
+
lines.push(``);
|
|
853
|
+
lines.push(`const LEDGER_DDL = \`CREATE TABLE IF NOT EXISTS schema_migrations (`);
|
|
854
|
+
lines.push(` id VARCHAR PRIMARY KEY,`);
|
|
855
|
+
lines.push(` checksum VARCHAR NOT NULL,`);
|
|
856
|
+
lines.push(` applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),`);
|
|
857
|
+
lines.push(` applied_by VARCHAR NOT NULL DEFAULT current_user,`);
|
|
858
|
+
lines.push(` duration_ms INTEGER NOT NULL DEFAULT 0`);
|
|
859
|
+
lines.push(`);\`;`);
|
|
860
|
+
lines.push(``);
|
|
703
861
|
lines.push(`async function migrate() {`);
|
|
704
|
-
lines.push(` console.log("Running migrations...");`);
|
|
705
862
|
lines.push(` const client = await pool.connect();`);
|
|
863
|
+
lines.push(` let applied = 0, skipped = 0;`);
|
|
706
864
|
lines.push(` try {`);
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
lines.push(`
|
|
865
|
+
lines.push(` await client.query(LEDGER_DDL);`);
|
|
866
|
+
lines.push(``);
|
|
867
|
+
lines.push(` // Generated blocks come first (compile-time schema), then manual diffs.`);
|
|
868
|
+
lines.push(` const allBlocks: Block[] = [...GENERATED_BLOCKS, ...loadManualBlocks()];`);
|
|
869
|
+
lines.push(``);
|
|
870
|
+
lines.push(` // Load already-applied entries.`);
|
|
871
|
+
lines.push(` const existing = await client.query(`);
|
|
872
|
+
lines.push(` "SELECT id, checksum FROM schema_migrations"`);
|
|
873
|
+
lines.push(` );`);
|
|
874
|
+
lines.push(` const rows = existing.rows as Array<{ id: string; checksum: string }>;`);
|
|
875
|
+
lines.push(` const appliedById = new Map(rows.map(r => [r.id, r.checksum]));`);
|
|
876
|
+
lines.push(``);
|
|
877
|
+
lines.push(` for (const block of allBlocks) {`);
|
|
878
|
+
lines.push(` const prior = appliedById.get(block.id);`);
|
|
879
|
+
lines.push(` if (prior === block.checksum) {`);
|
|
880
|
+
lines.push(` skipped++;`);
|
|
881
|
+
lines.push(` continue;`);
|
|
882
|
+
lines.push(` }`);
|
|
883
|
+
lines.push(` if (prior && prior !== block.checksum) {`);
|
|
884
|
+
lines.push(` // Someone changed an already-applied migration. Refuse to proceed —`);
|
|
885
|
+
lines.push(` // an explicit migration file should be authored instead.`);
|
|
886
|
+
lines.push(` throw new Error(`);
|
|
887
|
+
lines.push(" `Migration ${block.id} was previously applied with a different ` +");
|
|
888
|
+
lines.push(" `checksum. Generate a new migration via 'bonec diff --write' ` +");
|
|
889
|
+
lines.push(" `instead of editing existing schemas.`");
|
|
890
|
+
lines.push(` );`);
|
|
891
|
+
lines.push(` }`);
|
|
892
|
+
lines.push(``);
|
|
893
|
+
lines.push(` const start = Date.now();`);
|
|
894
|
+
lines.push(` try {`);
|
|
895
|
+
lines.push(` await client.query("BEGIN");`);
|
|
896
|
+
lines.push(` await client.query(block.sql);`);
|
|
897
|
+
lines.push(` await client.query(`);
|
|
898
|
+
lines.push(` "INSERT INTO schema_migrations (id, checksum, duration_ms) VALUES ($1, $2, $3)",`);
|
|
899
|
+
lines.push(` [block.id, block.checksum, Date.now() - start]`);
|
|
900
|
+
lines.push(` );`);
|
|
901
|
+
lines.push(` await client.query("COMMIT");`);
|
|
902
|
+
lines.push(" console.log(` applied ${block.id} (${Date.now() - start}ms)`);");
|
|
903
|
+
lines.push(` applied++;`);
|
|
904
|
+
lines.push(` } catch (e) {`);
|
|
905
|
+
lines.push(` await client.query("ROLLBACK").catch(() => {});`);
|
|
906
|
+
lines.push(` throw e;`);
|
|
907
|
+
lines.push(` }`);
|
|
908
|
+
lines.push(` }`);
|
|
909
|
+
lines.push(``);
|
|
910
|
+
lines.push(" console.log(`Migrations complete: ${applied} applied, ${skipped} already up to date.`);");
|
|
714
911
|
lines.push(` } finally {`);
|
|
715
912
|
lines.push(` client.release();`);
|
|
716
913
|
lines.push(` await pool.end();`);
|
package/src/emit_websocket.ts
CHANGED
|
@@ -29,7 +29,23 @@ export function emitWebSocketServer(system: IR.IRSystem): string {
|
|
|
29
29
|
lines.push(`import { eventBus } from "./events";`);
|
|
30
30
|
lines.push(`import { logger } from "./logger";`);
|
|
31
31
|
lines.push(``);
|
|
32
|
-
|
|
32
|
+
// Use the same secret-loading rules as the HTTP middleware.
|
|
33
|
+
// Refuse to boot in production without JWT_SECRET; warn in dev.
|
|
34
|
+
lines.push(`const JWT_SECRET = (() => {`);
|
|
35
|
+
lines.push(` const secret = process.env.JWT_SECRET;`);
|
|
36
|
+
lines.push(` if (!secret) {`);
|
|
37
|
+
lines.push(` if (process.env.NODE_ENV === "production") {`);
|
|
38
|
+
lines.push(` console.error("[FATAL] JWT_SECRET environment variable is not set. Refusing to start in production.");`);
|
|
39
|
+
lines.push(` process.exit(1);`);
|
|
40
|
+
lines.push(` }`);
|
|
41
|
+
lines.push(` console.warn("[WARN] JWT_SECRET is not set. Using insecure default — do not use in production.");`);
|
|
42
|
+
lines.push(` return "bonescript-dev-secret-do-not-use-in-production";`);
|
|
43
|
+
lines.push(` }`);
|
|
44
|
+
lines.push(` if (secret.length < 32) {`);
|
|
45
|
+
lines.push(` console.warn("[WARN] JWT_SECRET is shorter than 32 characters. Use a longer secret in production.");`);
|
|
46
|
+
lines.push(` }`);
|
|
47
|
+
lines.push(` return secret;`);
|
|
48
|
+
lines.push(`})();`);
|
|
33
49
|
lines.push(``);
|
|
34
50
|
// Redis pub/sub for multi-instance support
|
|
35
51
|
lines.push(`// Redis pub/sub for multi-instance WebSocket broadcasting`);
|
|
@@ -117,7 +133,11 @@ export function emitWebSocketServer(system: IR.IRSystem): string {
|
|
|
117
133
|
lines.push(``);
|
|
118
134
|
lines.push(` let userId: string;`);
|
|
119
135
|
lines.push(` try {`);
|
|
120
|
-
lines.push(` const decoded = jwt.verify(token, JWT_SECRET
|
|
136
|
+
lines.push(` const decoded = jwt.verify(token, JWT_SECRET, {`);
|
|
137
|
+
lines.push(` algorithms: ["HS256"],`);
|
|
138
|
+
lines.push(` maxAge: process.env.JWT_MAX_AGE || "1h",`);
|
|
139
|
+
lines.push(` }) as { sub?: unknown };`);
|
|
140
|
+
lines.push(` if (typeof decoded.sub !== "string" || decoded.sub.length === 0) throw new Error("invalid sub");`);
|
|
121
141
|
lines.push(` userId = decoded.sub;`);
|
|
122
142
|
lines.push(` } catch {`);
|
|
123
143
|
lines.push(` socket.send(JSON.stringify({ type: "error", message: "Authentication failed" }));`);
|
package/src/emit_zod.ts
CHANGED
|
@@ -62,9 +62,13 @@ export function emitZodSchemas(system: IR.IRSystem): string {
|
|
|
62
62
|
lines.push(`import { z } from "zod";`);
|
|
63
63
|
lines.push("");
|
|
64
64
|
|
|
65
|
-
// Model schemas
|
|
65
|
+
// Model schemas — dedupe by name since the same entity can appear in both
|
|
66
|
+
// an api_service module and its backing data_store module.
|
|
67
|
+
const seenModels = new Set<string>();
|
|
66
68
|
for (const mod of system.modules) {
|
|
67
69
|
for (const model of mod.models) {
|
|
70
|
+
if (seenModels.has(model.name)) continue;
|
|
71
|
+
seenModels.add(model.name);
|
|
68
72
|
const schemaName = toPascalCase(model.name) + "Schema";
|
|
69
73
|
const typeName = toPascalCase(model.name);
|
|
70
74
|
|
|
@@ -79,6 +83,12 @@ export function emitZodSchemas(system: IR.IRSystem): string {
|
|
|
79
83
|
}
|
|
80
84
|
lines.push(`});`);
|
|
81
85
|
lines.push(`export type ${typeName} = z.infer<typeof ${schemaName}>;`);
|
|
86
|
+
// Derived schemas for CRUD validation. `omit` drops server-managed fields
|
|
87
|
+
// and `partial` makes every key optional for PUT.
|
|
88
|
+
const hasState = model.fields.some(f => f.name === "state");
|
|
89
|
+
const createPartial = hasState ? ".partial({ state: true })" : "";
|
|
90
|
+
lines.push(`export const ${toPascalCase(model.name)}CreateSchema = ${schemaName}.omit({ id: true, created_at: true, updated_at: true })${createPartial};`);
|
|
91
|
+
lines.push(`export const ${toPascalCase(model.name)}UpdateSchema = ${schemaName}.omit({ id: true, created_at: true, updated_at: true }).partial();`);
|
|
82
92
|
lines.push("");
|
|
83
93
|
}
|
|
84
94
|
}
|
package/src/formatter.ts
CHANGED
|
@@ -76,7 +76,7 @@ export class Formatter {
|
|
|
76
76
|
|
|
77
77
|
if (e.owns.length > 0) {
|
|
78
78
|
if (e.owns.length <= 2) {
|
|
79
|
-
const fields = e.owns.map(f =>
|
|
79
|
+
const fields = e.owns.map(f => this.formatField(f)).join(", ");
|
|
80
80
|
this.line(`owns: [${fields}]`);
|
|
81
81
|
} else {
|
|
82
82
|
this.line(`owns: [`);
|
|
@@ -84,7 +84,7 @@ export class Formatter {
|
|
|
84
84
|
for (let i = 0; i < e.owns.length; i++) {
|
|
85
85
|
const f = e.owns[i];
|
|
86
86
|
const comma = i < e.owns.length - 1 ? "," : "";
|
|
87
|
-
this.line(`${
|
|
87
|
+
this.line(`${this.formatField(f)}${comma}`);
|
|
88
88
|
}
|
|
89
89
|
this.indent--;
|
|
90
90
|
this.line(`]`);
|
|
@@ -264,6 +264,13 @@ export class Formatter {
|
|
|
264
264
|
}
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
private formatField(f: AST.FieldNode): string {
|
|
268
|
+
let s = `${f.name}: ${this.formatType(f.type)}`;
|
|
269
|
+
if (f.renamedFrom) s += ` @renamed_from(${f.renamedFrom})`;
|
|
270
|
+
if (f.sensitive) s += ` @sensitive`;
|
|
271
|
+
return s;
|
|
272
|
+
}
|
|
273
|
+
|
|
267
274
|
private formatExpr(e: AST.ExprNode): string {
|
|
268
275
|
switch (e.kind) {
|
|
269
276
|
case "Literal":
|
package/src/index.ts
CHANGED
|
@@ -48,6 +48,9 @@ export { emitPostmanCollection } from "./emit_postman";
|
|
|
48
48
|
export { emitSeedFile } from "./emit_seed";
|
|
49
49
|
export { emitAuditSchema, emitAuditMiddleware } from "./emit_audit";
|
|
50
50
|
export { emitAdminPanel } from "./emit_admin";
|
|
51
|
+
export { emitNotifyService } from "./emit_notify";
|
|
52
|
+
export { emitCronJobs } from "./emit_cron";
|
|
53
|
+
export { emitGraphQLSchema } from "./emit_graphql";
|
|
51
54
|
|
|
52
55
|
/**
|
|
53
56
|
* Convenience function: compile a .bone source string to files.
|
package/src/ir.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface IRField {
|
|
|
17
17
|
unique: boolean;
|
|
18
18
|
indexed: boolean;
|
|
19
19
|
default_value: string | null;
|
|
20
|
+
renamed_from?: string | null;
|
|
21
|
+
sensitive?: boolean;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
// ââ€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬ Models ââ€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬
|
package/src/lexer.ts
CHANGED
|
@@ -25,6 +25,7 @@ export enum TokenKind {
|
|
|
25
25
|
Arrow = "Arrow",
|
|
26
26
|
Pipe = "Pipe",
|
|
27
27
|
Semicolon = "Semicolon",
|
|
28
|
+
At = "At",
|
|
28
29
|
|
|
29
30
|
// Operators
|
|
30
31
|
Equals = "Equals",
|
|
@@ -519,6 +520,7 @@ export class Lexer {
|
|
|
519
520
|
case "?": this.advance(); return { kind: TokenKind.Question, value: "?", loc };
|
|
520
521
|
case "!": this.advance(); return { kind: TokenKind.Bang, value: "!", loc };
|
|
521
522
|
case ";": this.advance(); return { kind: TokenKind.Semicolon, value: ";", loc };
|
|
523
|
+
case "@": this.advance(); return { kind: TokenKind.At, value: "@", loc };
|
|
522
524
|
}
|
|
523
525
|
|
|
524
526
|
// String literal
|
package/src/lowering.ts
CHANGED
|
@@ -380,9 +380,9 @@ export class Lowering {
|
|
|
380
380
|
config: {
|
|
381
381
|
authenticated: entity.auth !== null && entity.auth !== "none",
|
|
382
382
|
auth_method: entity.auth || "none",
|
|
383
|
-
audit: policies.some(p => p.audit === true),
|
|
384
|
-
rate_limit: policies.length > 0 && policies[0].rateLimit ? policies[0].rateLimit.count : 0,
|
|
385
|
-
rate_limit_window_ms: policies.length > 0 && policies[0].rateLimit ? (parseDurationMs(String(policies[0].rateLimit.per)) || 60000) : 60000,
|
|
383
|
+
audit: policies.some(p => p.audit === true),
|
|
384
|
+
rate_limit: policies.length > 0 && policies[0].rateLimit ? policies[0].rateLimit.count : 0,
|
|
385
|
+
rate_limit_window_ms: policies.length > 0 && policies[0].rateLimit ? (parseDurationMs(String(policies[0].rateLimit.per)) || 60000) : 60000,
|
|
386
386
|
},
|
|
387
387
|
};
|
|
388
388
|
}
|
|
@@ -554,6 +554,8 @@ export class Lowering {
|
|
|
554
554
|
unique: false,
|
|
555
555
|
indexed: false,
|
|
556
556
|
default_value: f.defaultValue ? serializeExpr(f.defaultValue) : null,
|
|
557
|
+
renamed_from: f.renamedFrom ?? null,
|
|
558
|
+
sensitive: f.sensitive ?? false,
|
|
557
559
|
};
|
|
558
560
|
}
|
|
559
561
|
}
|
package/src/parse_decls.ts
CHANGED
|
@@ -33,7 +33,37 @@ export function parseField(s: TokenStream): AST.FieldNode {
|
|
|
33
33
|
const type = parseTypeExpr(s);
|
|
34
34
|
let defaultValue: AST.ExprNode | null = null;
|
|
35
35
|
if (s.match(TokenKind.Equals)) { defaultValue = parseExpr(s); }
|
|
36
|
-
|
|
36
|
+
// Optional annotations: @renamed_from(old_name), @sensitive
|
|
37
|
+
let renamedFrom: string | null = null;
|
|
38
|
+
let sensitive = false;
|
|
39
|
+
while (s.check(TokenKind.At)) {
|
|
40
|
+
s.advance();
|
|
41
|
+
const annoName = parseIdentOrKeyword(s);
|
|
42
|
+
if (annoName === "renamed_from") {
|
|
43
|
+
s.expect(TokenKind.LParen, "renamed_from(old_name)");
|
|
44
|
+
renamedFrom = parseIdentOrKeyword(s);
|
|
45
|
+
s.expect(TokenKind.RParen, "renamed_from close");
|
|
46
|
+
} else if (annoName === "sensitive") {
|
|
47
|
+
// Bare flag annotation. Optional empty parens for forward compat.
|
|
48
|
+
sensitive = true;
|
|
49
|
+
if (s.match(TokenKind.LParen)) {
|
|
50
|
+
s.expect(TokenKind.RParen, "sensitive close");
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
// Unknown annotation — accept and ignore for forward compat; consume
|
|
54
|
+
// an optional (...) payload so it parses cleanly.
|
|
55
|
+
if (s.match(TokenKind.LParen)) {
|
|
56
|
+
let depth = 1;
|
|
57
|
+
while (depth > 0 && !s.check(TokenKind.EOF)) {
|
|
58
|
+
if (s.check(TokenKind.LParen)) depth++;
|
|
59
|
+
else if (s.check(TokenKind.RParen)) depth--;
|
|
60
|
+
if (depth > 0) s.advance();
|
|
61
|
+
}
|
|
62
|
+
s.expect(TokenKind.RParen, "annotation close");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { kind: "Field", loc, name, type, defaultValue, renamedFrom, sensitive };
|
|
37
67
|
}
|
|
38
68
|
|
|
39
69
|
export function parseIdentList(s: TokenStream): string[] {
|
package/src/scaffold.ts
CHANGED
|
@@ -15,7 +15,9 @@ export type ScaffoldDomain =
|
|
|
15
15
|
| "realtime_collaboration";
|
|
16
16
|
|
|
17
17
|
const TEMPLATES: Record<ScaffoldDomain, string> = {
|
|
18
|
-
multiplayer_game:
|
|
18
|
+
multiplayer_game: `// Compile with: bonec compile {name}.bone --target nakama
|
|
19
|
+
// for Nakama TypeScript runtime output instead of Express/PostgreSQL
|
|
20
|
+
system MyGame {
|
|
19
21
|
domain: multiplayer_game
|
|
20
22
|
|
|
21
23
|
entity Player {
|
package/src/typechecker.ts
CHANGED
|
@@ -404,6 +404,16 @@ export class TypeChecker {
|
|
|
404
404
|
const path = expr.path;
|
|
405
405
|
if (path.length === 0) return null;
|
|
406
406
|
|
|
407
|
+
// Built-in: `caller` resolves to the authenticated actor's identity.
|
|
408
|
+
// `caller.id` is a uuid; bare `caller` is a record { id: uuid } for now.
|
|
409
|
+
if (path[0] === "caller") {
|
|
410
|
+
const callerType = record("Caller", new Map([
|
|
411
|
+
["id", prim("uuid")],
|
|
412
|
+
["actor_id", prim("uuid")],
|
|
413
|
+
]));
|
|
414
|
+
return this.resolveFieldPath(callerType, path.slice(1), expr);
|
|
415
|
+
}
|
|
416
|
+
|
|
407
417
|
// First segment: look up in context
|
|
408
418
|
let currentType = ctx.lookup(path[0]);
|
|
409
419
|
|