bonescript-compiler 0.2.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 (146) hide show
  1. package/LICENSE +21 -0
  2. package/dist/algorithm_catalog.d.ts +32 -0
  3. package/dist/algorithm_catalog.js +323 -0
  4. package/dist/algorithm_catalog.js.map +1 -0
  5. package/dist/ast.d.ts +244 -0
  6. package/dist/ast.js +8 -0
  7. package/dist/ast.js.map +1 -0
  8. package/dist/cli.d.ts +4 -0
  9. package/dist/cli.js +605 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/emit_batch.d.ts +7 -0
  12. package/dist/emit_batch.js +133 -0
  13. package/dist/emit_batch.js.map +1 -0
  14. package/dist/emit_capability.d.ts +7 -0
  15. package/dist/emit_capability.js +376 -0
  16. package/dist/emit_capability.js.map +1 -0
  17. package/dist/emit_composition.d.ts +22 -0
  18. package/dist/emit_composition.js +184 -0
  19. package/dist/emit_composition.js.map +1 -0
  20. package/dist/emit_deploy.d.ts +9 -0
  21. package/dist/emit_deploy.js +191 -0
  22. package/dist/emit_deploy.js.map +1 -0
  23. package/dist/emit_events.d.ts +14 -0
  24. package/dist/emit_events.js +305 -0
  25. package/dist/emit_events.js.map +1 -0
  26. package/dist/emit_extras.d.ts +12 -0
  27. package/dist/emit_extras.js +234 -0
  28. package/dist/emit_extras.js.map +1 -0
  29. package/dist/emit_full.d.ts +13 -0
  30. package/dist/emit_full.js +273 -0
  31. package/dist/emit_full.js.map +1 -0
  32. package/dist/emit_maintenance.d.ts +16 -0
  33. package/dist/emit_maintenance.js +442 -0
  34. package/dist/emit_maintenance.js.map +1 -0
  35. package/dist/emit_runtime.d.ts +13 -0
  36. package/dist/emit_runtime.js +691 -0
  37. package/dist/emit_runtime.js.map +1 -0
  38. package/dist/emit_sourcemap.d.ts +29 -0
  39. package/dist/emit_sourcemap.js +123 -0
  40. package/dist/emit_sourcemap.js.map +1 -0
  41. package/dist/emit_tests.d.ts +15 -0
  42. package/dist/emit_tests.js +185 -0
  43. package/dist/emit_tests.js.map +1 -0
  44. package/dist/emit_websocket.d.ts +6 -0
  45. package/dist/emit_websocket.js +223 -0
  46. package/dist/emit_websocket.js.map +1 -0
  47. package/dist/emitter.d.ts +25 -0
  48. package/dist/emitter.js +511 -0
  49. package/dist/emitter.js.map +1 -0
  50. package/dist/extension_manager.d.ts +38 -0
  51. package/dist/extension_manager.js +170 -0
  52. package/dist/extension_manager.js.map +1 -0
  53. package/dist/formatter.d.ts +34 -0
  54. package/dist/formatter.js +317 -0
  55. package/dist/formatter.js.map +1 -0
  56. package/dist/index.d.ts +42 -0
  57. package/dist/index.js +113 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/ir.d.ts +168 -0
  60. package/dist/ir.js +10 -0
  61. package/dist/ir.js.map +1 -0
  62. package/dist/lexer.d.ts +195 -0
  63. package/dist/lexer.js +619 -0
  64. package/dist/lexer.js.map +1 -0
  65. package/dist/lowering.d.ts +25 -0
  66. package/dist/lowering.js +500 -0
  67. package/dist/lowering.js.map +1 -0
  68. package/dist/module_loader.d.ts +25 -0
  69. package/dist/module_loader.js +126 -0
  70. package/dist/module_loader.js.map +1 -0
  71. package/dist/optimizer.d.ts +26 -0
  72. package/dist/optimizer.js +158 -0
  73. package/dist/optimizer.js.map +1 -0
  74. package/dist/parse_decls.d.ts +13 -0
  75. package/dist/parse_decls.js +442 -0
  76. package/dist/parse_decls.js.map +1 -0
  77. package/dist/parse_decls2.d.ts +13 -0
  78. package/dist/parse_decls2.js +295 -0
  79. package/dist/parse_decls2.js.map +1 -0
  80. package/dist/parse_expr.d.ts +7 -0
  81. package/dist/parse_expr.js +197 -0
  82. package/dist/parse_expr.js.map +1 -0
  83. package/dist/parse_types.d.ts +6 -0
  84. package/dist/parse_types.js +51 -0
  85. package/dist/parse_types.js.map +1 -0
  86. package/dist/parser.d.ts +10 -0
  87. package/dist/parser.js +62 -0
  88. package/dist/parser.js.map +1 -0
  89. package/dist/parser_base.d.ts +19 -0
  90. package/dist/parser_base.js +50 -0
  91. package/dist/parser_base.js.map +1 -0
  92. package/dist/parser_recovery.d.ts +26 -0
  93. package/dist/parser_recovery.js +140 -0
  94. package/dist/parser_recovery.js.map +1 -0
  95. package/dist/scaffold.d.ts +13 -0
  96. package/dist/scaffold.js +376 -0
  97. package/dist/scaffold.js.map +1 -0
  98. package/dist/solver.d.ts +26 -0
  99. package/dist/solver.js +281 -0
  100. package/dist/solver.js.map +1 -0
  101. package/dist/typechecker.d.ts +52 -0
  102. package/dist/typechecker.js +534 -0
  103. package/dist/typechecker.js.map +1 -0
  104. package/dist/types.d.ts +38 -0
  105. package/dist/types.js +85 -0
  106. package/dist/types.js.map +1 -0
  107. package/dist/verifier.d.ts +46 -0
  108. package/dist/verifier.js +307 -0
  109. package/dist/verifier.js.map +1 -0
  110. package/package.json +52 -0
  111. package/src/algorithm_catalog.ts +345 -0
  112. package/src/ast.ts +334 -0
  113. package/src/cli.ts +624 -0
  114. package/src/emit_batch.ts +140 -0
  115. package/src/emit_capability.ts +436 -0
  116. package/src/emit_composition.ts +196 -0
  117. package/src/emit_deploy.ts +190 -0
  118. package/src/emit_events.ts +307 -0
  119. package/src/emit_extras.ts +240 -0
  120. package/src/emit_full.ts +309 -0
  121. package/src/emit_maintenance.ts +459 -0
  122. package/src/emit_runtime.ts +731 -0
  123. package/src/emit_sourcemap.ts +140 -0
  124. package/src/emit_tests.ts +205 -0
  125. package/src/emit_websocket.ts +229 -0
  126. package/src/emitter.ts +566 -0
  127. package/src/extension_manager.ts +187 -0
  128. package/src/formatter.ts +297 -0
  129. package/src/index.ts +88 -0
  130. package/src/ir.ts +215 -0
  131. package/src/lexer.ts +630 -0
  132. package/src/lowering.ts +556 -0
  133. package/src/module_loader.ts +114 -0
  134. package/src/optimizer.ts +196 -0
  135. package/src/parse_decls.ts +409 -0
  136. package/src/parse_decls2.ts +244 -0
  137. package/src/parse_expr.ts +197 -0
  138. package/src/parse_types.ts +54 -0
  139. package/src/parser.ts +64 -0
  140. package/src/parser_base.ts +57 -0
  141. package/src/parser_recovery.ts +153 -0
  142. package/src/scaffold.ts +375 -0
  143. package/src/solver.ts +330 -0
  144. package/src/typechecker.ts +591 -0
  145. package/src/types.ts +122 -0
  146. package/src/verifier.ts +348 -0
