bonescript-compiler 0.3.0 → 0.4.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 (52) 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_capability.js +61 -7
  7. package/dist/emit_capability.js.map +1 -1
  8. package/dist/emit_composition.js +37 -3
  9. package/dist/emit_composition.js.map +1 -1
  10. package/dist/emit_events.d.ts +1 -0
  11. package/dist/emit_events.js +68 -1
  12. package/dist/emit_events.js.map +1 -1
  13. package/dist/emit_full.js +33 -0
  14. package/dist/emit_full.js.map +1 -1
  15. package/dist/emit_models.d.ts +12 -0
  16. package/dist/emit_models.js +171 -0
  17. package/dist/emit_models.js.map +1 -0
  18. package/dist/emit_openapi.d.ts +9 -0
  19. package/dist/emit_openapi.js +308 -0
  20. package/dist/emit_openapi.js.map +1 -0
  21. package/dist/emit_router.js +19 -4
  22. package/dist/emit_router.js.map +1 -1
  23. package/dist/emit_tests.js +37 -0
  24. package/dist/emit_tests.js.map +1 -1
  25. package/dist/emitter.js +34 -5
  26. package/dist/emitter.js.map +1 -1
  27. package/dist/lowering.js +16 -1
  28. package/dist/lowering.js.map +1 -1
  29. package/dist/lowering_channels.d.ts +1 -1
  30. package/dist/lowering_channels.js +2 -2
  31. package/dist/lowering_channels.js.map +1 -1
  32. package/dist/typechecker.js +32 -13
  33. package/dist/typechecker.js.map +1 -1
  34. package/dist/verifier.d.ts +5 -0
  35. package/dist/verifier.js +140 -2
  36. package/dist/verifier.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/commands/compile.ts +41 -10
  39. package/src/commands/init.ts +28 -2
  40. package/src/emit_capability.ts +61 -6
  41. package/src/emit_composition.ts +36 -3
  42. package/src/emit_events.ts +70 -0
  43. package/src/emit_full.ts +36 -1
  44. package/src/emit_models.ts +176 -0
  45. package/src/emit_openapi.ts +318 -0
  46. package/src/emit_router.ts +18 -4
  47. package/src/emit_tests.ts +41 -0
  48. package/src/emitter.ts +592 -566
  49. package/src/lowering.ts +19 -1
  50. package/src/lowering_channels.ts +2 -2
  51. package/src/typechecker.ts +606 -591
  52. package/src/verifier.ts +495 -348
