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.
- package/dist/commands/compile.js +42 -10
- package/dist/commands/compile.js.map +1 -1
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.js +29 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/emit_auth.d.ts +14 -2
- package/dist/emit_auth.js +498 -60
- package/dist/emit_auth.js.map +1 -1
- package/dist/emit_capability.js +61 -7
- package/dist/emit_capability.js.map +1 -1
- package/dist/emit_composition.js +37 -3
- package/dist/emit_composition.js.map +1 -1
- package/dist/emit_events.d.ts +1 -0
- package/dist/emit_events.js +68 -1
- package/dist/emit_events.js.map +1 -1
- package/dist/emit_full.js +166 -11
- package/dist/emit_full.js.map +1 -1
- package/dist/emit_index.js +46 -1
- package/dist/emit_index.js.map +1 -1
- package/dist/emit_models.d.ts +12 -0
- package/dist/emit_models.js +171 -0
- package/dist/emit_models.js.map +1 -0
- package/dist/emit_openapi.d.ts +9 -0
- package/dist/emit_openapi.js +308 -0
- package/dist/emit_openapi.js.map +1 -0
- package/dist/emit_router.js +19 -4
- package/dist/emit_router.js.map +1 -1
- package/dist/emit_tests.js +37 -0
- package/dist/emit_tests.js.map +1 -1
- package/dist/emitter.js +81 -5
- package/dist/emitter.js.map +1 -1
- package/dist/ir.d.ts +4 -0
- package/dist/lowering.js +16 -1
- package/dist/lowering.js.map +1 -1
- package/dist/lowering_channels.d.ts +1 -1
- package/dist/lowering_channels.js +3 -2
- package/dist/lowering_channels.js.map +1 -1
- package/dist/lowering_entities.js +11 -1
- package/dist/lowering_entities.js.map +1 -1
- package/dist/optimizer.js +1 -1
- package/dist/optimizer.js.map +1 -1
- package/dist/scaffold.js +0 -1
- package/dist/scaffold.js.map +1 -1
- package/dist/typechecker.d.ts +5 -0
- package/dist/typechecker.js +68 -13
- package/dist/typechecker.js.map +1 -1
- package/dist/verifier.d.ts +5 -0
- package/dist/verifier.js +140 -2
- package/dist/verifier.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/compile.ts +41 -10
- package/src/commands/init.ts +28 -2
- package/src/emit_auth.ts +513 -67
- package/src/emit_capability.ts +61 -6
- package/src/emit_composition.ts +36 -3
- package/src/emit_events.ts +70 -0
- package/src/emit_full.ts +172 -13
- package/src/emit_index.ts +210 -161
- package/src/emit_models.ts +176 -0
- package/src/emit_openapi.ts +318 -0
- package/src/emit_router.ts +18 -4
- package/src/emit_tests.ts +41 -0
- package/src/emitter.ts +81 -5
- package/src/ir.ts +1 -0
- package/src/lowering.ts +19 -1
- package/src/lowering_channels.ts +3 -2
- package/src/lowering_entities.ts +258 -248
- package/src/optimizer.ts +1 -1
- package/src/scaffold.ts +0 -1
- package/src/typechecker.ts +81 -15
- 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
|
+
}
|
package/src/emit_router.ts
CHANGED
|
@@ -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(`
|
|
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
|
-
|
|
408
|
-
|
|
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
|
-
|
|
80
|
+
const source = eventSourceMap.get(ev.name) || "unknown";
|
|
81
|
+
events.push(lowerEvent(this.systemName, ev, source));
|
|
64
82
|
}
|
|
65
83
|
|
|
66
84
|
// Flows
|
package/src/lowering_channels.ts
CHANGED
|
@@ -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
|
|
88
|
+
source,
|
|
88
89
|
delivery: (ev.delivery as IR.IRDeliveryMode) || "at_least_once",
|
|
89
90
|
ordering: "fifo",
|
|
90
91
|
ttl_ms: parseDurationMs(ev.ttl),
|