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
@@ -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__",`);
package/src/emitter.ts CHANGED
@@ -62,6 +62,12 @@ function toSqlType(irType: string): string {
62
62
  return "JSONB";
63
63
  }
64
64
 
65
+ /** Returns an inline SQL CHECK constraint for types that need one, or empty string. */
66
+ function sqlCheckConstraint(irType: string): string {
67
+ if (irType === "uint") return " CHECK (VALUE >= 0)";
68
+ return "";
69
+ }
70
+
65
71
  function toSnakeCase(s: string): string {
66
72
  return s.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
67
73
  }
@@ -126,7 +132,7 @@ export class Emitter {
126
132
 
127
133
  const fieldLines: string[] = [];
128
134
  for (const field of model.fields) {
129
- let line = ` ${field.name} ${toSqlType(field.type)}`;
135
+ let line = ` ${field.name} ${toSqlType(field.type)}${sqlCheckConstraint(field.type)}`;
130
136
  if (!field.nullable) line += " NOT NULL";
131
137
  if (field.default_value) {
132
138
  if (field.default_value === "gen_random_uuid()") line += " DEFAULT gen_random_uuid()";
@@ -158,6 +164,16 @@ export class Emitter {
158
164
  }
159
165
  }
160
166
 
167
+ // Add cardinality CHECK constraints from relations
168
+ // has_one: enforce at most 1 child row via a partial unique index (emitted below)
169
+ // has_many with explicit max: enforce via CHECK on count (done via trigger — see below)
170
+ for (const rel of (mod as any).relations_with_cardinality || []) {
171
+ if (rel.cardinality && rel.cardinality.max !== "*" && typeof rel.cardinality.max === "number") {
172
+ // Will be enforced via trigger — placeholder comment
173
+ fieldLines.push(` -- cardinality: ${rel.name} max ${rel.cardinality.max} (enforced by trigger)`);
174
+ }
175
+ }
176
+
161
177
  lines.push(fieldLines.join(",\n"));
162
178
  lines.push(`);`);
163
179
  lines.push(``);
@@ -176,6 +192,46 @@ export class Emitter {
176
192
  }
177
193
  }
178
194
 
195
+ // Cardinality enforcement
196
+ for (const rel of mod.relations || []) {
197
+ // has_one: enforce via unique index on the FK column in the child table
198
+ if (rel.kind === "has_one") {
199
+ const childTable = rel.to_table;
200
+ const fk = rel.foreign_key;
201
+ lines.push(``);
202
+ lines.push(`-- has_one cardinality: each ${tableName.slice(0, -1)} may have at most one ${childTable.slice(0, -1)}`);
203
+ lines.push(`CREATE UNIQUE INDEX IF NOT EXISTS idx_${childTable}_${fk}_unique ON ${childTable} (${fk});`);
204
+ }
205
+
206
+ // has_many with explicit numeric max: enforce via a BEFORE INSERT trigger
207
+ if (rel.kind === "has_many" && rel.cardinality && rel.cardinality.max !== "*") {
208
+ const maxCount = rel.cardinality.max as number;
209
+ const childTable = rel.to_table;
210
+ const fk = rel.foreign_key;
211
+ const fnName = `check_${tableName}_${rel.name}_max`;
212
+ lines.push(``);
213
+ lines.push(`-- has_many cardinality: max ${maxCount} ${childTable} per ${tableName.slice(0, -1)}`);
214
+ lines.push(`CREATE OR REPLACE FUNCTION ${fnName}()`);
215
+ lines.push(`RETURNS TRIGGER AS $$`);
216
+ lines.push(`DECLARE`);
217
+ lines.push(` current_count INTEGER;`);
218
+ lines.push(`BEGIN`);
219
+ lines.push(` SELECT COUNT(*) INTO current_count FROM ${childTable} WHERE ${fk} = NEW.${fk};`);
220
+ lines.push(` IF current_count >= ${maxCount} THEN`);
221
+ lines.push(` RAISE EXCEPTION 'Cardinality violation: ${tableName.slice(0, -1)} already has ${maxCount} ${childTable} (max ${maxCount})';`);
222
+ lines.push(` END IF;`);
223
+ lines.push(` RETURN NEW;`);
224
+ lines.push(`END;`);
225
+ lines.push(`$$ LANGUAGE plpgsql;`);
226
+ lines.push(``);
227
+ lines.push(`DROP TRIGGER IF EXISTS trg_${fnName} ON ${childTable};`);
228
+ lines.push(`CREATE TRIGGER trg_${fnName}`);
229
+ lines.push(` BEFORE INSERT ON ${childTable}`);
230
+ lines.push(` FOR EACH ROW`);
231
+ lines.push(` EXECUTE FUNCTION ${fnName}();`);
232
+ }
233
+ }
234
+
179
235
  // Junction tables for many_to_many
180
236
  for (const rel of mod.relations || []) {
181
237
  if (rel.kind === "many_to_many" && rel.junction_table) {
@@ -345,7 +401,7 @@ export class Emitter {
345
401
  // Implementation class
346
402
  lines.push(`export class ${mod.name} implements ${iface.name} {`);
347
403
  for (const method of iface.methods) {
348
- lines.push(this.emitMethod(method));
404
+ lines.push(this.emitMethod(method, mod, system));
349
405
  }
350
406
  lines.push(`}`);
351
407
  lines.push(``);
@@ -359,7 +415,7 @@ export class Emitter {
359
415
  };
360
416
  }
361
417
 
362
- private emitMethod(method: IR.IRMethod): string {
418
+ private emitMethod(method: IR.IRMethod, mod: IR.IRModule, system: IR.IRSystem): string {
363
419
  const lines: string[] = [];
364
420
  const params = method.input.map(f => `${f.name}: ${toTsType(f.type)}`).join(", ");
365
421
  const ctxParam = method.authenticated ? "ctx: RequestContext" : "";
@@ -404,8 +460,28 @@ export class Emitter {
404
460
  lines.push(``);
405
461
  }
406
462
 
407
- lines.push(` // TODO: Implementation`);
408
- lines.push(` throw new Error("Not implemented: ${method.name}");`);
463
+ // Real implementation — delegate to emitCapabilityBody for capabilities,
464
+ // or generate CRUD SQL for standard methods
465
+ const { emitCapabilityBody } = require("./emit_capability");
466
+ const { emitPipelineBody, emitAlgorithmBody } = require("./emit_composition");
467
+
468
+ if (method.pipeline) {
469
+ lines.push(emitPipelineBody(method, " "));
470
+ } else if (method.algorithm) {
471
+ lines.push(emitAlgorithmBody(method, " "));
472
+ } else if (method.effects.length > 0 || method.preconditions.length > 0) {
473
+ // Capability with effects/preconditions — use the full capability body emitter
474
+ try {
475
+ lines.push(emitCapabilityBody(method, mod, system, " "));
476
+ } catch {
477
+ // Fallback: emit a descriptive stub if body generation fails
478
+ lines.push(` // Effects: ${method.effects.map((e: any) => e.target + " " + e.op + " " + e.value).join("; ")}`);
479
+ lines.push(` return { ok: false, error: { code: "NOT_IMPLEMENTED", message: "${method.name} not yet implemented" } };`);
480
+ }
481
+ } else {
482
+ // CRUD or simple method — emit a typed not-implemented stub
483
+ lines.push(` return { ok: false, error: { code: "NOT_IMPLEMENTED", message: "${method.name} not yet implemented" } };`);
484
+ }
409
485
  lines.push(` }`);
410
486
  lines.push(``);
411
487
 
package/src/ir.ts CHANGED
@@ -169,6 +169,7 @@ export interface IRRelation {
169
169
  to_table: string;
170
170
  foreign_key: string; // column name on the owning side
171
171
  junction_table?: string; // only for many_to_many
172
+ cardinality?: { min: number; max: number | "*" }; // optional explicit bounds
172
173
  }
173
174
 
174
175
  // ─── Invariants ──────────────────────────────────────────────────────────────
package/src/lowering.ts CHANGED
@@ -58,9 +58,27 @@ export class Lowering {
58
58
  modules.push(lowerChannel(this.systemName, channel));
59
59
  }
60
60
 
61
+ // Build a map from event name → emitting module id by scanning all capabilities
62
+ // across all entities. This resolves the event source before lowering events.
63
+ const eventSourceMap = new Map<string, string>();
64
+ for (const entity of entities) {
65
+ const moduleId = makeId(this.systemName, "api_service", `${entity.name}Service`);
66
+ const relatedCaps = capabilities.filter(c =>
67
+ c.params.some(p => p.type.kind === "EntityRefType" && p.type.name === entity.name)
68
+ );
69
+ for (const cap of relatedCaps) {
70
+ for (const emit of cap.emits) {
71
+ if (!eventSourceMap.has(emit.eventName)) {
72
+ eventSourceMap.set(emit.eventName, moduleId);
73
+ }
74
+ }
75
+ }
76
+ }
77
+
61
78
  // Events
62
79
  for (const ev of eventDecls) {
63
- events.push(lowerEvent(this.systemName, ev));
80
+ const source = eventSourceMap.get(ev.name) || "unknown";
81
+ events.push(lowerEvent(this.systemName, ev, source));
64
82
  }
65
83
 
66
84
  // Flows
@@ -73,18 +73,19 @@ export function lowerChannel(systemName: string, channel: AST.ChannelDeclNode):
73
73
  ordering: channel.ordering || "fifo",
74
74
  persistence: channel.persistence || "none",
75
75
  max_size: channel.maxSize || 10000,
76
+ ...(channel.filter ? { filter: serializeExpr(channel.filter) } : {}),
76
77
  },
77
78
  };
78
79
  }
79
80
 
80
81
  // ─── Event Lowering ───────────────────────────────────────────────────────────
81
82
 
82
- export function lowerEvent(systemName: string, ev: AST.EventDeclNode): IR.IREvent {
83
+ export function lowerEvent(systemName: string, ev: AST.EventDeclNode, source: string): IR.IREvent {
83
84
  return {
84
85
  id: makeId(systemName, "event", ev.name),
85
86
  name: ev.name,
86
87
  payload: ev.payload.map(lowerFieldHelper),
87
- source: "unknown",
88
+ source,
88
89
  delivery: (ev.delivery as IR.IRDeliveryMode) || "at_least_once",
89
90
  ordering: "fifo",
90
91
  ttl_ms: parseDurationMs(ev.ttl),