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.
Files changed (71) hide show
  1. package/dist/ast.d.ts +2 -0
  2. package/dist/cli.js +53 -9
  3. package/dist/cli.js.map +1 -1
  4. package/dist/emit_admin.d.ts +5 -0
  5. package/dist/emit_admin.js +341 -35
  6. package/dist/emit_admin.js.map +1 -1
  7. package/dist/emit_audit.js +40 -6
  8. package/dist/emit_audit.js.map +1 -1
  9. package/dist/emit_capability.js +14 -0
  10. package/dist/emit_capability.js.map +1 -1
  11. package/dist/emit_cron.d.ts +6 -0
  12. package/dist/emit_cron.js +66 -0
  13. package/dist/emit_cron.js.map +1 -0
  14. package/dist/emit_full.d.ts +6 -1
  15. package/dist/emit_full.js +38 -7
  16. package/dist/emit_full.js.map +1 -1
  17. package/dist/emit_graphql.d.ts +6 -0
  18. package/dist/emit_graphql.js +140 -0
  19. package/dist/emit_graphql.js.map +1 -0
  20. package/dist/emit_maintenance.js +35 -3
  21. package/dist/emit_maintenance.js.map +1 -1
  22. package/dist/emit_notify.d.ts +6 -0
  23. package/dist/emit_notify.js +85 -0
  24. package/dist/emit_notify.js.map +1 -0
  25. package/dist/emit_runtime.d.ts +18 -1
  26. package/dist/emit_runtime.js +217 -32
  27. package/dist/emit_runtime.js.map +1 -1
  28. package/dist/emit_websocket.js +22 -2
  29. package/dist/emit_websocket.js.map +1 -1
  30. package/dist/emit_zod.js +12 -1
  31. package/dist/emit_zod.js.map +1 -1
  32. package/dist/formatter.d.ts +1 -0
  33. package/dist/formatter.js +10 -2
  34. package/dist/formatter.js.map +1 -1
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.js +7 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/ir.d.ts +2 -0
  39. package/dist/lexer.d.ts +1 -0
  40. package/dist/lexer.js +4 -0
  41. package/dist/lexer.js.map +1 -1
  42. package/dist/lowering.js +2 -0
  43. package/dist/lowering.js.map +1 -1
  44. package/dist/parse_decls.js +36 -1
  45. package/dist/parse_decls.js.map +1 -1
  46. package/dist/scaffold.js +3 -1
  47. package/dist/scaffold.js.map +1 -1
  48. package/dist/typechecker.js +9 -0
  49. package/dist/typechecker.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/ast.ts +2 -0
  52. package/src/cli.ts +59 -11
  53. package/src/emit_admin.ts +343 -35
  54. package/src/emit_audit.ts +42 -6
  55. package/src/emit_capability.ts +13 -0
  56. package/src/emit_cron.ts +70 -0
  57. package/src/emit_full.ts +45 -7
  58. package/src/emit_graphql.ts +161 -0
  59. package/src/emit_maintenance.ts +35 -3
  60. package/src/emit_notify.ts +88 -0
  61. package/src/emit_runtime.ts +229 -32
  62. package/src/emit_websocket.ts +22 -2
  63. package/src/emit_zod.ts +11 -1
  64. package/src/formatter.ts +9 -2
  65. package/src/index.ts +3 -0
  66. package/src/ir.ts +2 -0
  67. package/src/lexer.ts +2 -0
  68. package/src/lowering.ts +5 -3
  69. package/src/parse_decls.ts +31 -1
  70. package/src/scaffold.ts +3 -1
  71. package/src/typechecker.ts +10 -0
package/src/emit_full.ts CHANGED
@@ -39,15 +39,24 @@ import { emitPostmanCollection } from "./emit_postman";
39
39
  import { emitSeedFile } from "./emit_seed";
40
40
  import { emitAuditSchema, emitAuditMiddleware } from "./emit_audit";
41
41
  import { emitAdminPanel } from "./emit_admin";
42
+ import { emitNotifyService } from "./emit_notify";
43
+ import { emitCronJobs } from "./emit_cron";
44
+ import { emitGraphQLSchema } from "./emit_graphql";
42
45
 
43
46
  function toSnakeCase(s: string): string {
44
47
  return s.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
45
48
  }
