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
package/src/emit_composition.ts
CHANGED
|
@@ -57,7 +57,30 @@ export function emitPipelineBody(method: IR.IRMethod, indent: string = " "):
|
|
|
57
57
|
} else if (p.on_error.action === "ignore") {
|
|
58
58
|
lines.push(`${indent} // on_error: ignore — log only`);
|
|
59
59
|
} else if (p.on_error.action === "retry") {
|
|
60
|
-
|
|
60
|
+
// on_error: retry — re-run the entire pipeline with exponential backoff.
|
|
61
|
+
// Wraps the pipeline steps in a retry loop. The outer try/catch feeds __err here.
|
|
62
|
+
lines.push(`${indent} // on_error: retry with exponential backoff`);
|
|
63
|
+
lines.push(`${indent} const __maxAttempts = ${method.retry?.max_attempts ?? 3};`);
|
|
64
|
+
lines.push(`${indent} const __backoffMs = ${method.retry?.interval_ms ?? 1000};`);
|
|
65
|
+
lines.push(`${indent} let __lastErr = __err;`);
|
|
66
|
+
lines.push(`${indent} for (let __attempt = 1; __attempt <= __maxAttempts; __attempt++) {`);
|
|
67
|
+
lines.push(`${indent} await new Promise(r => setTimeout(r, __backoffMs * Math.pow(2, __attempt - 1)));`);
|
|
68
|
+
lines.push(`${indent} try {`);
|
|
69
|
+
// Re-emit all pipeline steps inside the retry loop
|
|
70
|
+
for (const step of p.steps) {
|
|
71
|
+
const callExpr = generateStepCall(step);
|
|
72
|
+
if (step.bind_as) {
|
|
73
|
+
lines.push(`${indent} __pipeline_results["${step.bind_as}"] = await ${callExpr};`);
|
|
74
|
+
} else {
|
|
75
|
+
lines.push(`${indent} await ${callExpr};`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
lines.push(`${indent} return { ok: true, value: __pipeline_results } as any; // retry succeeded`);
|
|
79
|
+
lines.push(`${indent} } catch (__retryErr: any) {`);
|
|
80
|
+
lines.push(`${indent} __lastErr = __retryErr;`);
|
|
81
|
+
lines.push(`${indent} }`);
|
|
82
|
+
lines.push(`${indent} }`);
|
|
83
|
+
lines.push(`${indent} return { ok: false, error: { code: "PIPELINE_RETRY_EXHAUSTED", message: __lastErr.message, attempts: __maxAttempts } } as any;`);
|
|
61
84
|
}
|
|
62
85
|
} else {
|
|
63
86
|
// Default: rollback on error
|
|
@@ -100,9 +123,19 @@ function emitParallelPipeline(method: IR.IRMethod, indent: string): string {
|
|
|
100
123
|
}
|
|
101
124
|
|
|
102
125
|
function generateStepCall(step: IR.IRPipelineStep): string {
|
|
103
|
-
//
|
|
126
|
+
// Substitute any arg that references a previous step's bind_as result
|
|
127
|
+
// e.g. if a prior step bound its result as "payment", an arg of "payment"
|
|
128
|
+
// becomes __pipeline_results["payment"]
|
|
104
129
|
const args = step.call_args.map(arg => {
|
|
105
|
-
//
|
|
130
|
+
// Simple identifier that looks like a binding reference
|
|
131
|
+
if (/^\w+$/.test(arg.trim())) {
|
|
132
|
+
return `(__pipeline_results["${arg.trim()}"] ?? ${arg.trim()})`;
|
|
133
|
+
}
|
|
134
|
+
// Dotted path like "payment.id" — resolve the root from pipeline results
|
|
135
|
+
const dotMatch = arg.trim().match(/^(\w+)(\..+)$/);
|
|
136
|
+
if (dotMatch) {
|
|
137
|
+
return `(__pipeline_results["${dotMatch[1]}"] as any)${dotMatch[2]} ?? undefined`;
|
|
138
|
+
}
|
|
106
139
|
return arg;
|
|
107
140
|
});
|
|
108
141
|
return `${step.call_name}(${args.join(", ")})`;
|
package/src/emit_events.ts
CHANGED
|
@@ -305,3 +305,73 @@ export const eventBus = {
|
|
|
305
305
|
};
|
|
306
306
|
`;
|
|
307
307
|
}
|
|
308
|
+
|
|
309
|
+
// ─── Typed Event Publishers ───────────────────────────────────────────────────
|
|
310
|
+
// Generates per-event emitXxx() typed publisher functions as specified in
|
|
311
|
+
// spec/09_CODEGEN.md §5.4. These wrap eventBus.publish with a typed payload
|
|
312
|
+
// interface so callers get compile-time safety instead of raw Record<string,unknown>.
|
|
313
|
+
|
|
314
|
+
const TS_TYPE_MAP: Record<string, string> = {
|
|
315
|
+
string: "string", uint: "number", int: "number", float: "number",
|
|
316
|
+
bool: "boolean", timestamp: "Date", uuid: "string", bytes: "Buffer", json: "unknown",
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
function toTsType(irType: string): string {
|
|
320
|
+
if (TS_TYPE_MAP[irType]) return TS_TYPE_MAP[irType];
|
|
321
|
+
const listMatch = irType.match(/^list<(.+)>$/);
|
|
322
|
+
if (listMatch) return `${toTsType(listMatch[1])}[]`;
|
|
323
|
+
const setMatch = irType.match(/^set<(.+)>$/);
|
|
324
|
+
if (setMatch) return `${toTsType(setMatch[1])}[]`;
|
|
325
|
+
const optMatch = irType.match(/^optional<(.+)>$/);
|
|
326
|
+
if (optMatch) return `${toTsType(optMatch[1])} | null`;
|
|
327
|
+
return irType;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function emitTypedEventPublishers(system: IR.IRSystem): string {
|
|
331
|
+
if (system.events.length === 0) return "";
|
|
332
|
+
|
|
333
|
+
const lines: string[] = [];
|
|
334
|
+
lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
|
|
335
|
+
lines.push(`// Typed event publisher functions — one per declared event.`);
|
|
336
|
+
lines.push(`// Import these instead of calling eventBus.publish() directly.`);
|
|
337
|
+
lines.push(``);
|
|
338
|
+
lines.push(`import { eventBus } from "./events";`);
|
|
339
|
+
lines.push(`import type { PoolClient } from "pg";`);
|
|
340
|
+
lines.push(``);
|
|
341
|
+
|
|
342
|
+
for (const ev of system.events) {
|
|
343
|
+
const ifaceName = `${ev.name}Payload`;
|
|
344
|
+
|
|
345
|
+
// Payload interface
|
|
346
|
+
lines.push(`export interface ${ifaceName} {`);
|
|
347
|
+
for (const field of ev.payload) {
|
|
348
|
+
const nullable = field.nullable ? " | null" : "";
|
|
349
|
+
lines.push(` ${field.name}: ${toTsType(field.type)}${nullable};`);
|
|
350
|
+
}
|
|
351
|
+
lines.push(`}`);
|
|
352
|
+
lines.push(``);
|
|
353
|
+
|
|
354
|
+
// Publisher function
|
|
355
|
+
const sourceId = ev.source && ev.source !== "unknown" ? ev.source : system.name;
|
|
356
|
+
lines.push(`/**`);
|
|
357
|
+
lines.push(` * Publish a ${ev.name} event.`);
|
|
358
|
+
lines.push(` * Delivery: ${ev.delivery}${ev.ttl_ms ? ` | TTL: ${ev.ttl_ms}ms` : ""}`);
|
|
359
|
+
lines.push(` */`);
|
|
360
|
+
lines.push(`export async function emit${ev.name}(`);
|
|
361
|
+
lines.push(` payload: ${ifaceName},`);
|
|
362
|
+
lines.push(` correlationId?: string,`);
|
|
363
|
+
lines.push(` client?: PoolClient,`);
|
|
364
|
+
lines.push(`): Promise<void> {`);
|
|
365
|
+
lines.push(` await eventBus.publish(`);
|
|
366
|
+
lines.push(` "${ev.name}",`);
|
|
367
|
+
lines.push(` payload as unknown as Record<string, unknown>,`);
|
|
368
|
+
lines.push(` "${sourceId}",`);
|
|
369
|
+
lines.push(` correlationId,`);
|
|
370
|
+
lines.push(` client,`);
|
|
371
|
+
lines.push(` );`);
|
|
372
|
+
lines.push(`}`);
|
|
373
|
+
lines.push(``);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return lines.join("\n");
|
|
377
|
+
}
|
package/src/emit_full.ts
CHANGED
|
@@ -27,11 +27,13 @@ import { emitFlowRuntime } from "./emit_extras";
|
|
|
27
27
|
import { emitAlgorithmsFile, collectUsedAlgorithms } from "./emit_composition";
|
|
28
28
|
import { emitExtensionPointStub } from "./extension_manager";
|
|
29
29
|
import * as AST from "./ast";
|
|
30
|
-
import { emitDurableEventBus, emitOutboxSchema } from "./emit_events";
|
|
30
|
+
import { emitDurableEventBus, emitOutboxSchema, emitTypedEventPublishers } from "./emit_events";
|
|
31
31
|
import { emitBatchExecutor } from "./emit_batch";
|
|
32
32
|
import { emitSourceMapFile, emitDebugHandler } from "./emit_sourcemap";
|
|
33
33
|
import { emitTestSuite } from "./emit_tests";
|
|
34
34
|
import { emitDockerfile, emitDockerignore, emitK8sDeployment, emitGithubActions } from "./emit_deploy";
|
|
35
|
+
import { emitModelFile, emitModelsIndex } from "./emit_models";
|
|
36
|
+
import { emitOpenApiSchema } from "./emit_openapi";
|
|
35
37
|
|
|
36
38
|
function toSnakeCase(s: string): string {
|
|
37
39
|
return s.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
|
|
@@ -54,6 +56,10 @@ export class FullEmitter {
|
|
|
54
56
|
files.push({ path: "src/events.ts", content: emitDurableEventBus(system), language: "typescript", source_module: "infra" });
|
|
55
57
|
// Outbox SQL schema
|
|
56
58
|
files.push({ path: "migrations/event_outbox.sql", content: emitOutboxSchema(), language: "sql", source_module: "infra" });
|
|
59
|
+
// Typed event publisher functions (one per declared event)
|
|
60
|
+
if (system.events.length > 0) {
|
|
61
|
+
files.push({ path: "src/publishers.ts", content: emitTypedEventPublishers(system), language: "typescript", source_module: "infra" });
|
|
62
|
+
}
|
|
57
63
|
files.push({ path: "src/auth.ts", content: emitAuthMiddleware(system), language: "typescript", source_module: "infra" });
|
|
58
64
|
files.push({ path: "src/logger.ts", content: emitLogger(system), language: "typescript", source_module: "infra" });
|
|
59
65
|
files.push({ path: "src/metrics.ts", content: emitMetrics(), language: "typescript", source_module: "infra" });
|
|
@@ -156,6 +162,29 @@ export class FullEmitter {
|
|
|
156
162
|
// 5. Source: main entry point
|
|
157
163
|
files.push({ path: "src/index.ts", content: emitIndex(system), language: "typescript", source_module: "root" });
|
|
158
164
|
|
|
165
|
+
// 5a. Model interfaces, schemas, and validators (one file per model)
|
|
166
|
+
const modelFiles: string[] = [];
|
|
167
|
+
for (const mod of system.modules) {
|
|
168
|
+
for (const model of mod.models) {
|
|
169
|
+
const modelPath = `src/models/${toSnakeCase(model.name)}.ts`;
|
|
170
|
+
files.push({
|
|
171
|
+
path: modelPath,
|
|
172
|
+
content: emitModelFile(model, mod, system),
|
|
173
|
+
language: "typescript",
|
|
174
|
+
source_module: mod.id,
|
|
175
|
+
});
|
|
176
|
+
modelFiles.push(modelPath);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (modelFiles.length > 0) {
|
|
180
|
+
files.push({
|
|
181
|
+
path: "src/models/index.ts",
|
|
182
|
+
content: emitModelsIndex(system),
|
|
183
|
+
language: "typescript",
|
|
184
|
+
source_module: "shared",
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
159
188
|
// 6. SQL migrations — run schema emitter ONCE, then match by model name
|
|
160
189
|
const schemas: string[] = [];
|
|
161
190
|
const allSchemaFiles = this.schemaEmitter.emit(system);
|
|
@@ -180,6 +209,12 @@ export class FullEmitter {
|
|
|
180
209
|
// 9. README
|
|
181
210
|
files.push({ path: "README.md", content: this.emitReadme(system), language: "yaml", source_module: "root" });
|
|
182
211
|
|
|
212
|
+
// 9a. OpenAPI schema (spec/09_CODEGEN.md §2 — ApiService secondary target)
|
|
213
|
+
const openApiContent = emitOpenApiSchema(system);
|
|
214
|
+
if (openApiContent) {
|
|
215
|
+
files.push({ path: "openapi.json", content: openApiContent, language: "json", source_module: "root" });
|
|
216
|
+
}
|
|
217
|
+
|
|
183
218
|
// 10. Source map + debug handler
|
|
184
219
|
files.push({ path: `${system.name}.bone.map`, content: emitSourceMapFile(system, `${system.name}.bone`), language: "json", source_module: "root" });
|
|
185
220
|
files.push({ path: "src/debug.ts", content: emitDebugHandler(system), language: "typescript", source_module: "infra" });
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BoneScript Model Emitter
|
|
3
|
+
* Generates per-model TypeScript artifacts as specified in spec/09_CODEGEN.md §5.1:
|
|
4
|
+
* - XxxInterface — typed interface
|
|
5
|
+
* - XxxSchema — const with field metadata (type, nullable, unique, indexed)
|
|
6
|
+
* - validateXxx() — runtime validation function derived from IR constraints
|
|
7
|
+
*
|
|
8
|
+
* These are emitted to src/models/<entity_name>.ts
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as IR from "./ir";
|
|
12
|
+
import { toTsType, toSnakeCase } from "./emit_router";
|
|
13
|
+
|
|
14
|
+
// ─── Per-model file ───────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export function emitModelFile(model: IR.IRModel, mod: IR.IRModule, system: IR.IRSystem): string {
|
|
17
|
+
const lines: string[] = [];
|
|
18
|
+
lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
|
|
19
|
+
lines.push(`// Source hash: ${system.source_hash}`);
|
|
20
|
+
lines.push(`// Module: ${mod.name}`);
|
|
21
|
+
lines.push(``);
|
|
22
|
+
|
|
23
|
+
// ── Interface ──────────────────────────────────────────────────────────────
|
|
24
|
+
lines.push(`export interface ${model.name} {`);
|
|
25
|
+
for (const field of model.fields) {
|
|
26
|
+
// Skip generated-always fields (derived columns)
|
|
27
|
+
if (field.default_value?.startsWith("GENERATED ALWAYS")) continue;
|
|
28
|
+
const nullable = field.nullable ? " | null" : "";
|
|
29
|
+
lines.push(` ${field.name}: ${toTsType(field.type)}${nullable};`);
|
|
30
|
+
}
|
|
31
|
+
lines.push(`}`);
|
|
32
|
+
lines.push(``);
|
|
33
|
+
|
|
34
|
+
// ── Schema const ───────────────────────────────────────────────────────────
|
|
35
|
+
lines.push(`export const ${model.name}Schema = {`);
|
|
36
|
+
for (const field of model.fields) {
|
|
37
|
+
if (field.default_value?.startsWith("GENERATED ALWAYS")) continue;
|
|
38
|
+
const meta: string[] = [
|
|
39
|
+
`type: ${JSON.stringify(field.type)}`,
|
|
40
|
+
`nullable: ${field.nullable}`,
|
|
41
|
+
`unique: ${field.unique}`,
|
|
42
|
+
`indexed: ${field.indexed}`,
|
|
43
|
+
];
|
|
44
|
+
if (field.default_value && !field.default_value.startsWith("GENERATED")) {
|
|
45
|
+
meta.push(`default: ${JSON.stringify(field.default_value)}`);
|
|
46
|
+
}
|
|
47
|
+
lines.push(` ${field.name}: { ${meta.join(", ")} },`);
|
|
48
|
+
}
|
|
49
|
+
lines.push(`} as const;`);
|
|
50
|
+
lines.push(``);
|
|
51
|
+
|
|
52
|
+
// ── Input type (omits server-set fields) ──────────────────────────────────
|
|
53
|
+
const inputFields = model.fields.filter(f =>
|
|
54
|
+
!["id", "created_at", "updated_at"].includes(f.name) &&
|
|
55
|
+
!f.default_value?.startsWith("GENERATED ALWAYS")
|
|
56
|
+
);
|
|
57
|
+
lines.push(`export interface Create${model.name}Input {`);
|
|
58
|
+
for (const field of inputFields) {
|
|
59
|
+
const nullable = field.nullable ? "?" : "";
|
|
60
|
+
lines.push(` ${field.name}${nullable}: ${toTsType(field.type)};`);
|
|
61
|
+
}
|
|
62
|
+
lines.push(`}`);
|
|
63
|
+
lines.push(``);
|
|
64
|
+
|
|
65
|
+
lines.push(`export interface Update${model.name}Input {`);
|
|
66
|
+
for (const field of inputFields) {
|
|
67
|
+
lines.push(` ${field.name}?: ${toTsType(field.type)};`);
|
|
68
|
+
}
|
|
69
|
+
lines.push(`}`);
|
|
70
|
+
lines.push(``);
|
|
71
|
+
|
|
72
|
+
// ── Validation function ────────────────────────────────────────────────────
|
|
73
|
+
lines.push(`export interface ValidationError {`);
|
|
74
|
+
lines.push(` field: string;`);
|
|
75
|
+
lines.push(` code: string;`);
|
|
76
|
+
lines.push(` message: string;`);
|
|
77
|
+
lines.push(`}`);
|
|
78
|
+
lines.push(``);
|
|
79
|
+
lines.push(`export function validate${model.name}(input: unknown): { ok: true; value: ${model.name} } | { ok: false; errors: ValidationError[] } {`);
|
|
80
|
+
lines.push(` const errors: ValidationError[] = [];`);
|
|
81
|
+
lines.push(` const data = input as Record<string, unknown>;`);
|
|
82
|
+
lines.push(``);
|
|
83
|
+
|
|
84
|
+
// Required field presence checks
|
|
85
|
+
for (const field of model.fields) {
|
|
86
|
+
if (field.nullable || field.default_value || ["id", "created_at", "updated_at"].includes(field.name)) continue;
|
|
87
|
+
if (field.default_value?.startsWith("GENERATED ALWAYS")) continue;
|
|
88
|
+
lines.push(` if (data.${field.name} === undefined || data.${field.name} === null) {`);
|
|
89
|
+
lines.push(` errors.push({ field: "${field.name}", code: "REQUIRED", message: "${field.name} is required" });`);
|
|
90
|
+
lines.push(` }`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Type checks for primitive fields
|
|
94
|
+
for (const field of model.fields) {
|
|
95
|
+
if (["id", "created_at", "updated_at"].includes(field.name)) continue;
|
|
96
|
+
if (field.default_value?.startsWith("GENERATED ALWAYS")) continue;
|
|
97
|
+
const check = typeCheckExpr(field.type, `data.${field.name}`);
|
|
98
|
+
if (check) {
|
|
99
|
+
lines.push(` if (data.${field.name} !== undefined && data.${field.name} !== null && !(${check})) {`);
|
|
100
|
+
lines.push(` errors.push({ field: "${field.name}", code: "INVALID_TYPE", message: "${field.name} must be ${field.type}" });`);
|
|
101
|
+
lines.push(` }`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Constraint-derived checks from model constraints
|
|
106
|
+
for (const c of model.constraints) {
|
|
107
|
+
if (c.kind === "unique") continue; // enforced by DB
|
|
108
|
+
if (c.kind === "range" && c.params["min"] !== undefined && c.params["max"] !== undefined) {
|
|
109
|
+
lines.push(` if (typeof data.${c.target} === "number" && (data.${c.target} < ${c.params["min"]} || data.${c.target} > ${c.params["max"]})) {`);
|
|
110
|
+
lines.push(` errors.push({ field: "${c.target}", code: "OUT_OF_RANGE", message: "${c.target} must be between ${c.params["min"]} and ${c.params["max"]}" });`);
|
|
111
|
+
lines.push(` }`);
|
|
112
|
+
}
|
|
113
|
+
if (c.kind === "enum" && Array.isArray(c.params["values"])) {
|
|
114
|
+
const vals = (c.params["values"] as string[]).map(v => JSON.stringify(v)).join(", ");
|
|
115
|
+
lines.push(` if (data.${c.target} !== undefined && ![${vals}].includes(data.${c.target} as string)) {`);
|
|
116
|
+
lines.push(` errors.push({ field: "${c.target}", code: "INVALID_ENUM", message: "${c.target} must be one of: ${(c.params["values"] as string[]).join(", ")}" });`);
|
|
117
|
+
lines.push(` }`);
|
|
118
|
+
}
|
|
119
|
+
if (c.kind === "check" && typeof c.params["expression"] === "string") {
|
|
120
|
+
// Parse length-in-range constraints like "username.length in 3..32"
|
|
121
|
+
const lenMatch = (c.params["expression"] as string).match(/^(\w+)\.length\s+in\s+(\d+)\.\.(\d+)$/);
|
|
122
|
+
if (lenMatch) {
|
|
123
|
+
const [, fieldName, minLen, maxLen] = lenMatch;
|
|
124
|
+
lines.push(` if (typeof data.${fieldName} === "string" && (data.${fieldName}.length < ${minLen} || data.${fieldName}.length > ${maxLen})) {`);
|
|
125
|
+
lines.push(` errors.push({ field: "${fieldName}", code: "INVALID_LENGTH", message: "${fieldName} must be between ${minLen} and ${maxLen} characters" });`);
|
|
126
|
+
lines.push(` }`);
|
|
127
|
+
}
|
|
128
|
+
// Parse non-negative constraints like "score >= 0"
|
|
129
|
+
const nonNegMatch = (c.params["expression"] as string).match(/^(\w+)\s*>=\s*0$/);
|
|
130
|
+
if (nonNegMatch) {
|
|
131
|
+
const [, fieldName] = nonNegMatch;
|
|
132
|
+
lines.push(` if (typeof data.${fieldName} === "number" && data.${fieldName} < 0) {`);
|
|
133
|
+
lines.push(` errors.push({ field: "${fieldName}", code: "OUT_OF_RANGE", message: "${fieldName} must be >= 0" });`);
|
|
134
|
+
lines.push(` }`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
lines.push(``);
|
|
140
|
+
lines.push(` if (errors.length > 0) return { ok: false, errors };`);
|
|
141
|
+
lines.push(` return { ok: true, value: data as unknown as ${model.name} };`);
|
|
142
|
+
lines.push(`}`);
|
|
143
|
+
lines.push(``);
|
|
144
|
+
|
|
145
|
+
return lines.join("\n");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function typeCheckExpr(irType: string, expr: string): string | null {
|
|
149
|
+
switch (irType) {
|
|
150
|
+
case "string": return `typeof ${expr} === "string"`;
|
|
151
|
+
case "uint": return `typeof ${expr} === "number" && Number.isInteger(${expr}) && ${expr} >= 0`;
|
|
152
|
+
case "int": return `typeof ${expr} === "number" && Number.isInteger(${expr})`;
|
|
153
|
+
case "float": return `typeof ${expr} === "number"`;
|
|
154
|
+
case "bool": return `typeof ${expr} === "boolean"`;
|
|
155
|
+
case "uuid": return `typeof ${expr} === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(${expr})`;
|
|
156
|
+
case "timestamp": return `${expr} instanceof Date || typeof ${expr} === "string"`;
|
|
157
|
+
default:
|
|
158
|
+
if (irType.startsWith("list<") || irType.startsWith("set<")) return `Array.isArray(${expr})`;
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Barrel index for all models ─────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
export function emitModelsIndex(system: IR.IRSystem): string {
|
|
166
|
+
const lines: string[] = [];
|
|
167
|
+
lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
|
|
168
|
+
lines.push(`// Re-exports all model interfaces, schemas, and validators.`);
|
|
169
|
+
lines.push(``);
|
|
170
|
+
for (const mod of system.modules) {
|
|
171
|
+
for (const model of mod.models) {
|
|
172
|
+
lines.push(`export * from "./${toSnakeCase(model.name)}";`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return lines.join("\n");
|
|
176
|
+
}
|