@@ -0,0 +1,196 @@
1
+ /**
2
+ * BoneScript Composition Emitter
3
+ * Generates real implementations for pipeline and algorithm capabilities.
4
+ */
5
+
6
+ import * as IR from "./ir";
7
+ import { lookupAlgorithm } from "./algorithm_catalog";
8
+
9
+ // ─── Pipeline Emission (Leap 1) ──────────────────────────────────────────────
10
+
11
+ /**
12
+ * Generate the body of a pipeline-based capability.
13
+ * Sequential pipelines thread results step-to-step with auto-rollback on error.
14
+ * Parallel pipelines run all steps concurrently and collect results.
15
+ */
16
+ export function emitPipelineBody(method: IR.IRMethod, indent: string = " "): string {
17
+ if (!method.pipeline) return "";
18
+ const lines: string[] = [];
19
+ const p = method.pipeline;
20
+
21
+ if (p.parallel) {
22
+ return emitParallelPipeline(method, indent);
23
+ }
24
+
25
+ // Sequential pipeline
26
+ lines.push(`${indent}// Pipeline: ${p.steps.length} step(s), sequential`);
27
+ lines.push(`${indent}const __pipeline_completed: { step: string; rollback: (() => Promise<void>) | null }[] = [];`);
28
+ lines.push(`${indent}const __pipeline_results: Record<string, unknown> = {};`);
29
+ lines.push(``);
30
+ lines.push(`${indent}try {`);
31
+
32
+ for (const step of p.steps) {
33
+ const callExpr = generateStepCall(step);
34
+ if (step.bind_as) {
35
+ lines.push(`${indent} __pipeline_results["${step.bind_as}"] = await ${callExpr};`);
36
+ } else {
37
+ lines.push(`${indent} await ${callExpr};`);
38
+ }
39
+ lines.push(`${indent} __pipeline_completed.push({ step: "${step.call_name}", rollback: null });`);
40
+ lines.push(`${indent} counter("pipeline.step_completed", { method: "${method.name}", step: "${step.call_name}" });`);
41
+ }
42
+
43
+ lines.push(`${indent} return { ok: true, value: __pipeline_results } as any;`);
44
+ lines.push(`${indent}} catch (__err: any) {`);
45
+
46
+ // Error handler
47
+ if (p.on_error) {
48
+ if (p.on_error.action === "rollback") {
49
+ lines.push(`${indent} // on_error: rollback completed steps in reverse order`);
50
+ lines.push(`${indent} for (const c of [...__pipeline_completed].reverse()) {`);
51
+ lines.push(`${indent} if (c.rollback) await c.rollback().catch(() => {});`);
52
+ lines.push(`${indent} }`);
53
+ } else if (p.on_error.action === "compensate" && p.on_error.call_name) {
54
+ lines.push(`${indent} // on_error: invoke compensation`);
55
+ const args = p.on_error.call_args.join(", ");
56
+ lines.push(`${indent} await ${p.on_error.call_name}(${args}).catch(() => {});`);
57
+ } else if (p.on_error.action === "ignore") {
58
+ lines.push(`${indent} // on_error: ignore — log only`);
59
+ } else if (p.on_error.action === "retry") {
60
+ lines.push(`${indent} // on_error: retry not yet supported in inline emission`);
61
+ }
62
+ } else {
63
+ // Default: rollback on error
64
+ lines.push(`${indent} // Default: rollback completed steps in reverse order`);
65
+ lines.push(`${indent} for (const c of [...__pipeline_completed].reverse()) {`);
66
+ lines.push(`${indent} if (c.rollback) await c.rollback().catch(() => {});`);
67
+ lines.push(`${indent} }`);
68
+ }
69
+
70
+ lines.push(`${indent} counter("pipeline.failed", { method: "${method.name}" });`);
71
+ lines.push(`${indent} logger.error("pipeline_failed", { event: "${method.name}", metadata: { error: __err.message } });`);
72
+ lines.push(`${indent} return { ok: false, error: { code: "PIPELINE_FAILED", message: __err.message } } as any;`);
73
+ lines.push(`${indent}}`);
74
+
75
+ return lines.join("\n");
76
+ }
77
+
78
+ function emitParallelPipeline(method: IR.IRMethod, indent: string): string {
79
+ if (!method.pipeline) return "";
80
+ const lines: string[] = [];
81
+ const p = method.pipeline;
82
+
83
+ lines.push(`${indent}// Pipeline: ${p.steps.length} step(s), parallel`);
84
+ lines.push(`${indent}try {`);
85
+ lines.push(`${indent} const __results = await Promise.all([`);
86
+
87
+ for (const step of p.steps) {
88
+ lines.push(`${indent} ${generateStepCall(step)},`);
89
+ }
90
+
91
+ lines.push(`${indent} ]);`);
92
+ lines.push(`${indent} counter("pipeline.parallel_completed", { method: "${method.name}", count: "${p.steps.length}" });`);
93
+ lines.push(`${indent} return { ok: true, value: __results } as any;`);
94
+ lines.push(`${indent}} catch (__err: any) {`);
95
+ lines.push(`${indent} logger.error("parallel_pipeline_failed", { event: "${method.name}", metadata: { error: __err.message } });`);
96
+ lines.push(`${indent} return { ok: false, error: { code: "PIPELINE_FAILED", message: __err.message } } as any;`);
97
+ lines.push(`${indent}}`);
98
+
99
+ return lines.join("\n");
100
+ }
101
+
102
+ function generateStepCall(step: IR.IRPipelineStep): string {
103
+ // Replace any args that reference previous bindings with __pipeline_results
104
+ const args = step.call_args.map(arg => {
105
+ // If arg looks like an identifier path, check if it might be a binding ref
106
+ return arg;
107
+ });
108
+ return `${step.call_name}(${args.join(", ")})`;
109
+ }
110
+
111
+ // ─── Algorithm Emission (Leap 2) ─────────────────────────────────────────────
112
+
113
+ /**
114
+ * Generate the body of an algorithm-based capability by looking up the
115
+ * implementation in the algorithm catalog.
116
+ */
117
+ export function emitAlgorithmBody(method: IR.IRMethod, indent: string = " "): string {
118
+ if (!method.algorithm) return "";
119
+
120
+ const spec = lookupAlgorithm(method.algorithm.catalog_name);
121
+ if (!spec) {
122
+ return `${indent}return { ok: false, error: { code: "UNKNOWN_ALGORITHM", message: "Algorithm '${method.algorithm.catalog_name}' not in catalog" } } as any;`;
123
+ }
124
+
125
+ const lines: string[] = [];
126
+ lines.push(`${indent}// Algorithm: ${spec.name} (${spec.complexity})`);
127
+ lines.push(`${indent}// ${spec.description}`);
128
+ lines.push(``);
129
+ lines.push(`${indent}try {`);
130
+
131
+ // Bind named arguments to algorithm parameters
132
+ const argNames: string[] = [];
133
+ for (const input of spec.inputs) {
134
+ const binding = method.algorithm.bindings.find(b => b.param === input.name);
135
+ if (binding) {
136
+ argNames.push(binding.value);
137
+ } else {
138
+ argNames.push(input.name); // assume it's a method parameter
139
+ }
140
+ }
141
+
142
+ const fnName = camelize(spec.name);
143
+ lines.push(`${indent} const __result = ${fnName}(${argNames.join(", ")});`);
144
+ lines.push(`${indent} counter("algorithm.invoked", { algorithm: "${spec.name}" });`);
145
+ lines.push(`${indent} return { ok: true, value: __result } as any;`);
146
+ lines.push(`${indent}} catch (__err: any) {`);
147
+ lines.push(`${indent} logger.error("algorithm_failed", { event: "${spec.name}", metadata: { error: __err.message } });`);
148
+ lines.push(`${indent} return { ok: false, error: { code: "ALGORITHM_FAILED", message: __err.message } } as any;`);
149
+ lines.push(`${indent}}`);
150
+
151
+ return lines.join("\n");
152
+ }
153
+
154
+ function camelize(s: string): string {
155
+ return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
156
+ }
157
+
158
+ // ─── Algorithms File Emission ────────────────────────────────────────────────
159
+
160
+ /**
161
+ * Emit a single TypeScript file containing all algorithm implementations
162
+ * referenced by capabilities in the system.
163
+ */
164
+ export function emitAlgorithmsFile(usedAlgorithms: Set<string>): string {
165
+ if (usedAlgorithms.size === 0) return "";
166
+
167
+ const lines: string[] = [];
168
+ lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
169
+ lines.push(`// Algorithm implementations from BoneScript catalog.`);
170
+ lines.push(``);
171
+
172
+ for (const name of [...usedAlgorithms].sort()) {
173
+ const spec = lookupAlgorithm(name);
174
+ if (!spec) continue;
175
+ lines.push(`// ─── ${spec.name} (${spec.category}, ${spec.complexity}) ─────`);
176
+ lines.push(`// ${spec.description}`);
177
+ lines.push(`export ${spec.emit({}).trim()}`);
178
+ lines.push(``);
179
+ }
180
+
181
+ return lines.join("\n");
182
+ }
183
+
184
+ // ─── Collect Used Algorithms ─────────────────────────────────────────────────
185
+
186
+ export function collectUsedAlgorithms(system: IR.IRSystem): Set<string> {
187
+ const used = new Set<string>();
188
+ for (const mod of system.modules) {
189
+ for (const iface of mod.interfaces) {
190
+ for (const method of iface.methods) {
191
+ if (method.algorithm) used.add(method.algorithm.catalog_name);
192
+ }
193
+ }
194
+ }
195
+ return used;
196
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * BoneScript Deploy Target Emitter
3
+ * Generates Dockerfile, k8s manifests, and GitHub Actions CI/CD.
4
+ */
5
+
6
+ import * as IR from "./ir";
7
+
8
+ function toSnakeCase(s: string): string {
9
+ return s.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
10
+ }
11
+
12
+ export function emitDockerfile(system: IR.IRSystem): string {
13
+ return `# Generated by BoneScript compiler.
14
+ FROM node:20-alpine AS builder
15
+ WORKDIR /app
16
+ COPY package*.json ./
17
+ RUN npm ci --only=production
18
+ COPY . .
19
+ RUN npm run build
20
+
21
+ FROM node:20-alpine AS runtime
22
+ WORKDIR /app
23
+ ENV NODE_ENV=production
24
+ COPY --from=builder /app/dist ./dist
25
+ COPY --from=builder /app/node_modules ./node_modules
26
+ COPY --from=builder /app/package.json ./
27
+
28
+ # Health check
29
+ HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=3 \\
30
+ CMD wget -qO- http://localhost:3000/health/live || exit 1
31
+
32
+ EXPOSE 3000
33
+ USER node
34
+ CMD ["node", "dist/index.js"]
35
+ `;
36
+ }
37
+
38
+ export function emitDockerignore(): string {
39
+ return `node_modules
40
+ dist
41
+ .env
42
+ *.log
43
+ .git
44
+ *.bone
45
+ *.bone.map
46
+ `;
47
+ }
48
+
49
+ export function emitK8sDeployment(system: IR.IRSystem): string {
50
+ const name = toSnakeCase(system.name).replace(/_/g, "-");
51
+ return `# Generated by BoneScript compiler.
52
+ apiVersion: apps/v1
53
+ kind: Deployment
54
+ metadata:
55
+ name: ${name}
56
+ labels:
57
+ app: ${name}
58
+ version: "${system.version}"
59
+ spec:
60
+ replicas: 2
61
+ selector:
62
+ matchLabels:
63
+ app: ${name}
64
+ template:
65
+ metadata:
66
+ labels:
67
+ app: ${name}
68
+ spec:
69
+ containers:
70
+ - name: ${name}
71
+ image: ${name}:${system.version}
72
+ ports:
73
+ - containerPort: 3000
74
+ env:
75
+ - name: NODE_ENV
76
+ value: production
77
+ - name: PORT
78
+ value: "3000"
79
+ - name: DATABASE_URL
80
+ valueFrom:
81
+ secretKeyRef:
82
+ name: ${name}-secrets
83
+ key: database-url
84
+ - name: JWT_SECRET
85
+ valueFrom:
86
+ secretKeyRef:
87
+ name: ${name}-secrets
88
+ key: jwt-secret
89
+ - name: REDIS_URL
90
+ valueFrom:
91
+ secretKeyRef:
92
+ name: ${name}-secrets
93
+ key: redis-url
94
+ optional: true
95
+ livenessProbe:
96
+ httpGet:
97
+ path: /health/live
98
+ port: 3000
99
+ initialDelaySeconds: 10
100
+ periodSeconds: 10
101
+ readinessProbe:
102
+ httpGet:
103
+ path: /health/ready
104
+ port: 3000
105
+ initialDelaySeconds: 5
106
+ periodSeconds: 5
107
+ resources:
108
+ requests:
109
+ memory: "128Mi"
110
+ cpu: "100m"
111
+ limits:
112
+ memory: "512Mi"
113
+ cpu: "500m"
114
+ ---
115
+ apiVersion: v1
116
+ kind: Service
117
+ metadata:
118
+ name: ${name}
119
+ spec:
120
+ selector:
121
+ app: ${name}
122
+ ports:
123
+ - port: 80
124
+ targetPort: 3000
125
+ type: ClusterIP
126
+ `;
127
+ }
128
+
129
+ export function emitGithubActions(system: IR.IRSystem): string {
130
+ const name = toSnakeCase(system.name).replace(/_/g, "-");
131
+ return `# Generated by BoneScript compiler.
132
+ name: CI/CD
133
+
134
+ on:
135
+ push:
136
+ branches: [main]
137
+ pull_request:
138
+ branches: [main]
139
+
140
+ jobs:
141
+ test:
142
+ runs-on: ubuntu-latest
143
+ services:
144
+ postgres:
145
+ image: postgres:16
146
+ env:
147
+ POSTGRES_DB: ${toSnakeCase(system.name)}_test
148
+ POSTGRES_USER: postgres
149
+ POSTGRES_PASSWORD: postgres
150
+ ports:
151
+ - 5432:5432
152
+ options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
153
+
154
+ steps:
155
+ - uses: actions/checkout@v4
156
+ - uses: actions/setup-node@v4
157
+ with:
158
+ node-version: 20
159
+ cache: npm
160
+
161
+ - name: Install dependencies
162
+ run: npm ci
163
+
164
+ - name: Type check
165
+ run: npx tsc --noEmit
166
+
167
+ - name: Run migrations
168
+ run: npm run migrate
169
+ env:
170
+ DATABASE_URL: postgresql://postgres:postgres@localhost:5432/${toSnakeCase(system.name)}_test
171
+
172
+ - name: Run tests
173
+ run: npx ts-node src/tests.ts
174
+ env:
175
+ DATABASE_URL: postgresql://postgres:postgres@localhost:5432/${toSnakeCase(system.name)}_test
176
+ JWT_SECRET: test-secret
177
+ TEST_BASE_URL: http://localhost:3000
178
+
179
+ build:
180
+ needs: test
181
+ runs-on: ubuntu-latest
182
+ if: github.ref == 'refs/heads/main'
183
+ steps:
184
+ - uses: actions/checkout@v4
185
+ - name: Build Docker image
186
+ run: docker build -t ${name}:\${{ github.sha }} .
187
+ - name: Tag as latest
188
+ run: docker tag ${name}:\${{ github.sha }} ${name}:latest
189
+ `;
190
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * BoneScript Durable Event System Emitter
3
+ *
4
+ * Generates two event delivery modes:
5
+ * in_process — default dev mode, in-memory bus (existing behavior)
6
+ * durable — Postgres-backed transactional outbox
7
+ *
8
+ * Durable mode guarantees:
9
+ * at_least_once — retry until acknowledged
10
+ * exactly_once — deduplicated via event_id table
11
+ */
12
+
13
+ import * as IR from "./ir";
14
+
15
+ // ─── Outbox SQL Schema ────────────────────────────────────────────────────────
16
+
17
+ export function emitOutboxSchema(): string {
18
+ return `-- BoneScript: Transactional Outbox Schema
19
+ -- Generated by BoneScript compiler. DO NOT EDIT.
20
+
21
+ CREATE TABLE IF NOT EXISTS event_outbox (
22
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
23
+ event_type VARCHAR NOT NULL,
24
+ payload JSONB NOT NULL,
25
+ source VARCHAR NOT NULL,
26
+ correlation_id UUID,
27
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
28
+ scheduled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
29
+ delivered_at TIMESTAMPTZ,
30
+ attempts INT NOT NULL DEFAULT 0,
31
+ last_error TEXT,
32
+ status VARCHAR NOT NULL DEFAULT 'pending'
33
+ CHECK (status IN ('pending', 'delivered', 'failed', 'dead_letter'))
34
+ );
35
+
36
+ CREATE INDEX IF NOT EXISTS idx_event_outbox_status ON event_outbox (status, scheduled_at)
37
+ WHERE status = 'pending';
38
+
39
+ CREATE INDEX IF NOT EXISTS idx_event_outbox_created ON event_outbox (created_at);
40
+
41
+ -- Deduplication table for exactly_once delivery
42
+ CREATE TABLE IF NOT EXISTS event_processed (
43
+ event_id UUID PRIMARY KEY,
44
+ event_type VARCHAR NOT NULL,
45
+ processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
46
+ );
47
+
48
+ CREATE INDEX IF NOT EXISTS idx_event_processed_type ON event_processed (event_type, processed_at);
49
+ `;
50
+ }
51
+
52
+ // ─── Durable Event Bus ────────────────────────────────────────────────────────
53
+
54
+ export function emitDurableEventBus(system: IR.IRSystem): string {
55
+ const exactlyOnceEvents = system.events
56
+ .filter(e => e.delivery === "exactly_once")
57
+ .map(e => `"${e.name}"`);
58
+
59
+ const atLeastOnceEvents = system.events
60
+ .filter(e => e.delivery === "at_least_once")
61
+ .map(e => `"${e.name}"`);
62
+
63
+ return `// Generated by BoneScript compiler. DO NOT EDIT.
64
+ // Durable event bus with transactional outbox pattern.
65
+ // Set EVENT_MODE=durable in .env to enable.
66
+
67
+ import { Pool, PoolClient } from "pg";
68
+ import { v4 as uuid } from "uuid";
69
+ import { pool } from "./db";
70
+ import { logger } from "./logger";
71
+ import { counter } from "./metrics";
72
+
73
+ export type EventDeliveryMode = "in_process" | "durable";
74
+
75
+ const MODE: EventDeliveryMode =
76
+ (process.env.EVENT_MODE as EventDeliveryMode) || "in_process";
77
+
78
+ // Events requiring exactly_once delivery (deduplicated)
79
+ const EXACTLY_ONCE_EVENTS = new Set([${exactlyOnceEvents.join(", ")}]);
80
+
81
+ // Events requiring at_least_once delivery (retried until ack)
82
+ const AT_LEAST_ONCE_EVENTS = new Set([${atLeastOnceEvents.join(", ")}]);
83
+
84
+ export interface EventMetadata {
85
+ source: string;
86
+ timestamp: Date;
87
+ correlation_id: string;
88
+ causation_id: string;
89
+ }
90
+
91
+ export interface SystemEvent {
92
+ type: string;
93
+ payload: Record<string, unknown>;
94
+ metadata: EventMetadata;
95
+ }
96
+
97
+ type Handler = (event: SystemEvent) => Promise<void>;
98
+
99
+ // ─── In-Process Bus ──────────────────────────────────────────────────────────
100
+
101
+ class InProcessBus {
102
+ private handlers: Map<string, Handler[]> = new Map();
103
+
104
+ subscribe(type: string, handler: Handler): void {
105
+ const existing = this.handlers.get(type) || [];
106
+ existing.push(handler);
107
+ this.handlers.set(type, existing);
108
+ }
109
+
110
+ async publish(type: string, payload: Record<string, unknown>, source: string, correlationId?: string): Promise<void> {
111
+ const event: SystemEvent = {
112
+ type,
113
+ payload,
114
+ metadata: {
115
+ source,
116
+ timestamp: new Date(),
117
+ correlation_id: correlationId || uuid(),
118
+ causation_id: uuid(),
119
+ },
120
+ };
121
+ counter("event.published", { type, mode: "in_process" });
122
+ const handlers = this.handlers.get(type) || [];
123
+ for (const handler of handlers) {
124
+ try {
125
+ await handler(event);
126
+ counter("event.delivered", { type, mode: "in_process" });
127
+ } catch (e: any) {
128
+ counter("event.delivery_failed", { type, mode: "in_process" });
129
+ logger.error("event_handler_failed", { event: type, metadata: { error: e.message } });
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ // ─── Durable Bus (Transactional Outbox) ──────────────────────────────────────
136
+
137
+ class DurableBus {
138
+ private handlers: Map<string, Handler[]> = new Map();
139
+
140
+ subscribe(type: string, handler: Handler): void {
141
+ const existing = this.handlers.get(type) || [];
142
+ existing.push(handler);
143
+ this.handlers.set(type, existing);
144
+ }
145
+
146
+ // Write event to outbox within the current transaction (or a new one)
147
+ async publish(
148
+ type: string,
149
+ payload: Record<string, unknown>,
150
+ source: string,
151
+ correlationId?: string,
152
+ client?: PoolClient
153
+ ): Promise<void> {
154
+ const eventId = uuid();
155
+ const corrId = correlationId || uuid();
156
+ const sql = \`
157
+ INSERT INTO event_outbox (id, event_type, payload, source, correlation_id)
158
+ VALUES ($1, $2, $3, $4, $5)
159
+ \`;
160
+ const params = [eventId, type, JSON.stringify({ ...payload, _event_id: eventId }), source, corrId];
161
+
162
+ if (client) {
163
+ // Write within caller's transaction — atomicity guaranteed
164
+ await client.query(sql, params);
165
+ } else {
166
+ await pool.query(sql, params);
167
+ }
168
+ counter("event.outboxed", { type });
169
+ }
170
+
171
+ // Called by the background worker
172
+ async flush(): Promise<void> {
173
+ const client = await pool.connect();
174
+ try {
175
+ await client.query("BEGIN");
176
+
177
+ // Fetch pending events (lock rows to prevent concurrent processing)
178
+ const { rows } = await client.query(\`
179
+ SELECT id, event_type, payload, source, correlation_id, attempts
180
+ FROM event_outbox
181
+ WHERE status = 'pending' AND scheduled_at <= NOW()
182
+ ORDER BY scheduled_at ASC
183
+ LIMIT 50
184
+ FOR UPDATE SKIP LOCKED
185
+ \`);
186
+
187
+ for (const row of rows) {
188
+ try {
189
+ // exactly_once: check deduplication table
190
+ if (EXACTLY_ONCE_EVENTS.has(row.event_type)) {
191
+ const { rows: dup } = await client.query(
192
+ "SELECT 1 FROM event_processed WHERE event_id = $1",
193
+ [row.payload._event_id || row.id]
194
+ );
195
+ if (dup.length > 0) {
196
+ await client.query(
197
+ "UPDATE event_outbox SET status = 'delivered', delivered_at = NOW() WHERE id = $1",
198
+ [row.id]
199
+ );
200
+ continue;
201
+ }
202
+ }
203
+
204
+ const event: SystemEvent = {
205
+ type: row.event_type,
206
+ payload: row.payload,
207
+ metadata: {
208
+ source: row.source,
209
+ timestamp: new Date(),
210
+ correlation_id: row.correlation_id,
211
+ causation_id: uuid(),
212
+ },
213
+ };
214
+
215
+ const handlers = this.handlers.get(row.event_type) || [];
216
+ for (const handler of handlers) {
217
+ await handler(event);
218
+ }
219
+
220
+ // Mark delivered
221
+ await client.query(
222
+ "UPDATE event_outbox SET status = 'delivered', delivered_at = NOW(), attempts = attempts + 1 WHERE id = $1",
223
+ [row.id]
224
+ );
225
+
226
+ // Record for exactly_once deduplication
227
+ if (EXACTLY_ONCE_EVENTS.has(row.event_type)) {
228
+ await client.query(
229
+ "INSERT INTO event_processed (event_id, event_type) VALUES ($1, $2) ON CONFLICT DO NOTHING",
230
+ [row.payload._event_id || row.id, row.event_type]
231
+ );
232
+ }
233
+
234
+ counter("event.delivered", { type: row.event_type, mode: "durable" });
235
+ } catch (e: any) {
236
+ const maxAttempts = AT_LEAST_ONCE_EVENTS.has(row.event_type) ? 10 : 3;
237
+ const newAttempts = row.attempts + 1;
238
+ const status = newAttempts >= maxAttempts ? "dead_letter" : "pending";
239
+ const backoffMs = Math.min(1000 * Math.pow(2, newAttempts), 300000);
240
+ await client.query(
241
+ \`UPDATE event_outbox
242
+ SET attempts = $1, last_error = $2, status = $3,
243
+ scheduled_at = NOW() + ($4 || ' milliseconds')::interval
244
+ WHERE id = $5\`,
245
+ [newAttempts, e.message, status, backoffMs, row.id]
246
+ );
247
+ counter("event.delivery_failed", { type: row.event_type, mode: "durable" });
248
+ logger.error("event_delivery_failed", { event: row.event_type, metadata: { error: e.message, attempts: newAttempts } });
249
+ }
250
+ }
251
+
252
+ await client.query("COMMIT");
253
+ } catch (e) {
254
+ await client.query("ROLLBACK");
255
+ throw e;
256
+ } finally {
257
+ client.release();
258
+ }
259
+ }
260
+
261
+ // Start background worker
262
+ startWorker(intervalMs: number = 1000): NodeJS.Timeout {
263
+ logger.info("event_worker_started", { event: "startup", metadata: { interval_ms: intervalMs } });
264
+ return setInterval(async () => {
265
+ try {
266
+ await this.flush();
267
+ } catch (e: any) {
268
+ logger.error("event_worker_error", { event: "flush_failed", metadata: { error: e.message } });
269
+ }
270
+ }, intervalMs);
271
+ }
272
+ }
273
+
274
+ // ─── Unified Interface ────────────────────────────────────────────────────────
275
+
276
+ const inProcess = new InProcessBus();
277
+ const durable = new DurableBus();
278
+
279
+ export const eventBus = {
280
+ subscribe(type: string, handler: Handler): void {
281
+ inProcess.subscribe(type, handler);
282
+ durable.subscribe(type, handler);
283
+ },
284
+
285
+ async publish(
286
+ type: string,
287
+ payload: Record<string, unknown>,
288
+ source: string,
289
+ correlationId?: string,
290
+ client?: PoolClient
291
+ ): Promise<void> {
292
+ if (MODE === "durable") {
293
+ await durable.publish(type, payload, source, correlationId, client);
294
+ } else {
295
+ await inProcess.publish(type, payload, source, correlationId);
296
+ }
297
+ },
298
+
299
+ startWorker(intervalMs?: number): NodeJS.Timeout | null {
300
+ if (MODE === "durable") {
301
+ return durable.startWorker(intervalMs);
302
+ }
303
+ return null;
304
+ },
305
+ };
306
+ `;
307
+ }