@@ -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
@@ -27,11 +27,13 @@ import { emitFlowRuntime } 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();
@@ -54,6 +56,10 @@ export class FullEmitter {
54
56
  files.push({ path: "src/events.ts", content: emitDurableEventBus(system), language: "typescript", source_module: "infra" });
55
57
  // Outbox SQL schema
56
58
  files.push({ path: "migrations/event_outbox.sql", content: emitOutboxSchema(), language: "sql", source_module: "infra" });
59
+ // Typed event publisher functions (one per declared event)
60
+ if (system.events.length > 0) {
61
+ files.push({ path: "src/publishers.ts", content: emitTypedEventPublishers(system), language: "typescript", source_module: "infra" });
62
+ }
57
63
  files.push({ path: "src/auth.ts", content: emitAuthMiddleware(system), language: "typescript", source_module: "infra" });
58
64
  files.push({ path: "src/logger.ts", content: emitLogger(system), language: "typescript", source_module: "infra" });
59
65
  files.push({ path: "src/metrics.ts", content: emitMetrics(), language: "typescript", source_module: "infra" });
@@ -156,6 +162,29 @@ export class FullEmitter {
156
162
  // 5. Source: main entry point
157
163
  files.push({ path: "src/index.ts", content: emitIndex(system), language: "typescript", source_module: "root" });
158
164
 
165
+ // 5a. Model interfaces, schemas, and validators (one file per model)
166
+ const modelFiles: string[] = [];
167
+ for (const mod of system.modules) {
168
+ for (const model of mod.models) {
169
+ const modelPath = `src/models/${toSnakeCase(model.name)}.ts`;
170
+ files.push({
171
+ path: modelPath,
172
+ content: emitModelFile(model, mod, system),
173
+ language: "typescript",
174
+ source_module: mod.id,
175
+ });
176
+ modelFiles.push(modelPath);
177
+ }
178
+ }
179
+ if (modelFiles.length > 0) {
180
+ files.push({
181
+ path: "src/models/index.ts",
182
+ content: emitModelsIndex(system),
183
+ language: "typescript",
184
+ source_module: "shared",
185
+ });
186
+ }
187
+
159
188
  // 6. SQL migrations — run schema emitter ONCE, then match by model name
160
189
  const schemas: string[] = [];
161
190
  const allSchemaFiles = this.schemaEmitter.emit(system);
@@ -180,6 +209,12 @@ export class FullEmitter {
180
209
  // 9. README
181
210
  files.push({ path: "README.md", content: this.emitReadme(system), language: "yaml", source_module: "root" });
182
211
 
212
+ // 9a. OpenAPI schema (spec/09_CODEGEN.md §2 — ApiService secondary target)
213
+ const openApiContent = emitOpenApiSchema(system);
214
+ if (openApiContent) {
215
+ files.push({ path: "openapi.json", content: openApiContent, language: "json", source_module: "root" });
216
+ }
217
+
183
218
  // 10. Source map + debug handler
184
219
  files.push({ path: `${system.name}.bone.map`, content: emitSourceMapFile(system, `${system.name}.bone`), language: "json", source_module: "root" });
185
220
  files.push({ path: "src/debug.ts", content: emitDebugHandler(system), language: "typescript", source_module: "infra" });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * BoneScript Model Emitter
3
+ * Generates per-model TypeScript artifacts as specified in spec/09_CODEGEN.md §5.1:
4
+ * - XxxInterface — typed interface
5
+ * - XxxSchema — const with field metadata (type, nullable, unique, indexed)
6
+ * - validateXxx() — runtime validation function derived from IR constraints
7
+ *
8
+ * These are emitted to src/models/<entity_name>.ts
9
+ */
10
+
11
+ import * as IR from "./ir";
12
+ import { toTsType, toSnakeCase } from "./emit_router";
13
+
14
+ // ─── Per-model file ───────────────────────────────────────────────────────────
15
+
16
+ export function emitModelFile(model: IR.IRModel, mod: IR.IRModule, system: IR.IRSystem): string {
17
+ const lines: string[] = [];
18
+ lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
19
+ lines.push(`// Source hash: ${system.source_hash}`);
20
+ lines.push(`// Module: ${mod.name}`);
21
+ lines.push(``);
22
+
23
+ // ── Interface ──────────────────────────────────────────────────────────────
24
+ lines.push(`export interface ${model.name} {`);
25
+ for (const field of model.fields) {
26
+ // Skip generated-always fields (derived columns)
27
+ if (field.default_value?.startsWith("GENERATED ALWAYS")) continue;
28
+ const nullable = field.nullable ? " | null" : "";
29
+ lines.push(` ${field.name}: ${toTsType(field.type)}${nullable};`);
30
+ }
31
+ lines.push(`}`);
32
+ lines.push(``);
33
+
34
+ // ── Schema const ───────────────────────────────────────────────────────────
35
+ lines.push(`export const ${model.name}Schema = {`);
36
+ for (const field of model.fields) {
37
+ if (field.default_value?.startsWith("GENERATED ALWAYS")) continue;
38
+ const meta: string[] = [
39
+ `type: ${JSON.stringify(field.type)}`,
40
+ `nullable: ${field.nullable}`,
41
+ `unique: ${field.unique}`,
42
+ `indexed: ${field.indexed}`,
43
+ ];
44
+ if (field.default_value && !field.default_value.startsWith("GENERATED")) {
45
+ meta.push(`default: ${JSON.stringify(field.default_value)}`);
46
+ }
47
+ lines.push(` ${field.name}: { ${meta.join(", ")} },`);
48
+ }
49
+ lines.push(`} as const;`);
50
+ lines.push(``);
51
+
52
+ // ── Input type (omits server-set fields) ──────────────────────────────────
53
+ const inputFields = model.fields.filter(f =>
54
+ !["id", "created_at", "updated_at"].includes(f.name) &&
55
+ !f.default_value?.startsWith("GENERATED ALWAYS")
56
+ );
57
+ lines.push(`export interface Create${model.name}Input {`);
58
+ for (const field of inputFields) {
59
+ const nullable = field.nullable ? "?" : "";
60
+ lines.push(` ${field.name}${nullable}: ${toTsType(field.type)};`);
61
+ }
62
+ lines.push(`}`);
63
+ lines.push(``);
64
+
65
+ lines.push(`export interface Update${model.name}Input {`);
66
+ for (const field of inputFields) {
67
+ lines.push(` ${field.name}?: ${toTsType(field.type)};`);
68
+ }
69
+ lines.push(`}`);
70
+ lines.push(``);
71
+
72
+ // ── Validation function ────────────────────────────────────────────────────
73
+ lines.push(`export interface ValidationError {`);
74
+ lines.push(` field: string;`);
75
+ lines.push(` code: string;`);
76
+ lines.push(` message: string;`);
77
+ lines.push(`}`);
78
+ lines.push(``);
79
+ lines.push(`export function validate${model.name}(input: unknown): { ok: true; value: ${model.name} } | { ok: false; errors: ValidationError[] } {`);
80
+ lines.push(` const errors: ValidationError[] = [];`);
81
+ lines.push(` const data = input as Record<string, unknown>;`);
82
+ lines.push(``);
83
+
84
+ // Required field presence checks
85
+ for (const field of model.fields) {
86
+ if (field.nullable || field.default_value || ["id", "created_at", "updated_at"].includes(field.name)) continue;
87
+ if (field.default_value?.startsWith("GENERATED ALWAYS")) continue;
88
+ lines.push(` if (data.${field.name} === undefined || data.${field.name} === null) {`);
89
+ lines.push(` errors.push({ field: "${field.name}", code: "REQUIRED", message: "${field.name} is required" });`);
90
+ lines.push(` }`);
91
+ }
92
+
93
+ // Type checks for primitive fields
94
+ for (const field of model.fields) {
95
+ if (["id", "created_at", "updated_at"].includes(field.name)) continue;
96
+ if (field.default_value?.startsWith("GENERATED ALWAYS")) continue;
97
+ const check = typeCheckExpr(field.type, `data.${field.name}`);
98
+ if (check) {
99
+ lines.push(` if (data.${field.name} !== undefined && data.${field.name} !== null && !(${check})) {`);
100
+ lines.push(` errors.push({ field: "${field.name}", code: "INVALID_TYPE", message: "${field.name} must be ${field.type}" });`);
101
+ lines.push(` }`);
102
+ }
103
+ }
104
+
105
+ // Constraint-derived checks from model constraints
106
+ for (const c of model.constraints) {
107
+ if (c.kind === "unique") continue; // enforced by DB
108
+ if (c.kind === "range" && c.params["min"] !== undefined && c.params["max"] !== undefined) {
109
+ lines.push(` if (typeof data.${c.target} === "number" && (data.${c.target} < ${c.params["min"]} || data.${c.target} > ${c.params["max"]})) {`);
110
+ lines.push(` errors.push({ field: "${c.target}", code: "OUT_OF_RANGE", message: "${c.target} must be between ${c.params["min"]} and ${c.params["max"]}" });`);
111
+ lines.push(` }`);
112
+ }
113
+ if (c.kind === "enum" && Array.isArray(c.params["values"])) {
114
+ const vals = (c.params["values"] as string[]).map(v => JSON.stringify(v)).join(", ");
115
+ lines.push(` if (data.${c.target} !== undefined && ![${vals}].includes(data.${c.target} as string)) {`);
116
+ lines.push(` errors.push({ field: "${c.target}", code: "INVALID_ENUM", message: "${c.target} must be one of: ${(c.params["values"] as string[]).join(", ")}" });`);
117
+ lines.push(` }`);
118
+ }
119
+ if (c.kind === "check" && typeof c.params["expression"] === "string") {
120
+ // Parse length-in-range constraints like "username.length in 3..32"
121
+ const lenMatch = (c.params["expression"] as string).match(/^(\w+)\.length\s+in\s+(\d+)\.\.(\d+)$/);
122
+ if (lenMatch) {
123
+ const [, fieldName, minLen, maxLen] = lenMatch;
124
+ lines.push(` if (typeof data.${fieldName} === "string" && (data.${fieldName}.length < ${minLen} || data.${fieldName}.length > ${maxLen})) {`);
125
+ lines.push(` errors.push({ field: "${fieldName}", code: "INVALID_LENGTH", message: "${fieldName} must be between ${minLen} and ${maxLen} characters" });`);
126
+ lines.push(` }`);
127
+ }
128
+ // Parse non-negative constraints like "score >= 0"
129
+ const nonNegMatch = (c.params["expression"] as string).match(/^(\w+)\s*>=\s*0$/);
130
+ if (nonNegMatch) {
131
+ const [, fieldName] = nonNegMatch;
132
+ lines.push(` if (typeof data.${fieldName} === "number" && data.${fieldName} < 0) {`);
133
+ lines.push(` errors.push({ field: "${fieldName}", code: "OUT_OF_RANGE", message: "${fieldName} must be >= 0" });`);
134
+ lines.push(` }`);
135
+ }
136
+ }
137
+ }
138
+
139
+ lines.push(``);
140
+ lines.push(` if (errors.length > 0) return { ok: false, errors };`);
141
+ lines.push(` return { ok: true, value: data as unknown as ${model.name} };`);
142
+ lines.push(`}`);
143
+ lines.push(``);
144
+
145
+ return lines.join("\n");
146
+ }
147
+
148
+ function typeCheckExpr(irType: string, expr: string): string | null {
149
+ switch (irType) {
150
+ case "string": return `typeof ${expr} === "string"`;
151
+ case "uint": return `typeof ${expr} === "number" && Number.isInteger(${expr}) && ${expr} >= 0`;
152
+ case "int": return `typeof ${expr} === "number" && Number.isInteger(${expr})`;
153
+ case "float": return `typeof ${expr} === "number"`;
154
+ case "bool": return `typeof ${expr} === "boolean"`;
155
+ case "uuid": return `typeof ${expr} === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(${expr})`;
156
+ case "timestamp": return `${expr} instanceof Date || typeof ${expr} === "string"`;
157
+ default:
158
+ if (irType.startsWith("list<") || irType.startsWith("set<")) return `Array.isArray(${expr})`;
159
+ return null;
160
+ }
161
+ }
162
+
163
+ // ─── Barrel index for all models ─────────────────────────────────────────────
164
+
165
+ export function emitModelsIndex(system: IR.IRSystem): string {
166
+ const lines: string[] = [];
167
+ lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
168
+ lines.push(`// Re-exports all model interfaces, schemas, and validators.`);
169
+ lines.push(``);
170
+ for (const mod of system.modules) {
171
+ for (const model of mod.models) {
172
+ lines.push(`export * from "./${toSnakeCase(model.name)}";`);
173
+ }
174
+ }
175
+ return lines.join("\n");
176
+ }