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
@@ -0,0 +1,318 @@
1
+ /**
2
+ * BoneScript OpenAPI 3.1 Schema Emitter
3
+ * Generates a complete openapi.json for each api_service module.
4
+ * Implements spec/09_CODEGEN.md §2 (ApiService → JSON secondary target).
5
+ *
6
+ * Produces: openapi.json at the project root.
7
+ */
8
+
9
+ import * as IR from "./ir";
10
+
11
+ // ─── Type mapping ─────────────────────────────────────────────────────────────
12
+
13
+ function irTypeToJsonSchema(irType: string): Record<string, unknown> {
14
+ switch (irType) {
15
+ case "string": return { type: "string" };
16
+ case "uint": return { type: "integer", minimum: 0 };
17
+ case "int": return { type: "integer" };
18
+ case "float": return { type: "number" };
19
+ case "bool": return { type: "boolean" };
20
+ case "timestamp": return { type: "string", format: "date-time" };
21
+ case "uuid": return { type: "string", format: "uuid" };
22
+ case "bytes": return { type: "string", format: "byte" };
23
+ case "json": return {};
24
+ default: {
25
+ const listMatch = irType.match(/^list<(.+)>$/);
26
+ if (listMatch) return { type: "array", items: irTypeToJsonSchema(listMatch[1]) };
27
+ const setMatch = irType.match(/^set<(.+)>$/);
28
+ if (setMatch) return { type: "array", items: irTypeToJsonSchema(setMatch[1]), uniqueItems: true };
29
+ const optMatch = irType.match(/^optional<(.+)>$/);
30
+ if (optMatch) return { oneOf: [irTypeToJsonSchema(optMatch[1]), { type: "null" }] };
31
+ // Entity reference — use $ref
32
+ return { $ref: `#/components/schemas/${irType}` };
33
+ }
34
+ }
35
+ }
36
+
37
+ function modelToSchema(model: IR.IRModel): Record<string, unknown> {
38
+ const properties: Record<string, unknown> = {};
39
+ const required: string[] = [];
40
+
41
+ for (const field of model.fields) {
42
+ if (field.default_value?.startsWith("GENERATED ALWAYS")) continue;
43
+ properties[field.name] = irTypeToJsonSchema(field.type);
44
+ if (!field.nullable && !field.default_value) {
45
+ required.push(field.name);
46
+ }
47
+ }
48
+
49
+ return {
50
+ type: "object",
51
+ properties,
52
+ ...(required.length > 0 ? { required } : {}),
53
+ };
54
+ }
55
+
56
+ function toSnakeCase(s: string): string {
57
+ return s.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
58
+ }
59
+
60
+ // ─── Main emitter ─────────────────────────────────────────────────────────────
61
+
62
+ export function emitOpenApiSchema(system: IR.IRSystem): string {
63
+ const apiModules = system.modules.filter(m => m.kind === "api_service" && m.models.length > 0);
64
+ if (apiModules.length === 0) return "";
65
+
66
+ // ── Collect all schemas ──────────────────────────────────────────────────
67
+ const schemas: Record<string, unknown> = {};
68
+
69
+ // Standard error schema
70
+ schemas["Error"] = {
71
+ type: "object",
72
+ required: ["error"],
73
+ properties: {
74
+ error: {
75
+ type: "object",
76
+ required: ["code", "message"],
77
+ properties: {
78
+ code: { type: "string" },
79
+ message: { type: "string" },
80
+ },
81
+ },
82
+ },
83
+ };
84
+
85
+ // Paginated result wrapper
86
+ schemas["PaginatedResult"] = {
87
+ type: "object",
88
+ required: ["items", "total", "page", "page_size"],
89
+ properties: {
90
+ items: { type: "array", items: {} },
91
+ total: { type: "integer", minimum: 0 },
92
+ page: { type: "integer", minimum: 1 },
93
+ page_size: { type: "integer", minimum: 1 },
94
+ },
95
+ };
96
+
97
+ for (const mod of apiModules) {
98
+ for (const model of mod.models) {
99
+ schemas[model.name] = modelToSchema(model);
100
+
101
+ // Create input schema (omit server-set fields)
102
+ const createProps: Record<string, unknown> = {};
103
+ const createRequired: string[] = [];
104
+ for (const field of model.fields) {
105
+ if (["id", "created_at", "updated_at"].includes(field.name)) continue;
106
+ if (field.default_value?.startsWith("GENERATED ALWAYS")) continue;
107
+ createProps[field.name] = irTypeToJsonSchema(field.type);
108
+ if (!field.nullable && !field.default_value) createRequired.push(field.name);
109
+ }
110
+ schemas[`Create${model.name}Input`] = {
111
+ type: "object",
112
+ properties: createProps,
113
+ ...(createRequired.length > 0 ? { required: createRequired } : {}),
114
+ };
115
+
116
+ // Update input schema (all optional)
117
+ const updateProps: Record<string, unknown> = {};
118
+ for (const field of model.fields) {
119
+ if (["id", "created_at", "updated_at"].includes(field.name)) continue;
120
+ if (field.default_value?.startsWith("GENERATED ALWAYS")) continue;
121
+ updateProps[field.name] = irTypeToJsonSchema(field.type);
122
+ }
123
+ schemas[`Update${model.name}Input`] = {
124
+ type: "object",
125
+ properties: updateProps,
126
+ };
127
+ }
128
+ }
129
+
130
+ // Event payload schemas
131
+ for (const ev of system.events) {
132
+ const props: Record<string, unknown> = {};
133
+ for (const field of ev.payload) {
134
+ props[field.name] = irTypeToJsonSchema(field.type);
135
+ }
136
+ schemas[`${ev.name}Payload`] = { type: "object", properties: props };
137
+ }
138
+
139
+ // ── Build paths ──────────────────────────────────────────────────────────
140
+ const paths: Record<string, unknown> = {};
141
+
142
+ for (const mod of apiModules) {
143
+ const model = mod.models[0];
144
+ const basePath = `/${toSnakeCase(model.name)}s`;
145
+ const tag = mod.name;
146
+
147
+ // GET / — list
148
+ paths[basePath] = {
149
+ get: {
150
+ tags: [tag],
151
+ summary: `List ${model.name}s`,
152
+ security: [{ bearerAuth: [] }],
153
+ parameters: [
154
+ { name: "page", in: "query", schema: { type: "integer", minimum: 1, default: 1 } },
155
+ { name: "page_size", in: "query", schema: { type: "integer", minimum: 1, maximum: 100, default: 50 } },
156
+ ],
157
+ responses: {
158
+ "200": {
159
+ description: `List of ${model.name}s`,
160
+ content: {
161
+ "application/json": {
162
+ schema: {
163
+ allOf: [
164
+ { $ref: "#/components/schemas/PaginatedResult" },
165
+ { properties: { items: { type: "array", items: { $ref: `#/components/schemas/${model.name}` } } } },
166
+ ],
167
+ },
168
+ },
169
+ },
170
+ },
171
+ "401": { $ref: "#/components/responses/Unauthorized" },
172
+ },
173
+ },
174
+ post: {
175
+ tags: [tag],
176
+ summary: `Create ${model.name}`,
177
+ security: [{ bearerAuth: [] }],
178
+ requestBody: {
179
+ required: true,
180
+ content: {
181
+ "application/json": {
182
+ schema: { $ref: `#/components/schemas/Create${model.name}Input` },
183
+ },
184
+ },
185
+ },
186
+ responses: {
187
+ "201": {
188
+ description: `${model.name} created`,
189
+ content: { "application/json": { schema: { $ref: `#/components/schemas/${model.name}` } } },
190
+ },
191
+ "400": { $ref: "#/components/responses/BadRequest" },
192
+ "401": { $ref: "#/components/responses/Unauthorized" },
193
+ },
194
+ },
195
+ };
196
+
197
+ // GET /:id, PUT /:id, DELETE /:id
198
+ const idPath = `${basePath}/{id}`;
199
+ paths[idPath] = {
200
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }],
201
+ get: {
202
+ tags: [tag],
203
+ summary: `Get ${model.name} by id`,
204
+ security: [{ bearerAuth: [] }],
205
+ responses: {
206
+ "200": { description: `${model.name}`, content: { "application/json": { schema: { $ref: `#/components/schemas/${model.name}` } } } },
207
+ "401": { $ref: "#/components/responses/Unauthorized" },
208
+ "404": { $ref: "#/components/responses/NotFound" },
209
+ },
210
+ },
211
+ put: {
212
+ tags: [tag],
213
+ summary: `Update ${model.name}`,
214
+ security: [{ bearerAuth: [] }],
215
+ requestBody: {
216
+ required: true,
217
+ content: { "application/json": { schema: { $ref: `#/components/schemas/Update${model.name}Input` } } },
218
+ },
219
+ responses: {
220
+ "200": { description: `Updated ${model.name}`, content: { "application/json": { schema: { $ref: `#/components/schemas/${model.name}` } } } },
221
+ "400": { $ref: "#/components/responses/BadRequest" },
222
+ "401": { $ref: "#/components/responses/Unauthorized" },
223
+ "404": { $ref: "#/components/responses/NotFound" },
224
+ },
225
+ },
226
+ delete: {
227
+ tags: [tag],
228
+ summary: `Delete ${model.name}`,
229
+ security: [{ bearerAuth: [] }],
230
+ responses: {
231
+ "204": { description: "Deleted" },
232
+ "401": { $ref: "#/components/responses/Unauthorized" },
233
+ "404": { $ref: "#/components/responses/NotFound" },
234
+ },
235
+ },
236
+ };
237
+
238
+ // Capability endpoints
239
+ for (const iface of mod.interfaces) {
240
+ for (const method of iface.methods) {
241
+ if (["create", "read", "update", "delete", "list"].includes(method.name)) continue;
242
+
243
+ const capPath = `${basePath}/${method.name.replace(/_/g, "-")}`;
244
+ const inputProps: Record<string, unknown> = {};
245
+ for (const param of method.input) {
246
+ inputProps[param.name] = irTypeToJsonSchema(param.type);
247
+ }
248
+
249
+ paths[capPath] = {
250
+ post: {
251
+ tags: [tag],
252
+ summary: method.name.replace(/_/g, " "),
253
+ description: [
254
+ method.preconditions.length > 0 ? `**Preconditions:** ${method.preconditions.map(p => p.description).join("; ")}` : "",
255
+ method.effects.length > 0 ? `**Effects:** ${method.effects.map(e => `${e.target} ${e.op === "assign" ? "=" : e.op === "add" ? "+=" : "-="} ${e.value}`).join("; ")}` : "",
256
+ method.sync ? `**Sync:** ${method.sync}` : "",
257
+ ].filter(Boolean).join("\n\n"),
258
+ security: [{ bearerAuth: [] }],
259
+ requestBody: {
260
+ required: true,
261
+ content: {
262
+ "application/json": {
263
+ schema: { type: "object", properties: inputProps },
264
+ },
265
+ },
266
+ },
267
+ responses: {
268
+ "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, action: { type: "string" } } } } } },
269
+ "401": { $ref: "#/components/responses/Unauthorized" },
270
+ "422": { description: "Precondition failed", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
271
+ },
272
+ },
273
+ };
274
+ }
275
+ }
276
+ }
277
+
278
+ // ── Assemble document ────────────────────────────────────────────────────
279
+ const doc = {
280
+ openapi: "3.1.0",
281
+ info: {
282
+ title: system.name,
283
+ version: system.version,
284
+ description: `Generated by BoneScript compiler. Source hash: ${system.source_hash}`,
285
+ },
286
+ servers: [
287
+ { url: "http://localhost:3000", description: "Local development" },
288
+ ],
289
+ tags: apiModules.map(m => ({ name: m.name, description: `${m.name} endpoints` })),
290
+ paths,
291
+ components: {
292
+ securitySchemes: {
293
+ bearerAuth: {
294
+ type: "http",
295
+ scheme: "bearer",
296
+ bearerFormat: "JWT",
297
+ },
298
+ },
299
+ schemas,
300
+ responses: {
301
+ Unauthorized: {
302
+ description: "Authentication required",
303
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
304
+ },
305
+ NotFound: {
306
+ description: "Resource not found",
307
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
308
+ },
309
+ BadRequest: {
310
+ description: "Invalid request",
311
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
312
+ },
313
+ },
314
+ },
315
+ };
316
+
317
+ return JSON.stringify(doc, null, 2);
318
+ }
@@ -81,10 +81,12 @@ export function emitStateMachineRuntime(sm: IR.IRStateMachine): string {
81
81
  lines.push(``);
82
82
  lines.push(`export function transition${sm.entity}(`);
83
83
  lines.push(` current: ${sm.entity}State,`);
84
- lines.push(` trigger: string`);
84
+ lines.push(` trigger: string,`);
85
+ lines.push(` guard?: () => boolean`);
85
86
  lines.push(`): { ok: true; next: ${sm.entity}State } | { ok: false; error: string } {`);
86
87
  lines.push(` const next = TRANSITIONS[current]?.[trigger];`);
87
88
  lines.push(` if (!next) return { ok: false, error: \`Invalid transition: \${current} --[\${trigger}]--> ?\` };`);
89
+ lines.push(` if (guard && !guard()) return { ok: false, error: \`Guard failed for transition: \${current} --[\${trigger}]--> \${next}\` };`);
88
90
  lines.push(` return { ok: true, next };`);
89
91
  lines.push(`}`);
90
92
  lines.push(``);
@@ -115,6 +117,20 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
115
117
  lines.push(`const { shortestPath, topologicalSort, binarySearch, bipartiteMatching, roundRobin, weightedAverage, percentile, rankBy, consistentHash } = __algorithms as any;`);
116
118
  lines.push(``);
117
119
 
120
+ // Import broadcastToChannel if any capability uses sync: realtime
121
+ const hasRealtime = mod.interfaces.some(i => i.methods.some(m => m.sync === "realtime"));
122
+ if (hasRealtime) {
123
+ // Only import if websocket.ts will be generated (system has realtime_service modules)
124
+ const hasWebSocket = system.modules.some(m => m.kind === "realtime_service");
125
+ if (hasWebSocket) {
126
+ lines.push(`import { broadcastToChannel } from "../websocket";`);
127
+ } else {
128
+ // No WebSocket server — define a no-op stub so the route file still compiles
129
+ lines.push(`// No realtime_service declared — broadcastToChannel is a no-op`);
130
+ lines.push(`function broadcastToChannel(_channel: string, _msg: unknown, _exclude?: unknown): void {}`);
131
+ }
132
+ lines.push(``);
133
+ }
118
134
  const unknownFunctions = collectUnknownFunctions(mod);
119
135
  if (unknownFunctions.size > 0) {
120
136
  lines.push(`// User-defined functions referenced in effects — implement these or use extension_point`);
@@ -363,9 +379,7 @@ export function emitCapabilityEndpoint(
363
379
  lines.push(` __client.release();`);
364
380
  lines.push(` }`);
365
381
  } else if (method.sync === "realtime") {
366
- lines.push(` if (typeof broadcastToChannel === "function") {`);
367
- lines.push(` broadcastToChannel("${mod.name}", { type: "${method.name}", payload: req.body, actor: auth.actor_id });`);
368
- lines.push(` }`);
382
+ lines.push(` broadcastToChannel("${mod.name}", { type: "${method.name}", payload: req.body, actor: auth.actor_id });`);
369
383
  lines.push(` } catch (e: any) {`);
370
384
  lines.push(` res.status(400).json({ error: { code: "CAPABILITY_FAILED", message: e.message } });`);
371
385
  lines.push(` }`);
package/src/emit_tests.ts CHANGED
@@ -126,6 +126,19 @@ export function emitTestSuite(system: IR.IRSystem): string {
126
126
  lines.push(`});`);
127
127
  lines.push(``);
128
128
 
129
+ // Update test
130
+ const updatePayload = createFields.slice(0, 1).reduce((acc, f) => {
131
+ acc[f.name] = sampleValue(f.type);
132
+ return acc;
133
+ }, {} as Record<string, any>);
134
+ lines.push(`await test("PUT ${tablePath}/:id — updates entity", async () => {`);
135
+ lines.push(` if (!__${toSnakeCase(model.name)}_id) { throw new Error("Skipped: no id from create test"); }`);
136
+ lines.push(` const { status, data } = await request("PUT", \`${tablePath}/\${__${toSnakeCase(model.name)}_id}\`, ${JSON.stringify(updatePayload)});`);
137
+ lines.push(` assert(status === 200, \`Expected 200, got \${status}: \${JSON.stringify(data)}\`);`);
138
+ lines.push(` assert(data.id === __${toSnakeCase(model.name)}_id, "ID must match after update");`);
139
+ lines.push(`});`);
140
+ lines.push(``);
141
+
129
142
  // Capability tests
130
143
  for (const iface of mod.interfaces) {
131
144
  for (const method of iface.methods) {
@@ -141,6 +154,18 @@ export function emitTestSuite(system: IR.IRSystem): string {
141
154
  lines.push(`});`);
142
155
  lines.push(``);
143
156
 
157
+ // Idempotency test
158
+ if (method.idempotent) {
159
+ lines.push(`await test("POST ${endpoint} — idempotent (same result on repeat)", async () => {`);
160
+ lines.push(` const payload = { ${toSnakeCase(model.name)}_id: __${toSnakeCase(model.name)}_id };`);
161
+ lines.push(` const { status: s1, data: d1 } = await request("POST", "${endpoint}", payload);`);
162
+ lines.push(` const { status: s2, data: d2 } = await request("POST", "${endpoint}", payload);`);
163
+ lines.push(` assert(s1 === s2, \`Idempotency: first call \${s1}, second call \${s2}\`);`);
164
+ lines.push(` assert(JSON.stringify(d1) === JSON.stringify(d2), "Idempotency: responses must be identical");`);
165
+ lines.push(`});`);
166
+ lines.push(``);
167
+ }
168
+
144
169
  if (method.preconditions.length > 0) {
145
170
  lines.push(`await test("POST ${endpoint} — returns 401 without auth", async () => {`);
146
171
  lines.push(` const res = await fetch(\`\${BASE_URL}${endpoint}\`, { method: "POST" });`);
@@ -154,6 +179,22 @@ export function emitTestSuite(system: IR.IRSystem): string {
154
179
  // State machine tests
155
180
  for (const sm of mod.state_machines) {
156
181
  lines.push(`// State machine: ${sm.entity}`);
182
+
183
+ // Valid transition test — transition from initial state to first reachable state
184
+ const firstTransition = sm.transitions.find(t => t.from === sm.initial);
185
+ if (firstTransition) {
186
+ lines.push(`await test("PUT ${tablePath}/:id — valid state transition (${firstTransition.from} → ${firstTransition.to})", async () => {`);
187
+ lines.push(` if (!__${toSnakeCase(model.name)}_id) { throw new Error("Skipped: no id from create test"); }`);
188
+ lines.push(` const { status, data } = await request("PUT", \`${tablePath}/\${__${toSnakeCase(model.name)}_id}\`, {`);
189
+ lines.push(` state: "${firstTransition.to}",`);
190
+ lines.push(` });`);
191
+ lines.push(` assert(status === 200, \`Expected 200 for valid transition, got \${status}: \${JSON.stringify(data)}\`);`);
192
+ lines.push(` assert(data.state === "${firstTransition.to}", \`Expected state '${firstTransition.to}', got '\${data.state}'\`);`);
193
+ lines.push(`});`);
194
+ lines.push(``);
195
+ }
196
+
197
+ // Invalid transition test
157
198
  lines.push(`await test("PUT ${tablePath}/:id — rejects invalid state transition", async () => {`);
158
199
  lines.push(` const { status, data } = await request("PUT", \`${tablePath}/\${__${toSnakeCase(model.name)}_id}\`, {`);
159
200
  lines.push(` state: "__invalid_state__",`);