bonescript-compiler 0.3.0 → 0.5.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/commands/compile.js +42 -10
  2. package/dist/commands/compile.js.map +1 -1
  3. package/dist/commands/init.d.ts +1 -1
  4. package/dist/commands/init.js +29 -2
  5. package/dist/commands/init.js.map +1 -1
  6. package/dist/emit_auth.d.ts +14 -2
  7. package/dist/emit_auth.js +498 -60
  8. package/dist/emit_auth.js.map +1 -1
  9. package/dist/emit_capability.js +61 -7
  10. package/dist/emit_capability.js.map +1 -1
  11. package/dist/emit_composition.js +37 -3
  12. package/dist/emit_composition.js.map +1 -1
  13. package/dist/emit_events.d.ts +1 -0
  14. package/dist/emit_events.js +68 -1
  15. package/dist/emit_events.js.map +1 -1
  16. package/dist/emit_full.js +166 -11
  17. package/dist/emit_full.js.map +1 -1
  18. package/dist/emit_index.js +46 -1
  19. package/dist/emit_index.js.map +1 -1
  20. package/dist/emit_models.d.ts +12 -0
  21. package/dist/emit_models.js +171 -0
  22. package/dist/emit_models.js.map +1 -0
  23. package/dist/emit_openapi.d.ts +9 -0
  24. package/dist/emit_openapi.js +308 -0
  25. package/dist/emit_openapi.js.map +1 -0
  26. package/dist/emit_router.js +19 -4
  27. package/dist/emit_router.js.map +1 -1
  28. package/dist/emit_tests.js +37 -0
  29. package/dist/emit_tests.js.map +1 -1
  30. package/dist/emitter.js +81 -5
  31. package/dist/emitter.js.map +1 -1
  32. package/dist/ir.d.ts +4 -0
  33. package/dist/lowering.js +16 -1
  34. package/dist/lowering.js.map +1 -1
  35. package/dist/lowering_channels.d.ts +1 -1
  36. package/dist/lowering_channels.js +3 -2
  37. package/dist/lowering_channels.js.map +1 -1
  38. package/dist/lowering_entities.js +11 -1
  39. package/dist/lowering_entities.js.map +1 -1
  40. package/dist/optimizer.js +1 -1
  41. package/dist/optimizer.js.map +1 -1
  42. package/dist/scaffold.js +0 -1
  43. package/dist/scaffold.js.map +1 -1
  44. package/dist/typechecker.d.ts +5 -0
  45. package/dist/typechecker.js +68 -13
  46. package/dist/typechecker.js.map +1 -1
  47. package/dist/verifier.d.ts +5 -0
  48. package/dist/verifier.js +140 -2
  49. package/dist/verifier.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/commands/compile.ts +41 -10
  52. package/src/commands/init.ts +28 -2
  53. package/src/emit_auth.ts +513 -67
  54. package/src/emit_capability.ts +61 -6
  55. package/src/emit_composition.ts +36 -3
  56. package/src/emit_events.ts +70 -0
  57. package/src/emit_full.ts +172 -13
  58. package/src/emit_index.ts +210 -161
  59. package/src/emit_models.ts +176 -0
  60. package/src/emit_openapi.ts +318 -0
  61. package/src/emit_router.ts +18 -4
  62. package/src/emit_tests.ts +41 -0
  63. package/src/emitter.ts +81 -5
  64. package/src/ir.ts +1 -0
  65. package/src/lowering.ts +19 -1
  66. package/src/lowering_channels.ts +3 -2
  67. package/src/lowering_entities.ts +258 -248
  68. package/src/optimizer.ts +1 -1
  69. package/src/scaffold.ts +0 -1
  70. package/src/typechecker.ts +81 -15
  71. package/src/verifier.ts +495 -348
