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.
@@ -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
+ }
@@ -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
- lines.push(`${toCamelCase(routeBase)}Router.post("/", requireAuth, async (req: Request, res: Response) => {`);
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", requireAuth, async (req: Request, res: Response) => {`);
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("/", requireAuth, async (req: Request, res: Response) => {`);
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", requireAuth, async (req: Request, res: Response) => {`);
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", requireAuth, async (req: Request, res: Response) => {`);
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
- lines.push(`${routerName}.post("${endpoint}", requireAuth, async (req: Request, res: Response) => {`);
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: `system MyGame {
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 {