46
49
 
50
+ export interface FullEmitterOptions {
51
+ noSdk?: boolean;
52
+ noOpenApi?: boolean;
53
+ noSeed?: boolean;
54
+ }
55
+
47
56
  export class FullEmitter {
48
57
  private schemaEmitter = new Emitter();
49
58
 
50
- emit(system: IR.IRSystem): EmittedFile[] {
59
+ emit(system: IR.IRSystem, options: FullEmitterOptions = {}): EmittedFile[] {
51
60
  const files: EmittedFile[] = [];
52
61
 
53
62
  // 1. Package files
@@ -163,15 +172,22 @@ export class FullEmitter {
163
172
  // 5. Source: main entry point
164
173
  files.push({ path: "src/index.ts", content: emitIndex(system), language: "typescript", source_module: "root" });
165
174
 
166
- // 6. SQL migrations — run schema emitter ONCE, then match by model name
175
+ // 6. SQL migrations — run schema emitter ONCE, then match by model name.
176
+ // Multiple modules (e.g. an api_service AND its backing data_store) can
177
+ // reference the same model. We dedupe by output path so each table only
178
+ // appears once in migrations/ and once in the migrate.ts blocks list.
167
179
  const schemas: string[] = [];
180
+ const seenPaths = new Set<string>();
168
181
  const allSchemaFiles = this.schemaEmitter.emit(system);
169
182
  for (const mod of system.modules) {
170
183
  if (mod.kind === "data_store" || mod.kind === "api_service") {
171
184
  for (const model of mod.models) {
172
185
  const schemaFile = allSchemaFiles.find(f => f.path.includes(toSnakeCase(model.name)) && f.language === "sql");
173
186
  if (schemaFile) {
174
- files.push({ ...schemaFile, path: `migrations/${schemaFile.path.replace("schema/", "")}` });
187
+ const targetPath = `migrations/${schemaFile.path.replace("schema/", "")}`;
188
+ if (seenPaths.has(targetPath)) continue;
189
+ seenPaths.add(targetPath);
190
+ files.push({ ...schemaFile, path: targetPath });
175
191
  schemas.push(schemaFile.content);
176
192
  }
177
193
  }
@@ -188,24 +204,40 @@ export class FullEmitter {
188
204
  files.push({ path: "README.md", content: this.emitReadme(system), language: "yaml", source_module: "root" });
189
205
 
190
206
  // 12. OpenAPI spec
191
- files.push({ path: "openapi.yaml", content: emitOpenApiSpec(system), language: "yaml", source_module: "docs" });
207
+ if (!options.noOpenApi) {
208
+ files.push({ path: "openapi.yaml", content: emitOpenApiSpec(system), language: "yaml", source_module: "docs" });
209
+ // GraphQL schema (alongside openapi)
210
+ files.push({ path: "schema.graphql", content: emitGraphQLSchema(system), language: "yaml", source_module: "docs" });
211
+ }
192
212
 
193
213
  // 13. TypeScript SDK
194
- files.push({ path: "sdk/client.ts", content: emitTypescriptSdk(system), language: "typescript", source_module: "sdk" });
214
+ if (!options.noSdk) {
215
+ files.push({ path: "sdk/client.ts", content: emitTypescriptSdk(system), language: "typescript", source_module: "sdk" });
216
+ }
195
217
 
196
218
  // 14. Zod schemas
197
219
  files.push({ path: "src/schemas.ts", content: emitZodSchemas(system), language: "typescript", source_module: "validation" });
198
220
 
199
221
  // 15. Postman collection
200
- files.push({ path: `${system.name}.postman_collection.json`, content: emitPostmanCollection(system), language: "json", source_module: "docs" });
222
+ if (!options.noOpenApi) {
223
+ files.push({ path: `${system.name}.postman_collection.json`, content: emitPostmanCollection(system), language: "json", source_module: "docs" });
224
+ }
201
225
 
202
226
  // 16. Seed file
203
- files.push({ path: "src/seed.ts", content: emitSeedFile(system), language: "typescript", source_module: "dev" });
227
+ if (!options.noSeed) {
228
+ files.push({ path: "src/seed.ts", content: emitSeedFile(system), language: "typescript", source_module: "dev" });
229
+ }
204
230
 
205
231
  // 17. Audit log
206
232
  files.push({ path: "migrations/audit_log.sql", content: emitAuditSchema(), language: "sql", source_module: "infra" });
207
233
  files.push({ path: "src/audit.ts", content: emitAuditMiddleware(system), language: "typescript", source_module: "infra" });
208
234
 
235
+ // 18. Notification service
236
+ files.push({ path: "src/notify.ts", content: emitNotifyService(system), language: "typescript", source_module: "infra" });
237
+
238
+ // 19. Cron jobs
239
+ files.push({ path: "src/cron.ts", content: emitCronJobs(system), language: "typescript", source_module: "infra" });
240
+
209
241
  // 18. Admin panel
210
242
  files.push({ path: "admin/index.html", content: emitAdminPanel(system), language: "yaml", source_module: "admin" });
211
243
 
@@ -254,6 +286,12 @@ EVENT_WORKER_INTERVAL_MS=1000
254
286
 
255
287
  # --- Request timeout ---
256
288
  REQUEST_TIMEOUT_MS=30000
289
+
290
+ # --- Notifications ---
291
+ # NOTIFY_PROVIDER=log|resend|sendgrid (default: log)
292
+ NOTIFY_PROVIDER=log
293
+ NOTIFY_API_KEY=
294
+ NOTIFY_FROM_EMAIL=noreply@example.com
257
295
  `;
258
296
  }
259
297
 
@@ -0,0 +1,161 @@
1
+ /**
2
+ * BoneScript GraphQL Schema Emitter
3
+ * Generates schema.graphql from an IRSystem.
4
+ */
5
+
6
+ import * as IR from "./ir";
7
+
8
+ function toCamelCase(s: string): string {
9
+ return s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
10
+ }
11
+
12
+ function toCamelCasePlural(s: string): string {
13
+ const camel = toCamelCase(s);
14
+ // Simple pluralisation: append 's' (good enough for generated stubs)
15
+ return camel.endsWith("s") ? camel : camel + "s";
16
+ }
17
+
18
+ function toGraphQLType(irType: string): string {
19
+ // Handle optional<X> — nullable in GraphQL (just unwrap, no !)
20
+ const optMatch = irType.match(/^optional<(.+)>$/);
21
+ if (optMatch) return toGraphQLType(optMatch[1]);
22
+
23
+ // Handle list<X> / set<X>
24
+ const listMatch = irType.match(/^(list|set)<(.+)>$/);
25
+ if (listMatch) return `[${toGraphQLType(listMatch[2])}]`;
26
+
27
+ switch (irType) {
28
+ case "string": return "String";
29
+ case "uint":
30
+ case "int": return "Int";
31
+ case "float": return "Float";
32
+ case "bool": return "Boolean";
33
+ case "timestamp": return "String";
34
+ case "uuid": return "ID";
35
+ case "bytes": return "String";
36
+ case "json": return "String";
37
+ default: return "String";
38
+ }
39
+ }
40
+
41
+ function isOptional(irType: string): boolean {
42
+ return irType.startsWith("optional<");
43
+ }
44
+
45
+ function fieldLine(field: IR.IRField): string {
46
+ const gqlType = toGraphQLType(field.type);
47
+ const nullable = isOptional(field.type) || field.nullable;
48
+ return ` ${field.name}: ${gqlType}${nullable ? "" : "!"}`;
49
+ }
50
+
51
+ export function emitGraphQLSchema(system: IR.IRSystem): string {
52
+ const lines: string[] = [];
53
+
54
+ lines.push(`# Generated by BoneScript compiler. DO NOT EDIT.`);
55
+ lines.push(`# System: ${system.name} v${system.version}`);
56
+ lines.push(``);
57
+
58
+ // Collect all models from api_service modules
59
+ const apiModules = system.modules.filter(m => m.kind === "api_service" && m.models.length > 0);
60
+
61
+ const queryFields: string[] = [];
62
+ const mutationFields: string[] = [];
63
+ const extraTypes: string[] = [];
64
+
65
+ for (const mod of apiModules) {
66
+ const model = mod.models[0];
67
+ const modelName = model.name;
68
+ const camelName = toCamelCase(modelName.charAt(0).toLowerCase() + modelName.slice(1));
69
+ const camelPlural = toCamelCasePlural(modelName.charAt(0).toLowerCase() + modelName.slice(1));
70
+
71
+ // ─── type {ModelName} ───
72
+ lines.push(`type ${modelName} {`);
73
+ // Always include id, created_at, updated_at
74
+ lines.push(` id: ID!`);
75
+ for (const field of model.fields) {
76
+ if (field.name === "id") continue; // already emitted
77
+ lines.push(fieldLine(field));
78
+ }
79
+ lines.push(`}`);
80
+ lines.push(``);
81
+
82
+ // ─── type {ModelName}Page ───
83
+ lines.push(`type ${modelName}Page {`);
84
+ lines.push(` items: [${modelName}!]!`);
85
+ lines.push(` total: Int!`);
86
+ lines.push(` page: Int!`);
87
+ lines.push(` pageSize: Int!`);
88
+ lines.push(`}`);
89
+ lines.push(``);
90
+
91
+ // ─── input {ModelName}Input ───
92
+ const inputFields = model.fields.filter(
93
+ f => !["id", "created_at", "updated_at"].includes(f.name)
94
+ );
95
+ lines.push(`input ${modelName}Input {`);
96
+ for (const field of inputFields) {
97
+ lines.push(fieldLine(field));
98
+ }
99
+ lines.push(`}`);
100
+ lines.push(``);
101
+
102
+ // Capability inputs + results
103
+ const capabilities = mod.interfaces.flatMap(i => i.methods)
104
+ .filter(m => !["create", "read", "update", "delete", "list"].includes(m.name));
105
+
106
+ for (const cap of capabilities) {
107
+ const capPascal = cap.name.charAt(0).toUpperCase() + toCamelCase(cap.name).slice(1);
108
+ extraTypes.push(`input ${modelName}${capPascal}Input {`);
109
+ for (const param of cap.input) {
110
+ extraTypes.push(fieldLine(param));
111
+ }
112
+ extraTypes.push(`}`);
113
+ extraTypes.push(``);
114
+ }
115
+
116
+ // ─── Query fields ───
117
+ queryFields.push(` ${camelName}(id: ID!): ${modelName}`);
118
+ queryFields.push(` ${camelPlural}(page: Int, pageSize: Int): ${modelName}Page`);
119
+
120
+ // ─── Mutation fields ───
121
+ mutationFields.push(` create${modelName}(input: ${modelName}Input!): ${modelName}!`);
122
+ mutationFields.push(` update${modelName}(id: ID!, input: ${modelName}Input!): ${modelName}!`);
123
+ mutationFields.push(` delete${modelName}(id: ID!): Boolean!`);
124
+
125
+ for (const cap of capabilities) {
126
+ const capCamel = toCamelCase(cap.name);
127
+ const capPascal = cap.name.charAt(0).toUpperCase() + capCamel.slice(1);
128
+ mutationFields.push(` ${capCamel}(input: ${modelName}${capPascal}Input): CapabilityResult!`);
129
+ }
130
+ }
131
+
132
+ // ─── type CapabilityResult ───
133
+ lines.push(`type CapabilityResult {`);
134
+ lines.push(` ok: Boolean!`);
135
+ lines.push(` action: String!`);
136
+ lines.push(`}`);
137
+ lines.push(``);
138
+
139
+ // ─── Extra capability input types ───
140
+ for (const l of extraTypes) {
141
+ lines.push(l);
142
+ }
143
+
144
+ // ─── type Query ───
145
+ lines.push(`type Query {`);
146
+ for (const f of queryFields) {
147
+ lines.push(f);
148
+ }
149
+ lines.push(`}`);
150
+ lines.push(``);
151
+
152
+ // ─── type Mutation ───
153
+ lines.push(`type Mutation {`);
154
+ for (const f of mutationFields) {
155
+ lines.push(f);
156
+ }
157
+ lines.push(`}`);
158
+ lines.push(``);
159
+
160
+ return lines.join("\n");
161
+ }
@@ -220,9 +220,29 @@ export function emitHealthChecks(system: IR.IRSystem): string {
220
220
  lines.push(`});`);
221
221
  lines.push(``);
222
222
 
223
- // Metrics endpoint
224
- lines.push(`// Prometheus-style metrics`);
225
- lines.push(`healthRouter.get("/metrics", (_req: Request, res: Response) => {`);
223
+ // Metrics endpoint — restricted to internal callers.
224
+ // Accepted: shared bearer in METRICS_TOKEN, or loopback / RFC1918 source IPs.
225
+ // External scrapers must inject the bearer; otherwise 403.
226
+ lines.push(`// Prometheus-style metrics — restricted to internal callers`);
227
+ lines.push(`function isInternalMetricsRequest(req: Request): boolean {`);
228
+ lines.push(` const expected = process.env.METRICS_TOKEN || "";`);
229
+ lines.push(` if (expected) {`);
230
+ lines.push(` const header = req.headers.authorization || "";`);
231
+ lines.push(` if (header.startsWith("Bearer ") && header.slice(7) === expected) return true;`);
232
+ lines.push(` }`);
233
+ lines.push(` const ip = (req.ip || "").replace(/^::ffff:/, "");`);
234
+ lines.push(` if (ip === "127.0.0.1" || ip === "::1") return true;`);
235
+ lines.push(` if (ip.startsWith("10.") || ip.startsWith("192.168.")) return true;`);
236
+ lines.push(` // RFC1918 172.16.0.0/12`);
237
+ lines.push(` const m = ip.match(/^172\\.(\\d{1,3})\\./);`);
238
+ lines.push(` if (m && +m[1] >= 16 && +m[1] <= 31) return true;`);
239
+ lines.push(` return false;`);
240
+ lines.push(`}`);
241
+ lines.push(`healthRouter.get("/metrics", (req: Request, res: Response) => {`);
242
+ lines.push(` if (!isInternalMetricsRequest(req)) {`);
243
+ lines.push(` res.status(403).json({ error: { code: "FORBIDDEN", message: "Metrics restricted to internal callers" } });`);
244
+ lines.push(` return;`);
245
+ lines.push(` }`);
226
246
  lines.push(` res.type("text/plain").send(dumpMetrics());`);
227
247
  lines.push(`});`);
228
248
  lines.push(``);
@@ -356,6 +376,7 @@ interface Field {
356
376
  name: string;
357
377
  type: string;
358
378
  nullable: boolean;
379
+ renamed_from?: string | null;
359
380
  }
360
381
 
361
382
  interface Model {
@@ -395,8 +416,18 @@ export function diffModels(oldModels: Model[], newModels: Model[]): string[] {
395
416
  const oldFields = new Map(oldModel.fields.map(f => [f.name, f]));
396
417
  const newFields = new Map(newModel.fields.map(f => [f.name, f]));
397
418
 
419
+ // Renames first — avoid double-counting as add+drop.
420
+ const renamedOld = new Set<string>();
421
+ for (const [fname, field] of newFields) {
422
+ if (field.renamed_from && oldFields.has(field.renamed_from) && !oldFields.has(fname)) {
423
+ statements.push(\`ALTER TABLE \${tableName} RENAME COLUMN \${field.renamed_from} TO \${fname};\`);
424
+ renamedOld.add(field.renamed_from);
425
+ }
426
+ }
427
+
398
428
  // New columns (backward-compatible)
399
429
  for (const [fname, field] of newFields) {
430
+ if (field.renamed_from && renamedOld.has(field.renamed_from)) continue;
400
431
  if (!oldFields.has(fname)) {
401
432
  const sqlType = mapType(field.type);
402
433
  const nullability = field.nullable ? "" : " NOT NULL DEFAULT (CASE WHEN false THEN NULL ELSE NULL END)";
@@ -406,6 +437,7 @@ export function diffModels(oldModels: Model[], newModels: Model[]): string[] {
406
437
 
407
438
  // Removed columns (NOT auto-dropped — backward compat)
408
439
  for (const [fname] of oldFields) {
440
+ if (renamedOld.has(fname)) continue;
409
441
  if (!newFields.has(fname)) {
410
442
  statements.push(\`-- WARNING: Column \${tableName}.\${fname} removed from schema. Run manually: ALTER TABLE \${tableName} DROP COLUMN \${fname};\`);
411
443
  }
@@ -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
+ }