bonescript-compiler 0.5.6 → 0.5.8
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/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/emit_admin.d.ts +7 -0
- package/dist/emit_admin.js +131 -0
- package/dist/emit_admin.js.map +1 -0
- package/dist/emit_audit.js +2 -2
- package/dist/emit_audit.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 +31 -5
- 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_notify.d.ts +6 -0
- package/dist/emit_notify.js +85 -0
- package/dist/emit_notify.js.map +1 -0
- package/dist/emit_runtime.js +35 -6
- package/dist/emit_runtime.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/lowering.js +5 -2
- package/dist/lowering.js.map +1 -1
- package/dist/scaffold.js +3 -1
- package/dist/scaffold.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +2 -2
- package/src/emit_admin.ts +132 -0
- package/src/emit_audit.ts +2 -2
- package/src/emit_cron.ts +70 -0
- package/src/emit_full.ts +40 -5
- package/src/emit_graphql.ts +161 -0
- package/src/emit_notify.ts +88 -0
- package/src/emit_runtime.ts +36 -6
- package/src/index.ts +4 -0
- package/src/lowering.ts +5 -2
- package/src/scaffold.ts +3 -1
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BoneScript Notification Service Emitter
|
|
3
|
+
* Generates src/notify.ts — fires on event emissions.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as IR from "./ir";
|
|
7
|
+
|
|
8
|
+
function toPascalCase(s: string): string {
|
|
9
|
+
return s.replace(/(^|[-_\s])(\w)/g, (_, __, c: string) => c.toUpperCase());
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function emitNotifyService(system: IR.IRSystem): string {
|
|
13
|
+
const lines: string[] = [];
|
|
14
|
+
|
|
15
|
+
lines.push(`// Generated by BoneScript compiler.`);
|
|
16
|
+
lines.push(`// Notification service — fires on event emissions.`);
|
|
17
|
+
lines.push(`// Configure NOTIFY_PROVIDER=resend|sendgrid|log (default: log)`);
|
|
18
|
+
lines.push(`// Set NOTIFY_API_KEY and NOTIFY_FROM_EMAIL in .env`);
|
|
19
|
+
lines.push(``);
|
|
20
|
+
lines.push(`import { SystemEvent } from "./events";`);
|
|
21
|
+
lines.push(``);
|
|
22
|
+
lines.push(`export type NotifyProvider = "resend" | "sendgrid" | "log";`);
|
|
23
|
+
lines.push(``);
|
|
24
|
+
lines.push(`const PROVIDER = (process.env.NOTIFY_PROVIDER || "log") as NotifyProvider;`);
|
|
25
|
+
lines.push(`const API_KEY = process.env.NOTIFY_API_KEY || "";`);
|
|
26
|
+
lines.push(`const FROM_EMAIL = process.env.NOTIFY_FROM_EMAIL || "noreply@example.com";`);
|
|
27
|
+
lines.push(``);
|
|
28
|
+
lines.push(`export interface NotifyMessage {`);
|
|
29
|
+
lines.push(` to: string;`);
|
|
30
|
+
lines.push(` subject: string;`);
|
|
31
|
+
lines.push(` body: string;`);
|
|
32
|
+
lines.push(`}`);
|
|
33
|
+
lines.push(``);
|
|
34
|
+
lines.push(`async function sendEmail(msg: NotifyMessage): Promise<void> {`);
|
|
35
|
+
lines.push(` if (PROVIDER === "log") {`);
|
|
36
|
+
lines.push(` console.log(\`[notify] \${msg.subject} → \${msg.to}\`);`);
|
|
37
|
+
lines.push(` return;`);
|
|
38
|
+
lines.push(` }`);
|
|
39
|
+
lines.push(` if (PROVIDER === "resend") {`);
|
|
40
|
+
lines.push(` const res = await fetch("https://api.resend.com/emails", {`);
|
|
41
|
+
lines.push(` method: "POST",`);
|
|
42
|
+
lines.push(` headers: { "Authorization": \`Bearer \${API_KEY}\`, "Content-Type": "application/json" },`);
|
|
43
|
+
lines.push(` body: JSON.stringify({ from: FROM_EMAIL, to: msg.to, subject: msg.subject, html: msg.body }),`);
|
|
44
|
+
lines.push(` });`);
|
|
45
|
+
lines.push(` if (!res.ok) throw new Error(\`Resend error: \${res.status}\`);`);
|
|
46
|
+
lines.push(` return;`);
|
|
47
|
+
lines.push(` }`);
|
|
48
|
+
lines.push(` if (PROVIDER === "sendgrid") {`);
|
|
49
|
+
lines.push(` const res = await fetch("https://api.sendgrid.com/v3/mail/send", {`);
|
|
50
|
+
lines.push(` method: "POST",`);
|
|
51
|
+
lines.push(` headers: { "Authorization": \`Bearer \${API_KEY}\`, "Content-Type": "application/json" },`);
|
|
52
|
+
lines.push(` body: JSON.stringify({ personalizations: [{ to: [{ email: msg.to }] }], from: { email: FROM_EMAIL }, subject: msg.subject, content: [{ type: "text/html", value: msg.body }] }),`);
|
|
53
|
+
lines.push(` });`);
|
|
54
|
+
lines.push(` if (!res.ok) throw new Error(\`SendGrid error: \${res.status}\`);`);
|
|
55
|
+
lines.push(` return;`);
|
|
56
|
+
lines.push(` }`);
|
|
57
|
+
lines.push(`}`);
|
|
58
|
+
lines.push(``);
|
|
59
|
+
|
|
60
|
+
// Per-event handlers
|
|
61
|
+
for (const event of system.events) {
|
|
62
|
+
const eventName = toPascalCase(event.name);
|
|
63
|
+
lines.push(`// Handler for ${eventName}`);
|
|
64
|
+
lines.push(`export async function notify${eventName}(event: SystemEvent, recipientEmail: string): Promise<void> {`);
|
|
65
|
+
lines.push(` await sendEmail({`);
|
|
66
|
+
lines.push(` to: recipientEmail,`);
|
|
67
|
+
lines.push(` subject: "${eventName} notification",`);
|
|
68
|
+
lines.push(` body: \`<p>Event <strong>${eventName}</strong> occurred.</p><pre>\${JSON.stringify(event.payload, null, 2)}</pre>\`,`);
|
|
69
|
+
lines.push(` });`);
|
|
70
|
+
lines.push(`}`);
|
|
71
|
+
lines.push(``);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// registerNotificationHandlers
|
|
75
|
+
lines.push(`export function registerNotificationHandlers(eventBus: any): void {`);
|
|
76
|
+
lines.push(` // TODO: implement recipient lookup per event type`);
|
|
77
|
+
for (const event of system.events) {
|
|
78
|
+
const eventName = toPascalCase(event.name);
|
|
79
|
+
lines.push(` // eventBus.subscribe("${eventName}", async (event: SystemEvent) => {`);
|
|
80
|
+
lines.push(` // const recipientEmail = await lookupRecipient(event);`);
|
|
81
|
+
lines.push(` // await notify${eventName}(event, recipientEmail);`);
|
|
82
|
+
lines.push(` // });`);
|
|
83
|
+
}
|
|
84
|
+
lines.push(`}`);
|
|
85
|
+
lines.push(``);
|
|
86
|
+
|
|
87
|
+
return lines.join("\n");
|
|
88
|
+
}
|
package/src/emit_runtime.ts
CHANGED
|
@@ -57,6 +57,7 @@ export function emitPackageJson(system: IR.IRSystem): string {
|
|
|
57
57
|
"express-rate-limit": "7.1.5",
|
|
58
58
|
jsonwebtoken: "9.0.2",
|
|
59
59
|
dotenv: "16.3.1",
|
|
60
|
+
"node-cron": "3.0.3",
|
|
60
61
|
},
|
|
61
62
|
devDependencies: {
|
|
62
63
|
"@types/express": "4.17.21",
|
|
@@ -66,6 +67,7 @@ export function emitPackageJson(system: IR.IRSystem): string {
|
|
|
66
67
|
"@types/cors": "2.8.17",
|
|
67
68
|
"@types/jsonwebtoken": "9.0.5",
|
|
68
69
|
"@types/uuid": "9.0.7",
|
|
70
|
+
"@types/node-cron": "3.0.11",
|
|
69
71
|
typescript: "5.3.3",
|
|
70
72
|
"ts-node": "10.9.2",
|
|
71
73
|
},
|
|
@@ -217,6 +219,11 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
217
219
|
lines.push(`import { query, queryOne, execute, pool } from "../db";`);
|
|
218
220
|
lines.push(`import { eventBus } from "../events";`);
|
|
219
221
|
lines.push(`import { requireAuth, AuthContext } from "../auth";`);
|
|
222
|
+
lines.push(`import rateLimit from "express-rate-limit";`);
|
|
223
|
+
// Only import audit if module has audit: true
|
|
224
|
+
if (mod.config["audit"]) {
|
|
225
|
+
lines.push(`import { auditLog } from "../audit";`);
|
|
226
|
+
}
|
|
220
227
|
lines.push(`import { logger } from "../logger";`);
|
|
221
228
|
lines.push(`import { counter } from "../metrics";`);
|
|
222
229
|
lines.push(`import * as __algorithms from "../algorithms";`);
|
|
@@ -242,10 +249,22 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
242
249
|
lines.push(`export const ${toCamelCase(routeBase)}Router = Router();`);
|
|
243
250
|
lines.push(``);
|
|
244
251
|
|
|
252
|
+
// Per-module rate limiter (from policy declaration)
|
|
253
|
+
const modRateLimit = typeof mod.config["rate_limit"] === "number" && (mod.config["rate_limit"] as number) > 0
|
|
254
|
+
? mod.config["rate_limit"] as number : 0;
|
|
255
|
+
const modRateLimitWindowMs = typeof mod.config["rate_limit_window_ms"] === "number"
|
|
256
|
+
? mod.config["rate_limit_window_ms"] as number : 60000;
|
|
257
|
+
if (modRateLimit > 0) {
|
|
258
|
+
lines.push(`// Rate limiter from policy declaration`);
|
|
259
|
+
lines.push(`const __routeRateLimit = rateLimit({ windowMs: ${modRateLimitWindowMs}, max: ${modRateLimit}, standardHeaders: true, legacyHeaders: false });`);
|
|
260
|
+
lines.push(``);
|
|
261
|
+
}
|
|
262
|
+
|
|
245
263
|
// CREATE
|
|
246
264
|
const insertFields = entityModel.fields.filter(f => f.name !== "id" && f.name !== "created_at" && f.name !== "updated_at");
|
|
247
265
|
lines.push(`// CREATE`);
|
|
248
|
-
|
|
266
|
+
const __crudMiddlewares = modRateLimit > 0 ? "__routeRateLimit, requireAuth" : "requireAuth";
|
|
267
|
+
lines.push(`${toCamelCase(routeBase)}Router.post("/", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
|
|
249
268
|
lines.push(` try {`);
|
|
250
269
|
lines.push(` const id = uuid();`);
|
|
251
270
|
lines.push(` const { ${insertFields.map(f => f.name).join(", ")} } = req.body;`);
|
|
@@ -270,7 +289,7 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
270
289
|
|
|
271
290
|
// READ
|
|
272
291
|
lines.push(`// READ`);
|
|
273
|
-
lines.push(`${toCamelCase(routeBase)}Router.get("/:id",
|
|
292
|
+
lines.push(`${toCamelCase(routeBase)}Router.get("/:id", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
|
|
274
293
|
lines.push(` try {`);
|
|
275
294
|
lines.push(` const row = await queryOne(\`SELECT * FROM ${tableName} WHERE id = $1\`, [req.params.id]);`);
|
|
276
295
|
lines.push(` if (!row) return res.status(404).json({ error: { code: "NOT_FOUND", message: "Not found" } });`);
|
|
@@ -284,7 +303,7 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
284
303
|
// LIST — with optional JOINs for has_one/belongs_to relations
|
|
285
304
|
const joinRelations = mod.relations.filter(r => r.kind === "has_one" || r.kind === "belongs_to");
|
|
286
305
|
lines.push(`// LIST`);
|
|
287
|
-
lines.push(`${toCamelCase(routeBase)}Router.get("/",
|
|
306
|
+
lines.push(`${toCamelCase(routeBase)}Router.get("/", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
|
|
288
307
|
lines.push(` try {`);
|
|
289
308
|
lines.push(` const page = parseInt(req.query.page as string) || 1;`);
|
|
290
309
|
lines.push(` const pageSize = Math.min(parseInt(req.query.page_size as string) || 50, 100);`);
|
|
@@ -314,7 +333,7 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
314
333
|
|
|
315
334
|
// UPDATE — with state machine enforcement
|
|
316
335
|
lines.push(`// UPDATE`);
|
|
317
|
-
lines.push(`${toCamelCase(routeBase)}Router.put("/:id",
|
|
336
|
+
lines.push(`${toCamelCase(routeBase)}Router.put("/:id", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
|
|
318
337
|
lines.push(` const fields = { ...req.body };`);
|
|
319
338
|
if (mod.state_machines.length > 0) {
|
|
320
339
|
const sm = mod.state_machines[0];
|
|
@@ -339,7 +358,7 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
339
358
|
|
|
340
359
|
// DELETE
|
|
341
360
|
lines.push(`// DELETE`);
|
|
342
|
-
lines.push(`${toCamelCase(routeBase)}Router.delete("/:id",
|
|
361
|
+
lines.push(`${toCamelCase(routeBase)}Router.delete("/:id", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
|
|
343
362
|
lines.push(` try {`);
|
|
344
363
|
lines.push(` const count = await execute(\`DELETE FROM ${tableName} WHERE id = $1\`, [req.params.id]);`);
|
|
345
364
|
lines.push(` if (count === 0) return res.status(404).json({ error: { code: "NOT_FOUND", message: "Not found" } });`);
|
|
@@ -400,7 +419,15 @@ function emitCapabilityEndpoint(method: IR.IRMethod, mod: IR.IRModule, tableName
|
|
|
400
419
|
const isTransactional = method.sync === "transactional";
|
|
401
420
|
|
|
402
421
|
lines.push(`// CAPABILITY: ${method.name}${isTransactional ? " [transactional]" : ""}${method.retry ? ` [retry: ${method.retry.max_attempts}x ${method.retry.backoff}]` : ""}`);
|
|
403
|
-
|
|
422
|
+
// Build middleware chain: optional rate limiter + requireAuth + optional audit
|
|
423
|
+
const capMiddlewares: string[] = ["requireAuth"];
|
|
424
|
+
if (typeof mod.config["rate_limit"] === "number" && (mod.config["rate_limit"] as number) > 0) {
|
|
425
|
+
capMiddlewares.unshift("__routeRateLimit");
|
|
426
|
+
}
|
|
427
|
+
if (mod.config["audit"]) {
|
|
428
|
+
capMiddlewares.push(`auditLog("${method.name}", "${mod.models[0]?.name ?? ""}")`);
|
|
429
|
+
}
|
|
430
|
+
lines.push(`${routerName}.post("${endpoint}", ${capMiddlewares.join(", ")}, async (req: Request, res: Response) => {`);
|
|
404
431
|
lines.push(` const auth: AuthContext = (req as any).auth;`);
|
|
405
432
|
|
|
406
433
|
// Wrap in retry logic if declared
|
|
@@ -551,6 +578,7 @@ export function emitIndex(system: IR.IRSystem): string {
|
|
|
551
578
|
);
|
|
552
579
|
if (hasBatch) {
|
|
553
580
|
lines.push(`import { startBatchWorker } from "./batch";`);
|
|
581
|
+
lines.push(`import { startCronJobs } from "./cron";`);
|
|
554
582
|
}
|
|
555
583
|
if (hasWebSocket) {
|
|
556
584
|
lines.push(`import { setupWebSocketServer } from "./websocket";`);
|
|
@@ -628,6 +656,8 @@ export function emitIndex(system: IR.IRSystem): string {
|
|
|
628
656
|
if (hasBatch) {
|
|
629
657
|
lines.push(` // Start batch executor`);
|
|
630
658
|
lines.push(` startBatchWorker();`);
|
|
659
|
+
lines.push(` // Start cron jobs`);
|
|
660
|
+
lines.push(` startCronJobs();`);
|
|
631
661
|
}
|
|
632
662
|
lines.push(` console.log(\`[${system.name}] Running on port \${PORT}\`);`);
|
|
633
663
|
lines.push(` console.log(\` HTTP routes:\`);`);
|
package/src/index.ts
CHANGED
|
@@ -47,6 +47,10 @@ export { emitZodSchemas } from "./emit_zod";
|
|
|
47
47
|
export { emitPostmanCollection } from "./emit_postman";
|
|
48
48
|
export { emitSeedFile } from "./emit_seed";
|
|
49
49
|
export { emitAuditSchema, emitAuditMiddleware } from "./emit_audit";
|
|
50
|
+
export { emitAdminPanel } from "./emit_admin";
|
|
51
|
+
export { emitNotifyService } from "./emit_notify";
|
|
52
|
+
export { emitCronJobs } from "./emit_cron";
|
|
53
|
+
export { emitGraphQLSchema } from "./emit_graphql";
|
|
50
54
|
|
|
51
55
|
/**
|
|
52
56
|
* Convenience function: compile a .bone source string to files.
|
package/src/lowering.ts
CHANGED
|
@@ -118,7 +118,7 @@ export class Lowering {
|
|
|
118
118
|
const relatedCaps = capabilities.filter(c =>
|
|
119
119
|
c.params.some(p => p.type.kind === "EntityRefType" && p.type.name === entity.name)
|
|
120
120
|
);
|
|
121
|
-
modules.push(this.lowerEntity(entity, relatedCaps, stores));
|
|
121
|
+
modules.push(this.lowerEntity(entity, relatedCaps, stores, policies));
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
// Lower channels → realtime_service modules
|
|
@@ -233,7 +233,7 @@ export class Lowering {
|
|
|
233
233
|
|
|
234
234
|
// ââ€Âۉâ€Âۉâ€Â€ Entity Lowering ââ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Âۉâ€Â€
|
|
235
235
|
|
|
236
|
-
private lowerEntity(entity: AST.EntityDeclNode, capabilities: AST.CapabilityDeclNode[], stores: AST.StoreDeclNode[]): IR.IRModule {
|
|
236
|
+
private lowerEntity(entity: AST.EntityDeclNode, capabilities: AST.CapabilityDeclNode[], stores: AST.StoreDeclNode[], policies: AST.PolicyDeclNode[] = []): IR.IRModule {
|
|
237
237
|
const moduleId = makeId(this.systemName, "api_service", `${entity.name}Service`);
|
|
238
238
|
|
|
239
239
|
// Build model from entity fields + ontology entailments
|
|
@@ -380,6 +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
386
|
},
|
|
384
387
|
};
|
|
385
388
|
}
|
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 {
|