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.
- 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_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 +33 -0
- package/dist/emit_full.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 +34 -5
- package/dist/emitter.js.map +1 -1
- 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 +2 -2
- package/dist/lowering_channels.js.map +1 -1
- package/dist/typechecker.js +32 -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_capability.ts +61 -6
- package/src/emit_composition.ts +36 -3
- package/src/emit_events.ts +70 -0
- package/src/emit_full.ts +36 -1
- 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 +592 -566
- package/src/lowering.ts +19 -1
- package/src/lowering_channels.ts +2 -2
- package/src/typechecker.ts +606 -591
- 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__",`);
|