@@ -191,6 +191,7 @@ function compileEffect(
191
191
  mod: IR.IRModule,
192
192
  system: IR.IRSystem,
193
193
  paramIdx: { n: number },
194
+ method?: IR.IRMethod,
194
195
  ): CompiledEffect | null {
195
196
  const targetParts = effect.target.split(".");
196
197
  if (targetParts.length < 2) return null;
@@ -199,7 +200,20 @@ function compileEffect(
199
200
  const fieldName = targetParts[1];
200
201
  const nestedPath = targetParts.slice(2);
201
202
 
203
+ // Resolve the model: first try matching by param name → entity type via method inputs,
204
+ // then fall back to matching by entity name directly.
202
205
  const model = (() => {
206
+ // If we have method context, resolve the param name to its entity type
207
+ if (method) {
208
+ const param = method.input.find(p => p.name === entityParam);
209
+ if (param) {
210
+ for (const m of system.modules) {
211
+ const found = m.models.find(mdl => mdl.name === param.type || mdl.name.toLowerCase() === param.type.toLowerCase());
212
+ if (found) return found;
213
+ }
214
+ }
215
+ }
216
+ // Fall back to matching by entity name directly
203
217
  for (const m of system.modules) {
204
218
  const found = m.models.find(mdl =>
205
219
  toSnakeCase(mdl.name) === entityParam || mdl.name.toLowerCase() === entityParam.toLowerCase()
@@ -221,12 +235,15 @@ function compileEffect(
221
235
  const p1 = `$${paramIdx.n++}`;
222
236
  const p2 = `$${paramIdx.n++}`;
223
237
  const jsonbPathLiteral = `'{${nestedPath.join(",")}}'`;
238
+ // Use to_jsonb($1) directly — casting via ::text loses type information for
239
+ // non-string values (numbers, booleans, objects). to_jsonb() handles all
240
+ // PostgreSQL types correctly without an intermediate text cast.
224
241
  return {
225
242
  tableName, entityParam, idParam,
226
243
  assignments: [],
227
244
  description: `${effect.target} = ${effect.value}`,
228
245
  standalone: {
229
- sql: `UPDATE ${tableName} SET ${fieldName} = jsonb_set(COALESCE(${fieldName}, '{}'), ${jsonbPathLiteral}, to_jsonb(${p1}::text), true), updated_at = NOW() WHERE id = ${p2} RETURNING *`,
246
+ sql: `UPDATE ${tableName} SET ${fieldName} = jsonb_set(COALESCE(${fieldName}, '{}'), ${jsonbPathLiteral}, to_jsonb(${p1}), true), updated_at = NOW() WHERE id = ${p2} RETURNING *`,
230
247
  params: [valueTs, idParam],
231
248
  },
232
249
  };
@@ -490,7 +507,7 @@ export function emitCapabilityBody(
490
507
  lines.push(`${indent}// Effects (batched by entity to minimise round-trips)`);
491
508
 
492
509
  const paramIdx = { n: 1 };
493
- const compiled = method.effects.map(e => compileEffect(e, mod, system, paramIdx));
510
+ const compiled = method.effects.map(e => compileEffect(e, mod, system, paramIdx, method));
494
511
  const { batches, standalones } = batchEffects(compiled);
495
512
 
496
513
  // Emit batched UPDATEs
@@ -518,12 +535,50 @@ export function emitCapabilityBody(
518
535
  lines.push(`${indent}}`);
519
536
  }
520
537
 
521
- // Fallback for effects that couldn't be compiled
538
+ // Fallback for effects that couldn't be compiled.
539
+ // Collection-level effects (e.g. list<T>.field = value) are not yet supported
540
+ // by the batch compiler — emit a clear TODO rather than silently dropping them.
541
+ // Effects that reference a completely unknown model are a hard error.
522
542
  for (const effect of method.effects) {
523
543
  const paramIdx2 = { n: 1 };
524
- if (!compileEffect(effect, mod, system, paramIdx2)) {
525
- lines.push(`${indent}// EFFECT: ${effect.target} ${effect.op === "assign" ? "=" : effect.op === "add" ? "+=" : "-="} ${effect.value}`);
526
- lines.push(`${indent}// TODO: Implement this effect manually`);
544
+ if (!compileEffect(effect, mod, system, paramIdx2, method)) {
545
+ const targetParts = effect.target.split(".");
546
+ const paramName = targetParts[0];
547
+ const param = method.input.find(p => p.name === paramName);
548
+ const isCollectionEffect = param && (param.type.startsWith("list<") || param.type.startsWith("set<"));
549
+
550
+ if (isCollectionEffect) {
551
+ // Collection-level effects: apply the field update to all items in the collection
552
+ // using a single batched UPDATE ... WHERE id = ANY($ids::uuid[])
553
+ const innerType = param.type.replace(/^(list|set)<(.+)>$/, "$2");
554
+ // Find the model for the inner element type
555
+ let elemModel: IR.IRModel | undefined;
556
+ for (const m of system.modules) {
557
+ elemModel = m.models.find(mdl => mdl.name === innerType || mdl.name.toLowerCase() === innerType.toLowerCase());
558
+ if (elemModel) break;
559
+ }
560
+ const tableName = elemModel ? (elemModel.name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase() + "s") : (innerType.toLowerCase() + "s");
561
+ const fieldName = targetParts[1];
562
+ const valueTs = targetParts[1] ? effect.value : "null";
563
+ const opSql = effect.op === "add"
564
+ ? `${fieldName} = ${fieldName} + $1`
565
+ : effect.op === "remove"
566
+ ? `${fieldName} = ${fieldName} - $1`
567
+ : `${fieldName} = $1`;
568
+ lines.push(`${indent}// Collection effect: ${effect.target} ${effect.op === "assign" ? "=" : effect.op === "add" ? "+=" : "-="} ${effect.value}`);
569
+ lines.push(`${indent}if (${paramName} && ${paramName}.length > 0) {`);
570
+ lines.push(`${indent} const __ids_${paramName} = ${paramName}.map((x: any) => x.id ?? x);`);
571
+ lines.push(`${indent} await query(`);
572
+ lines.push(`${indent} \`UPDATE ${tableName} SET ${opSql}, updated_at = NOW() WHERE id = ANY($2::uuid[])\`,`);
573
+ lines.push(`${indent} [${effect.value}, __ids_${paramName}],`);
574
+ lines.push(`${indent} );`);
575
+ lines.push(`${indent}}`);
576
+ } else {
577
+ throw new Error(
578
+ `Unsupported effect in method '${method.name}': target '${effect.target}' could not be resolved to a known model field. ` +
579
+ `Ensure the effect target matches a declared entity field (e.g. 'entityName.fieldName').`
580
+ );
581
+ }
527
582
  }
528
583
  }
529
584
  lines.push(``);
@@ -57,7 +57,30 @@ export function emitPipelineBody(method: IR.IRMethod, indent: string = " "):
57
57
  } else if (p.on_error.action === "ignore") {
58
58
  lines.push(`${indent} // on_error: ignore — log only`);
59
59
  } else if (p.on_error.action === "retry") {
60
- lines.push(`${indent} // on_error: retry not yet supported in inline emission`);
60
+ // on_error: retry re-run the entire pipeline with exponential backoff.
61
+ // Wraps the pipeline steps in a retry loop. The outer try/catch feeds __err here.
62
+ lines.push(`${indent} // on_error: retry with exponential backoff`);
63
+ lines.push(`${indent} const __maxAttempts = ${method.retry?.max_attempts ?? 3};`);
64
+ lines.push(`${indent} const __backoffMs = ${method.retry?.interval_ms ?? 1000};`);
65
+ lines.push(`${indent} let __lastErr = __err;`);
66
+ lines.push(`${indent} for (let __attempt = 1; __attempt <= __maxAttempts; __attempt++) {`);
67
+ lines.push(`${indent} await new Promise(r => setTimeout(r, __backoffMs * Math.pow(2, __attempt - 1)));`);
68
+ lines.push(`${indent} try {`);
69
+ // Re-emit all pipeline steps inside the retry loop
70
+ for (const step of p.steps) {
71
+ const callExpr = generateStepCall(step);
72
+ if (step.bind_as) {
73
+ lines.push(`${indent} __pipeline_results["${step.bind_as}"] = await ${callExpr};`);
74
+ } else {
75
+ lines.push(`${indent} await ${callExpr};`);
76
+ }
77
+ }
78
+ lines.push(`${indent} return { ok: true, value: __pipeline_results } as any; // retry succeeded`);
79
+ lines.push(`${indent} } catch (__retryErr: any) {`);
80
+ lines.push(`${indent} __lastErr = __retryErr;`);
81
+ lines.push(`${indent} }`);
82
+ lines.push(`${indent} }`);
83
+ lines.push(`${indent} return { ok: false, error: { code: "PIPELINE_RETRY_EXHAUSTED", message: __lastErr.message, attempts: __maxAttempts } } as any;`);
61
84
  }
62
85
  } else {
63
86
  // Default: rollback on error
@@ -100,9 +123,19 @@ function emitParallelPipeline(method: IR.IRMethod, indent: string): string {
100
123
  }
101
124
 
102
125
  function generateStepCall(step: IR.IRPipelineStep): string {
103
- // Replace any args that reference previous bindings with __pipeline_results
126
+ // Substitute any arg that references a previous step's bind_as result
127
+ // e.g. if a prior step bound its result as "payment", an arg of "payment"
128
+ // becomes __pipeline_results["payment"]
104
129
  const args = step.call_args.map(arg => {
105
- // If arg looks like an identifier path, check if it might be a binding ref
130
+ // Simple identifier that looks like a binding reference
131
+ if (/^\w+$/.test(arg.trim())) {
132
+ return `(__pipeline_results["${arg.trim()}"] ?? ${arg.trim()})`;
133
+ }
134
+ // Dotted path like "payment.id" — resolve the root from pipeline results
135
+ const dotMatch = arg.trim().match(/^(\w+)(\..+)$/);
136
+ if (dotMatch) {
137
+ return `(__pipeline_results["${dotMatch[1]}"] as any)${dotMatch[2]} ?? undefined`;
138
+ }
106
139
  return arg;
107
140
  });
108
141
  return `${step.call_name}(${args.join(", ")})`;
@@ -305,3 +305,73 @@ export const eventBus = {
305
305
  };
306
306
  `;
307
307
  }
308
+
309
+ // ─── Typed Event Publishers ───────────────────────────────────────────────────
310
+ // Generates per-event emitXxx() typed publisher functions as specified in
311
+ // spec/09_CODEGEN.md §5.4. These wrap eventBus.publish with a typed payload
312
+ // interface so callers get compile-time safety instead of raw Record<string,unknown>.
313
+
314
+ const TS_TYPE_MAP: Record<string, string> = {
315
+ string: "string", uint: "number", int: "number", float: "number",
316
+ bool: "boolean", timestamp: "Date", uuid: "string", bytes: "Buffer", json: "unknown",
317
+ };
318
+
319
+ function toTsType(irType: string): string {
320
+ if (TS_TYPE_MAP[irType]) return TS_TYPE_MAP[irType];
321
+ const listMatch = irType.match(/^list<(.+)>$/);
322
+ if (listMatch) return `${toTsType(listMatch[1])}[]`;
323
+ const setMatch = irType.match(/^set<(.+)>$/);
324
+ if (setMatch) return `${toTsType(setMatch[1])}[]`;
325
+ const optMatch = irType.match(/^optional<(.+)>$/);
326
+ if (optMatch) return `${toTsType(optMatch[1])} | null`;
327
+ return irType;
328
+ }
329
+
330
+ export function emitTypedEventPublishers(system: IR.IRSystem): string {
331
+ if (system.events.length === 0) return "";
332
+
333
+ const lines: string[] = [];
334
+ lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
335
+ lines.push(`// Typed event publisher functions — one per declared event.`);
336
+ lines.push(`// Import these instead of calling eventBus.publish() directly.`);
337
+ lines.push(``);
338
+ lines.push(`import { eventBus } from "./events";`);
339
+ lines.push(`import type { PoolClient } from "pg";`);
340
+ lines.push(``);
341
+
342
+ for (const ev of system.events) {
343
+ const ifaceName = `${ev.name}Payload`;
344
+
345
+ // Payload interface
346
+ lines.push(`export interface ${ifaceName} {`);
347
+ for (const field of ev.payload) {
348
+ const nullable = field.nullable ? " | null" : "";
349
+ lines.push(` ${field.name}: ${toTsType(field.type)}${nullable};`);
350
+ }
351
+ lines.push(`}`);
352
+ lines.push(``);
353
+
354
+ // Publisher function
355
+ const sourceId = ev.source && ev.source !== "unknown" ? ev.source : system.name;
356
+ lines.push(`/**`);
357
+ lines.push(` * Publish a ${ev.name} event.`);
358
+ lines.push(` * Delivery: ${ev.delivery}${ev.ttl_ms ? ` | TTL: ${ev.ttl_ms}ms` : ""}`);
359
+ lines.push(` */`);
360
+ lines.push(`export async function emit${ev.name}(`);
361
+ lines.push(` payload: ${ifaceName},`);
362
+ lines.push(` correlationId?: string,`);
363
+ lines.push(` client?: PoolClient,`);
364
+ lines.push(`): Promise<void> {`);
365
+ lines.push(` await eventBus.publish(`);
366
+ lines.push(` "${ev.name}",`);
367
+ lines.push(` payload as unknown as Record<string, unknown>,`);
368
+ lines.push(` "${sourceId}",`);
369
+ lines.push(` correlationId,`);
370
+ lines.push(` client,`);
371
+ lines.push(` );`);
372
+ lines.push(`}`);
373
+ lines.push(``);
374
+ }
375
+
376
+ return lines.join("\n");
377
+ }
package/src/emit_full.ts CHANGED
@@ -23,20 +23,38 @@ import {
23
23
  emitFailureRules,
24
24
  emitMigrationDiff,
25
25
  } from "./emit_maintenance";
26
- import { emitFlowRuntime } from "./emit_extras";
26
+ import { emitFlowRuntime, emitChannelFilters } from "./emit_extras";
27
27
  import { emitAlgorithmsFile, collectUsedAlgorithms } from "./emit_composition";
28
28
  import { emitExtensionPointStub } from "./extension_manager";
29
29
  import * as AST from "./ast";
30
- import { emitDurableEventBus, emitOutboxSchema } from "./emit_events";
30
+ import { emitDurableEventBus, emitOutboxSchema, emitTypedEventPublishers } from "./emit_events";
31
31
  import { emitBatchExecutor } from "./emit_batch";
32
32
  import { emitSourceMapFile, emitDebugHandler } from "./emit_sourcemap";
33
33
  import { emitTestSuite } from "./emit_tests";
34
34
  import { emitDockerfile, emitDockerignore, emitK8sDeployment, emitGithubActions } from "./emit_deploy";
35
+ import { emitModelFile, emitModelsIndex } from "./emit_models";
36
+ import { emitOpenApiSchema } from "./emit_openapi";
35
37
 
36
38
  function toSnakeCase(s: string): string {
37
39
  return s.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
38
40
  }
39
41
 
42
+ /** Resolve the auth method for the system from the resolution map or module configs. */
43
+ function resolveSystemAuthMethod(system: IR.IRSystem): "jwt" | "oauth2" | "apikey" {
44
+ const direct = system.resolution["implied.auth_method"] || system.resolution["system.auth_method"];
45
+ if (direct === "oauth2" || direct === "apikey" || direct === "jwt") return direct as "jwt" | "oauth2" | "apikey";
46
+ for (const [key, val] of Object.entries(system.resolution)) {
47
+ if (key.endsWith(".auth_method") && (val === "oauth2" || val === "apikey" || val === "jwt")) {
48
+ return val as "jwt" | "oauth2" | "apikey";
49
+ }
50
+ }
51
+ for (const mod of system.modules) {
52
+ const m = mod.config["auth_method"] as string | undefined;
53
+ if (m === "oauth2" || m === "apikey" || m === "jwt") return m;
54
+ }
55
+ return "jwt";
56
+ }
57
+
40
58
  export class FullEmitter {
41
59
  private schemaEmitter = new Emitter();
42
60
 
@@ -54,6 +72,37 @@ export class FullEmitter {
54
72
  files.push({ path: "src/events.ts", content: emitDurableEventBus(system), language: "typescript", source_module: "infra" });
55
73
  // Outbox SQL schema
56
74
  files.push({ path: "migrations/event_outbox.sql", content: emitOutboxSchema(), language: "sql", source_module: "infra" });
75
+
76
+ // API key table migration (only when auth_method = apikey)
77
+ const authMethod = resolveSystemAuthMethod(system);
78
+ if (authMethod === "apikey") {
79
+ files.push({
80
+ path: "migrations/api_keys.sql",
81
+ content: [
82
+ "-- Generated by BoneScript compiler. DO NOT EDIT.",
83
+ "-- API key table for apikey auth strategy.",
84
+ "",
85
+ "CREATE TABLE IF NOT EXISTS api_keys (",
86
+ " id UUID PRIMARY KEY DEFAULT gen_random_uuid(),",
87
+ " actor_id UUID NOT NULL,",
88
+ " key_hash VARCHAR(64) NOT NULL UNIQUE,",
89
+ " key_prefix VARCHAR(16) NOT NULL,",
90
+ " name VARCHAR(255) NOT NULL DEFAULT 'default',",
91
+ " created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),",
92
+ " expires_at TIMESTAMPTZ NOT NULL,",
93
+ " revoked BOOLEAN NOT NULL DEFAULT false",
94
+ ");",
95
+ "CREATE INDEX IF NOT EXISTS idx_api_keys_actor ON api_keys (actor_id);",
96
+ "CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys (key_hash);",
97
+ ].join("\n"),
98
+ language: "sql",
99
+ source_module: "infra",
100
+ });
101
+ }
102
+ // Typed event publisher functions (one per declared event)
103
+ if (system.events.length > 0) {
104
+ files.push({ path: "src/publishers.ts", content: emitTypedEventPublishers(system), language: "typescript", source_module: "infra" });
105
+ }
57
106
  files.push({ path: "src/auth.ts", content: emitAuthMiddleware(system), language: "typescript", source_module: "infra" });
58
107
  files.push({ path: "src/logger.ts", content: emitLogger(system), language: "typescript", source_module: "infra" });
59
108
  files.push({ path: "src/metrics.ts", content: emitMetrics(), language: "typescript", source_module: "infra" });
@@ -105,17 +154,24 @@ export class FullEmitter {
105
154
  "",
106
155
  ];
107
156
  for (const ep of system.extension_points) {
108
- const params = ep.params.map((p: { name: string; type: string }) => `${p.name}: ${p.type}`).join(", ");
109
- const returnType = ep.returns || "void";
110
- extLines.push(`/**`);
111
- extLines.push(` * Extension point: ${ep.name}`);
112
- extLines.push(` * ${ep.stable ? "STABLE: implementation required." : "Optional."}`);
113
- extLines.push(` */`);
114
- extLines.push(`export function ${ep.name}(${params}): ${returnType} {`);
115
- extLines.push(` // <bonescript:ext:${ep.name}:begin>`);
116
- extLines.push(` throw new Error("Not implemented: ${ep.name}");`);
117
- extLines.push(` // <bonescript:ext:${ep.name}:end>`);
118
- extLines.push(`}`);
157
+ // Use the shared emitExtensionPointStub so the format is consistent
158
+ // with what extension_manager.ts expects when merging on recompile.
159
+ const stub = emitExtensionPointStub({
160
+ kind: "ExtensionPointDecl",
161
+ loc: { line: 0, column: 0, offset: 0 },
162
+ name: ep.name,
163
+ params: ep.params.map(p => ({
164
+ kind: "Param" as const,
165
+ loc: { line: 0, column: 0, offset: 0 },
166
+ name: p.name,
167
+ type: { kind: "PrimitiveType" as const, loc: { line: 0, column: 0, offset: 0 }, name: p.type },
168
+ })),
169
+ returns: ep.returns
170
+ ? { kind: "PrimitiveType" as const, loc: { line: 0, column: 0, offset: 0 }, name: ep.returns }
171
+ : null,
172
+ stable: ep.stable,
173
+ });
174
+ extLines.push(stub);
119
175
  extLines.push("");
120
176
  }
121
177
  files.push({
@@ -138,6 +194,79 @@ export class FullEmitter {
138
194
  }
139
195
  }
140
196
 
197
+ // 3a. Derived field helpers (one file per entity that has derived fields)
198
+ for (const mod of system.modules) {
199
+ for (const model of mod.models) {
200
+ const derivedFields = model.fields.filter(f =>
201
+ f.default_value && f.default_value.startsWith("GENERATED ALWAYS AS")
202
+ );
203
+ if (derivedFields.length === 0) continue;
204
+ const lines: string[] = [
205
+ `// Generated by BoneScript compiler. DO NOT EDIT.`,
206
+ `// Derived field helpers for ${model.name}.`,
207
+ `// These mirror the GENERATED ALWAYS AS columns in the SQL migration.`,
208
+ `// Use them when you need the computed value in application code before a DB round-trip.`,
209
+ ``,
210
+ `export type ${model.name}Derived = {`,
211
+ ];
212
+ for (const f of derivedFields) {
213
+ lines.push(` ${f.name}: unknown;`);
214
+ }
215
+ lines.push(`};`);
216
+ lines.push(``);
217
+ lines.push(`export const ${model.name.toUpperCase()}_DERIVED_FIELDS = [${derivedFields.map(f => `"${f.name}"`).join(", ")}] as const;`);
218
+ lines.push(``);
219
+ lines.push(`/** Returns true if the field name is a derived (computed) field on ${model.name}. */`);
220
+ lines.push(`export function is${model.name}DerivedField(field: string): boolean {`);
221
+ lines.push(` return (${model.name.toUpperCase()}_DERIVED_FIELDS as readonly string[]).includes(field);`);
222
+ lines.push(`}`);
223
+ files.push({
224
+ path: `src/derived/${toSnakeCase(model.name)}.ts`,
225
+ content: lines.join("\n"),
226
+ language: "typescript",
227
+ source_module: mod.id,
228
+ });
229
+ }
230
+ }
231
+
232
+ // 3b. Channel filter predicates (only if any channel has a filter expression)
233
+ const realtimeMods = system.modules.filter(m => m.kind === "realtime_service" && m.config["filter"]);
234
+ if (realtimeMods.length > 0) {
235
+ const filterLines: string[] = [
236
+ `// Generated by BoneScript compiler. DO NOT EDIT.`,
237
+ `// Channel filter predicates — applied before delivering messages to participants.`,
238
+ ``,
239
+ `export const CHANNEL_FILTERS: Record<string, (event: any, participant: any) => boolean> = {`,
240
+ ];
241
+ for (const mod of realtimeMods) {
242
+ const filterExpr = String(mod.config["filter"] || "true");
243
+ // Translate bone field refs to JS: event.field and participant.field pass through,
244
+ // bare identifiers are assumed to be event properties.
245
+ const jsFilter = filterExpr
246
+ .replace(/\band\b/g, "&&")
247
+ .replace(/\bor\b/g, "||")
248
+ .replace(/\bnot\b/g, "!")
249
+ .replace(/\b==\b/g, "===")
250
+ .replace(/\b!=\b/g, "!==")
251
+ .replace(/\bcontains\b/g, "?.includes");
252
+ filterLines.push(` "${mod.name}": (event, participant) => {`);
253
+ filterLines.push(` try { return Boolean(${jsFilter}); } catch { return true; }`);
254
+ filterLines.push(` },`);
255
+ }
256
+ filterLines.push(`};`);
257
+ filterLines.push(``);
258
+ filterLines.push(`export function shouldDeliver(channel: string, event: any, participant: any): boolean {`);
259
+ filterLines.push(` const filter = CHANNEL_FILTERS[channel];`);
260
+ filterLines.push(` return filter ? filter(event, participant) : true;`);
261
+ filterLines.push(`}`);
262
+ files.push({
263
+ path: "src/channel_filters.ts",
264
+ content: filterLines.join("\n"),
265
+ language: "typescript",
266
+ source_module: "infra",
267
+ });
268
+ }
269
+
141
270
  // 4. Source: route files (CRUD + capabilities)
142
271
  for (const mod of system.modules) {
143
272
  if (mod.kind === "api_service" && mod.models.length > 0) {
@@ -156,7 +285,31 @@ export class FullEmitter {
156
285
  // 5. Source: main entry point
157
286
  files.push({ path: "src/index.ts", content: emitIndex(system), language: "typescript", source_module: "root" });
158
287
 
288
+ // 5a. Model interfaces, schemas, and validators (one file per model)
289
+ const modelFiles: string[] = [];
290
+ for (const mod of system.modules) {
291
+ for (const model of mod.models) {
292
+ const modelPath = `src/models/${toSnakeCase(model.name)}.ts`;
293
+ files.push({
294
+ path: modelPath,
295
+ content: emitModelFile(model, mod, system),
296
+ language: "typescript",
297
+ source_module: mod.id,
298
+ });
299
+ modelFiles.push(modelPath);
300
+ }
301
+ }
302
+ if (modelFiles.length > 0) {
303
+ files.push({
304
+ path: "src/models/index.ts",
305
+ content: emitModelsIndex(system),
306
+ language: "typescript",
307
+ source_module: "shared",
308
+ });
309
+ }
310
+
159
311
  // 6. SQL migrations — run schema emitter ONCE, then match by model name
312
+ // Includes both api_service entities AND data_store schemas.
160
313
  const schemas: string[] = [];
161
314
  const allSchemaFiles = this.schemaEmitter.emit(system);
162
315
  for (const mod of system.modules) {
@@ -180,6 +333,12 @@ export class FullEmitter {
180
333
  // 9. README
181
334
  files.push({ path: "README.md", content: this.emitReadme(system), language: "yaml", source_module: "root" });
182
335
 
336
+ // 9a. OpenAPI schema (spec/09_CODEGEN.md §2 — ApiService secondary target)
337
+ const openApiContent = emitOpenApiSchema(system);
338
+ if (openApiContent) {
339
+ files.push({ path: "openapi.json", content: openApiContent, language: "json", source_module: "root" });
340
+ }
341
+
183
342
  // 10. Source map + debug handler
184
343
  files.push({ path: `${system.name}.bone.map`, content: emitSourceMapFile(system, `${system.name}.bone`), language: "json", source_module: "root" });
185
344
  files.push({ path: "src/debug.ts", content: emitDebugHandler(system), language: "typescript", source_module: "infra" });