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
package/src/emit_capability.ts
CHANGED
|
@@ -191,6 +191,7 @@ function compileEffect(
|
|
|
191
191
|
mod: IR.IRModule,
|
|
192
192
|
system: IR.IRSystem,
|
|
193
193
|
paramIdx: { n: number },
|
|
194
|
+
method?: IR.IRMethod,
|
|
194
195
|
): CompiledEffect | null {
|
|
195
196
|
const targetParts = effect.target.split(".");
|
|
196
197
|
if (targetParts.length < 2) return null;
|
|
@@ -199,7 +200,20 @@ function compileEffect(
|
|
|
199
200
|
const fieldName = targetParts[1];
|
|
200
201
|
const nestedPath = targetParts.slice(2);
|
|
201
202
|
|
|
203
|
+
// Resolve the model: first try matching by param name → entity type via method inputs,
|
|
204
|
+
// then fall back to matching by entity name directly.
|
|
202
205
|
const model = (() => {
|
|
206
|
+
// If we have method context, resolve the param name to its entity type
|
|
207
|
+
if (method) {
|
|
208
|
+
const param = method.input.find(p => p.name === entityParam);
|
|
209
|
+
if (param) {
|
|
210
|
+
for (const m of system.modules) {
|
|
211
|
+
const found = m.models.find(mdl => mdl.name === param.type || mdl.name.toLowerCase() === param.type.toLowerCase());
|
|
212
|
+
if (found) return found;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Fall back to matching by entity name directly
|
|
203
217
|
for (const m of system.modules) {
|
|
204
218
|
const found = m.models.find(mdl =>
|
|
205
219
|
toSnakeCase(mdl.name) === entityParam || mdl.name.toLowerCase() === entityParam.toLowerCase()
|
|
@@ -221,12 +235,15 @@ function compileEffect(
|
|
|
221
235
|
const p1 = `$${paramIdx.n++}`;
|
|
222
236
|
const p2 = `$${paramIdx.n++}`;
|
|
223
237
|
const jsonbPathLiteral = `'{${nestedPath.join(",")}}'`;
|
|
238
|
+
// Use to_jsonb($1) directly — casting via ::text loses type information for
|
|
239
|
+
// non-string values (numbers, booleans, objects). to_jsonb() handles all
|
|
240
|
+
// PostgreSQL types correctly without an intermediate text cast.
|
|
224
241
|
return {
|
|
225
242
|
tableName, entityParam, idParam,
|
|
226
243
|
assignments: [],
|
|
227
244
|
description: `${effect.target} = ${effect.value}`,
|
|
228
245
|
standalone: {
|
|
229
|
-
sql: `UPDATE ${tableName} SET ${fieldName} = jsonb_set(COALESCE(${fieldName}, '{}'), ${jsonbPathLiteral}, to_jsonb(${p1}
|
|
246
|
+
sql: `UPDATE ${tableName} SET ${fieldName} = jsonb_set(COALESCE(${fieldName}, '{}'), ${jsonbPathLiteral}, to_jsonb(${p1}), true), updated_at = NOW() WHERE id = ${p2} RETURNING *`,
|
|
230
247
|
params: [valueTs, idParam],
|
|
231
248
|
},
|
|
232
249
|
};
|
|
@@ -490,7 +507,7 @@ export function emitCapabilityBody(
|
|
|
490
507
|
lines.push(`${indent}// Effects (batched by entity to minimise round-trips)`);
|
|
491
508
|
|
|
492
509
|
const paramIdx = { n: 1 };
|
|
493
|
-
const compiled = method.effects.map(e => compileEffect(e, mod, system, paramIdx));
|
|
510
|
+
const compiled = method.effects.map(e => compileEffect(e, mod, system, paramIdx, method));
|
|
494
511
|
const { batches, standalones } = batchEffects(compiled);
|
|
495
512
|
|
|
496
513
|
// Emit batched UPDATEs
|
|
@@ -518,12 +535,50 @@ export function emitCapabilityBody(
|
|
|
518
535
|
lines.push(`${indent}}`);
|
|
519
536
|
}
|
|
520
537
|
|
|
521
|
-
// Fallback for effects that couldn't be compiled
|
|
538
|
+
// Fallback for effects that couldn't be compiled.
|
|
539
|
+
// Collection-level effects (e.g. list<T>.field = value) are not yet supported
|
|
540
|
+
// by the batch compiler — emit a clear TODO rather than silently dropping them.
|
|
541
|
+
// Effects that reference a completely unknown model are a hard error.
|
|
522
542
|
for (const effect of method.effects) {
|
|
523
543
|
const paramIdx2 = { n: 1 };
|
|
524
|
-
if (!compileEffect(effect, mod, system, paramIdx2)) {
|
|
525
|
-
|
|
526
|
-
|
|
544
|
+
if (!compileEffect(effect, mod, system, paramIdx2, method)) {
|
|
545
|
+
const targetParts = effect.target.split(".");
|
|
546
|
+
const paramName = targetParts[0];
|
|
547
|
+
const param = method.input.find(p => p.name === paramName);
|
|
548
|
+
const isCollectionEffect = param && (param.type.startsWith("list<") || param.type.startsWith("set<"));
|
|
549
|
+
|
|
550
|
+
if (isCollectionEffect) {
|
|
551
|
+
// Collection-level effects: apply the field update to all items in the collection
|
|
552
|
+
// using a single batched UPDATE ... WHERE id = ANY($ids::uuid[])
|
|
553
|
+
const innerType = param.type.replace(/^(list|set)<(.+)>$/, "$2");
|
|
554
|
+
// Find the model for the inner element type
|
|
555
|
+
let elemModel: IR.IRModel | undefined;
|
|
556
|
+
for (const m of system.modules) {
|
|
557
|
+
elemModel = m.models.find(mdl => mdl.name === innerType || mdl.name.toLowerCase() === innerType.toLowerCase());
|
|
558
|
+
if (elemModel) break;
|
|
559
|
+
}
|
|
560
|
+
const tableName = elemModel ? (elemModel.name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase() + "s") : (innerType.toLowerCase() + "s");
|
|
561
|
+
const fieldName = targetParts[1];
|
|
562
|
+
const valueTs = targetParts[1] ? effect.value : "null";
|
|
563
|
+
const opSql = effect.op === "add"
|
|
564
|
+
? `${fieldName} = ${fieldName} + $1`
|
|
565
|
+
: effect.op === "remove"
|
|
566
|
+
? `${fieldName} = ${fieldName} - $1`
|
|
567
|
+
: `${fieldName} = $1`;
|
|
568
|
+
lines.push(`${indent}// Collection effect: ${effect.target} ${effect.op === "assign" ? "=" : effect.op === "add" ? "+=" : "-="} ${effect.value}`);
|
|
569
|
+
lines.push(`${indent}if (${paramName} && ${paramName}.length > 0) {`);
|
|
570
|
+
lines.push(`${indent} const __ids_${paramName} = ${paramName}.map((x: any) => x.id ?? x);`);
|
|
571
|
+
lines.push(`${indent} await query(`);
|
|
572
|
+
lines.push(`${indent} \`UPDATE ${tableName} SET ${opSql}, updated_at = NOW() WHERE id = ANY($2::uuid[])\`,`);
|
|
573
|
+
lines.push(`${indent} [${effect.value}, __ids_${paramName}],`);
|
|
574
|
+
lines.push(`${indent} );`);
|
|
575
|
+
lines.push(`${indent}}`);
|
|
576
|
+
} else {
|
|
577
|
+
throw new Error(
|
|
578
|
+
`Unsupported effect in method '${method.name}': target '${effect.target}' could not be resolved to a known model field. ` +
|
|
579
|
+
`Ensure the effect target matches a declared entity field (e.g. 'entityName.fieldName').`
|
|
580
|
+
);
|
|
581
|
+
}
|
|
527
582
|
}
|
|
528
583
|
}
|
|
529
584
|
lines.push(``);
|
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
|
@@ -23,20 +23,38 @@ import {
|
|
|
23
23
|
emitFailureRules,
|
|
24
24
|
emitMigrationDiff,
|
|
25
25
|
} from "./emit_maintenance";
|
|
26
|
-
import { emitFlowRuntime } from "./emit_extras";
|
|
26
|
+
import { emitFlowRuntime, emitChannelFilters } 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();
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
/** Resolve the auth method for the system from the resolution map or module configs. */
|
|
43
|
+
function resolveSystemAuthMethod(system: IR.IRSystem): "jwt" | "oauth2" | "apikey" {
|
|
44
|
+
const direct = system.resolution["implied.auth_method"] || system.resolution["system.auth_method"];
|
|
45
|
+
if (direct === "oauth2" || direct === "apikey" || direct === "jwt") return direct as "jwt" | "oauth2" | "apikey";
|
|
46
|
+
for (const [key, val] of Object.entries(system.resolution)) {
|
|
47
|
+
if (key.endsWith(".auth_method") && (val === "oauth2" || val === "apikey" || val === "jwt")) {
|
|
48
|
+
return val as "jwt" | "oauth2" | "apikey";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const mod of system.modules) {
|
|
52
|
+
const m = mod.config["auth_method"] as string | undefined;
|
|
53
|
+
if (m === "oauth2" || m === "apikey" || m === "jwt") return m;
|
|
54
|
+
}
|
|
55
|
+
return "jwt";
|
|
56
|
+
}
|
|
57
|
+
|
|
40
58
|
export class FullEmitter {
|
|
41
59
|
private schemaEmitter = new Emitter();
|
|
42
60
|
|
|
@@ -54,6 +72,37 @@ export class FullEmitter {
|
|
|
54
72
|
files.push({ path: "src/events.ts", content: emitDurableEventBus(system), language: "typescript", source_module: "infra" });
|
|
55
73
|
// Outbox SQL schema
|
|
56
74
|
files.push({ path: "migrations/event_outbox.sql", content: emitOutboxSchema(), language: "sql", source_module: "infra" });
|
|
75
|
+
|
|
76
|
+
// API key table migration (only when auth_method = apikey)
|
|
77
|
+
const authMethod = resolveSystemAuthMethod(system);
|
|
78
|
+
if (authMethod === "apikey") {
|
|
79
|
+
files.push({
|
|
80
|
+
path: "migrations/api_keys.sql",
|
|
81
|
+
content: [
|
|
82
|
+
"-- Generated by BoneScript compiler. DO NOT EDIT.",
|
|
83
|
+
"-- API key table for apikey auth strategy.",
|
|
84
|
+
"",
|
|
85
|
+
"CREATE TABLE IF NOT EXISTS api_keys (",
|
|
86
|
+
" id UUID PRIMARY KEY DEFAULT gen_random_uuid(),",
|
|
87
|
+
" actor_id UUID NOT NULL,",
|
|
88
|
+
" key_hash VARCHAR(64) NOT NULL UNIQUE,",
|
|
89
|
+
" key_prefix VARCHAR(16) NOT NULL,",
|
|
90
|
+
" name VARCHAR(255) NOT NULL DEFAULT 'default',",
|
|
91
|
+
" created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),",
|
|
92
|
+
" expires_at TIMESTAMPTZ NOT NULL,",
|
|
93
|
+
" revoked BOOLEAN NOT NULL DEFAULT false",
|
|
94
|
+
");",
|
|
95
|
+
"CREATE INDEX IF NOT EXISTS idx_api_keys_actor ON api_keys (actor_id);",
|
|
96
|
+
"CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys (key_hash);",
|
|
97
|
+
].join("\n"),
|
|
98
|
+
language: "sql",
|
|
99
|
+
source_module: "infra",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
// Typed event publisher functions (one per declared event)
|
|
103
|
+
if (system.events.length > 0) {
|
|
104
|
+
files.push({ path: "src/publishers.ts", content: emitTypedEventPublishers(system), language: "typescript", source_module: "infra" });
|
|
105
|
+
}
|
|
57
106
|
files.push({ path: "src/auth.ts", content: emitAuthMiddleware(system), language: "typescript", source_module: "infra" });
|
|
58
107
|
files.push({ path: "src/logger.ts", content: emitLogger(system), language: "typescript", source_module: "infra" });
|
|
59
108
|
files.push({ path: "src/metrics.ts", content: emitMetrics(), language: "typescript", source_module: "infra" });
|
|
@@ -105,17 +154,24 @@ export class FullEmitter {
|
|
|
105
154
|
"",
|
|
106
155
|
];
|
|
107
156
|
for (const ep of system.extension_points) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
157
|
+
// Use the shared emitExtensionPointStub so the format is consistent
|
|
158
|
+
// with what extension_manager.ts expects when merging on recompile.
|
|
159
|
+
const stub = emitExtensionPointStub({
|
|
160
|
+
kind: "ExtensionPointDecl",
|
|
161
|
+
loc: { line: 0, column: 0, offset: 0 },
|
|
162
|
+
name: ep.name,
|
|
163
|
+
params: ep.params.map(p => ({
|
|
164
|
+
kind: "Param" as const,
|
|
165
|
+
loc: { line: 0, column: 0, offset: 0 },
|
|
166
|
+
name: p.name,
|
|
167
|
+
type: { kind: "PrimitiveType" as const, loc: { line: 0, column: 0, offset: 0 }, name: p.type },
|
|
168
|
+
})),
|
|
169
|
+
returns: ep.returns
|
|
170
|
+
? { kind: "PrimitiveType" as const, loc: { line: 0, column: 0, offset: 0 }, name: ep.returns }
|
|
171
|
+
: null,
|
|
172
|
+
stable: ep.stable,
|
|
173
|
+
});
|
|
174
|
+
extLines.push(stub);
|
|
119
175
|
extLines.push("");
|
|
120
176
|
}
|
|
121
177
|
files.push({
|
|
@@ -138,6 +194,79 @@ export class FullEmitter {
|
|
|
138
194
|
}
|
|
139
195
|
}
|
|
140
196
|
|
|
197
|
+
// 3a. Derived field helpers (one file per entity that has derived fields)
|
|
198
|
+
for (const mod of system.modules) {
|
|
199
|
+
for (const model of mod.models) {
|
|
200
|
+
const derivedFields = model.fields.filter(f =>
|
|
201
|
+
f.default_value && f.default_value.startsWith("GENERATED ALWAYS AS")
|
|
202
|
+
);
|
|
203
|
+
if (derivedFields.length === 0) continue;
|
|
204
|
+
const lines: string[] = [
|
|
205
|
+
`// Generated by BoneScript compiler. DO NOT EDIT.`,
|
|
206
|
+
`// Derived field helpers for ${model.name}.`,
|
|
207
|
+
`// These mirror the GENERATED ALWAYS AS columns in the SQL migration.`,
|
|
208
|
+
`// Use them when you need the computed value in application code before a DB round-trip.`,
|
|
209
|
+
``,
|
|
210
|
+
`export type ${model.name}Derived = {`,
|
|
211
|
+
];
|
|
212
|
+
for (const f of derivedFields) {
|
|
213
|
+
lines.push(` ${f.name}: unknown;`);
|
|
214
|
+
}
|
|
215
|
+
lines.push(`};`);
|
|
216
|
+
lines.push(``);
|
|
217
|
+
lines.push(`export const ${model.name.toUpperCase()}_DERIVED_FIELDS = [${derivedFields.map(f => `"${f.name}"`).join(", ")}] as const;`);
|
|
218
|
+
lines.push(``);
|
|
219
|
+
lines.push(`/** Returns true if the field name is a derived (computed) field on ${model.name}. */`);
|
|
220
|
+
lines.push(`export function is${model.name}DerivedField(field: string): boolean {`);
|
|
221
|
+
lines.push(` return (${model.name.toUpperCase()}_DERIVED_FIELDS as readonly string[]).includes(field);`);
|
|
222
|
+
lines.push(`}`);
|
|
223
|
+
files.push({
|
|
224
|
+
path: `src/derived/${toSnakeCase(model.name)}.ts`,
|
|
225
|
+
content: lines.join("\n"),
|
|
226
|
+
language: "typescript",
|
|
227
|
+
source_module: mod.id,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 3b. Channel filter predicates (only if any channel has a filter expression)
|
|
233
|
+
const realtimeMods = system.modules.filter(m => m.kind === "realtime_service" && m.config["filter"]);
|
|
234
|
+
if (realtimeMods.length > 0) {
|
|
235
|
+
const filterLines: string[] = [
|
|
236
|
+
`// Generated by BoneScript compiler. DO NOT EDIT.`,
|
|
237
|
+
`// Channel filter predicates — applied before delivering messages to participants.`,
|
|
238
|
+
``,
|
|
239
|
+
`export const CHANNEL_FILTERS: Record<string, (event: any, participant: any) => boolean> = {`,
|
|
240
|
+
];
|
|
241
|
+
for (const mod of realtimeMods) {
|
|
242
|
+
const filterExpr = String(mod.config["filter"] || "true");
|
|
243
|
+
// Translate bone field refs to JS: event.field and participant.field pass through,
|
|
244
|
+
// bare identifiers are assumed to be event properties.
|
|
245
|
+
const jsFilter = filterExpr
|
|
246
|
+
.replace(/\band\b/g, "&&")
|
|
247
|
+
.replace(/\bor\b/g, "||")
|
|
248
|
+
.replace(/\bnot\b/g, "!")
|
|
249
|
+
.replace(/\b==\b/g, "===")
|
|
250
|
+
.replace(/\b!=\b/g, "!==")
|
|
251
|
+
.replace(/\bcontains\b/g, "?.includes");
|
|
252
|
+
filterLines.push(` "${mod.name}": (event, participant) => {`);
|
|
253
|
+
filterLines.push(` try { return Boolean(${jsFilter}); } catch { return true; }`);
|
|
254
|
+
filterLines.push(` },`);
|
|
255
|
+
}
|
|
256
|
+
filterLines.push(`};`);
|
|
257
|
+
filterLines.push(``);
|
|
258
|
+
filterLines.push(`export function shouldDeliver(channel: string, event: any, participant: any): boolean {`);
|
|
259
|
+
filterLines.push(` const filter = CHANNEL_FILTERS[channel];`);
|
|
260
|
+
filterLines.push(` return filter ? filter(event, participant) : true;`);
|
|
261
|
+
filterLines.push(`}`);
|
|
262
|
+
files.push({
|
|
263
|
+
path: "src/channel_filters.ts",
|
|
264
|
+
content: filterLines.join("\n"),
|
|
265
|
+
language: "typescript",
|
|
266
|
+
source_module: "infra",
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
141
270
|
// 4. Source: route files (CRUD + capabilities)
|
|
142
271
|
for (const mod of system.modules) {
|
|
143
272
|
if (mod.kind === "api_service" && mod.models.length > 0) {
|
|
@@ -156,7 +285,31 @@ export class FullEmitter {
|
|
|
156
285
|
// 5. Source: main entry point
|
|
157
286
|
files.push({ path: "src/index.ts", content: emitIndex(system), language: "typescript", source_module: "root" });
|
|
158
287
|
|
|
288
|
+
// 5a. Model interfaces, schemas, and validators (one file per model)
|
|
289
|
+
const modelFiles: string[] = [];
|
|
290
|
+
for (const mod of system.modules) {
|
|
291
|
+
for (const model of mod.models) {
|
|
292
|
+
const modelPath = `src/models/${toSnakeCase(model.name)}.ts`;
|
|
293
|
+
files.push({
|
|
294
|
+
path: modelPath,
|
|
295
|
+
content: emitModelFile(model, mod, system),
|
|
296
|
+
language: "typescript",
|
|
297
|
+
source_module: mod.id,
|
|
298
|
+
});
|
|
299
|
+
modelFiles.push(modelPath);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (modelFiles.length > 0) {
|
|
303
|
+
files.push({
|
|
304
|
+
path: "src/models/index.ts",
|
|
305
|
+
content: emitModelsIndex(system),
|
|
306
|
+
language: "typescript",
|
|
307
|
+
source_module: "shared",
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
159
311
|
// 6. SQL migrations — run schema emitter ONCE, then match by model name
|
|
312
|
+
// Includes both api_service entities AND data_store schemas.
|
|
160
313
|
const schemas: string[] = [];
|
|
161
314
|
const allSchemaFiles = this.schemaEmitter.emit(system);
|
|
162
315
|
for (const mod of system.modules) {
|
|
@@ -180,6 +333,12 @@ export class FullEmitter {
|
|
|
180
333
|
// 9. README
|
|
181
334
|
files.push({ path: "README.md", content: this.emitReadme(system), language: "yaml", source_module: "root" });
|
|
182
335
|
|
|
336
|
+
// 9a. OpenAPI schema (spec/09_CODEGEN.md §2 — ApiService secondary target)
|
|
337
|
+
const openApiContent = emitOpenApiSchema(system);
|
|
338
|
+
if (openApiContent) {
|
|
339
|
+
files.push({ path: "openapi.json", content: openApiContent, language: "json", source_module: "root" });
|
|
340
|
+
}
|
|
341
|
+
|
|
183
342
|
// 10. Source map + debug handler
|
|
184
343
|
files.push({ path: `${system.name}.bone.map`, content: emitSourceMapFile(system, `${system.name}.bone`), language: "json", source_module: "root" });
|
|
185
344
|
files.push({ path: "src/debug.ts", content: emitDebugHandler(system), language: "typescript", source_module: "infra" });
|