bonescript-compiler 0.7.0 → 0.9.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.
@@ -0,0 +1,898 @@
1
+ /**
2
+ * BoneScript SQLite Target Emitter
3
+ *
4
+ * Generates a SQLite-flavored project: same shape as the Express target but
5
+ * with a SQLite type map, no triggers, and the better-sqlite3 driver in place
6
+ * of pg.
7
+ *
8
+ * SQLite differences from PostgreSQL that this emitter handles:
9
+ * - No JSONB / no native UUID — TEXT for both
10
+ * - No CREATE OR REPLACE FUNCTION / no triggers needed (we do updated_at in app code)
11
+ * - Parameter style is ? not $1, $2 — but better-sqlite3 also supports named (:name)
12
+ * - No SKIP LOCKED, no transactional outbox locking semantics — outbox uses simpler ordering
13
+ * - No CHECK constraints with subqueries — basic CHECK only
14
+ *
15
+ * Strategy: keep the SQL flat and portable. Use better-sqlite3's prepared
16
+ * statements. Keep the public interface identical to the Postgres target so
17
+ * route handlers and capability bodies don't need to change.
18
+ */
19
+
20
+ import * as IR from "./ir";
21
+ import { EmittedFile } from "./emitter";
22
+
23
+ function toSnakeCase(s: string): string {
24
+ return s.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
25
+ }
26
+
27
+ // ─── SQLite type mapping ──────────────────────────────────────────────────────
28
+
29
+ const SQLITE_TYPE_MAP: Record<string, string> = {
30
+ string: "TEXT",
31
+ uint: "INTEGER",
32
+ int: "INTEGER",
33
+ float: "REAL",
34
+ bool: "INTEGER", // 0 / 1
35
+ timestamp: "TEXT", // ISO 8601 string
36
+ uuid: "TEXT",
37
+ bytes: "BLOB",
38
+ json: "TEXT", // JSON-encoded text
39
+ };
40
+
41
+ export function toSqliteType(irType: string): string {
42
+ if (SQLITE_TYPE_MAP[irType]) return SQLITE_TYPE_MAP[irType];
43
+ if (irType.startsWith("list<") || irType.startsWith("set<") || irType.startsWith("map<")) return "TEXT";
44
+ if (irType.startsWith("optional<")) return toSqliteType(irType.slice(9, -1));
45
+ return "TEXT";
46
+ }
47
+
48
+ // ─── Schema emission ──────────────────────────────────────────────────────────
49
+
50
+ export function emitSqliteSchema(model: IR.IRModel, mod: IR.IRModule, system: IR.IRSystem): EmittedFile {
51
+ const tableName = toSnakeCase(model.name) + "s";
52
+ const lines: string[] = [];
53
+
54
+ lines.push(`-- Generated by BoneScript compiler. DO NOT EDIT.`);
55
+ lines.push(`-- Source: ${system.source_hash}`);
56
+ lines.push(`-- Module: ${mod.name}`);
57
+ lines.push(`-- Engine: sqlite`);
58
+ lines.push(``);
59
+ lines.push(`CREATE TABLE IF NOT EXISTS ${tableName} (`);
60
+
61
+ const fieldLines: string[] = [];
62
+ for (const field of model.fields) {
63
+ let line = ` ${field.name} ${toSqliteType(field.type)}`;
64
+ if (!field.nullable) line += " NOT NULL";
65
+
66
+ // SQLite default value handling. The Postgres-flavored defaults need translation.
67
+ if (field.default_value) {
68
+ if (field.default_value === "gen_random_uuid()" || field.default_value === "uuid()") {
69
+ // SQLite has no built-in UUID generator. We rely on app code to fill `id`
70
+ // before insert. No DEFAULT clause emitted.
71
+ } else if (field.default_value === "now()") {
72
+ line += " DEFAULT (datetime('now'))";
73
+ } else if (field.default_value.startsWith("GENERATED ALWAYS AS")) {
74
+ // Generated columns — supported in SQLite 3.31+
75
+ line += ` ${field.default_value}`;
76
+ } else {
77
+ line += ` DEFAULT ${field.default_value}`;
78
+ }
79
+ } else if (field.name === "created_at" || field.name === "updated_at") {
80
+ line += " DEFAULT (datetime('now'))";
81
+ }
82
+
83
+ if (field.name === model.primary_key) line += " PRIMARY KEY";
84
+ fieldLines.push(line);
85
+ }
86
+
87
+ // Unique constraints (declared inline in CREATE TABLE)
88
+ for (const c of model.constraints) {
89
+ if (c.kind === "unique") {
90
+ fieldLines.push(` CONSTRAINT ${tableName}_${c.target}_unique UNIQUE (${c.target})`);
91
+ }
92
+ }
93
+
94
+ // Foreign keys for belongs_to relations
95
+ for (const rel of mod.relations || []) {
96
+ if (rel.kind === "belongs_to") {
97
+ fieldLines.push(` FOREIGN KEY (${rel.foreign_key}) REFERENCES ${rel.to_table}(id) ON DELETE CASCADE`);
98
+ }
99
+ }
100
+
101
+ lines.push(fieldLines.join(",\n"));
102
+ lines.push(`);`);
103
+ lines.push(``);
104
+
105
+ // Indexes
106
+ for (const idx of model.indexes) {
107
+ const idxName = `idx_${tableName}_${idx.fields.join("_")}`;
108
+ const unique = idx.unique ? "UNIQUE " : "";
109
+ lines.push(`CREATE ${unique}INDEX IF NOT EXISTS ${idxName} ON ${tableName} (${idx.fields.join(", ")});`);
110
+ }
111
+
112
+ // FK indexes for belongs_to relations
113
+ for (const rel of mod.relations || []) {
114
+ if (rel.kind === "belongs_to") {
115
+ lines.push(`CREATE INDEX IF NOT EXISTS idx_${tableName}_${rel.foreign_key} ON ${tableName} (${rel.foreign_key});`);
116
+ }
117
+ }
118
+
119
+ // Junction tables for many_to_many
120
+ for (const rel of mod.relations || []) {
121
+ if (rel.kind === "many_to_many" && rel.junction_table) {
122
+ lines.push(``);
123
+ lines.push(`CREATE TABLE IF NOT EXISTS ${rel.junction_table} (`);
124
+ lines.push(` ${rel.from_table.slice(0, -1)}_id TEXT NOT NULL REFERENCES ${rel.from_table}(id) ON DELETE CASCADE,`);
125
+ lines.push(` ${rel.to_table.slice(0, -1)}_id TEXT NOT NULL REFERENCES ${rel.to_table}(id) ON DELETE CASCADE,`);
126
+ lines.push(` created_at TEXT NOT NULL DEFAULT (datetime('now')),`);
127
+ lines.push(` PRIMARY KEY (${rel.from_table.slice(0, -1)}_id, ${rel.to_table.slice(0, -1)}_id)`);
128
+ lines.push(`);`);
129
+ }
130
+ }
131
+
132
+ // Note: No triggers for updated_at in SQLite. The DB client wrapper handles
133
+ // it on every UPDATE by interpolating the current timestamp.
134
+
135
+ return {
136
+ path: `migrations/${toSnakeCase(model.name)}.sql`,
137
+ content: lines.join("\n") + "\n",
138
+ language: "sql",
139
+ source_module: mod.id,
140
+ };
141
+ }
142
+
143
+ // ─── Outbox / audit infra schemas (SQLite flavor) ─────────────────────────────
144
+
145
+ export function emitSqliteOutboxSchema(): string {
146
+ return `-- BoneScript: Transactional Outbox (SQLite)
147
+ -- Generated by BoneScript compiler. DO NOT EDIT.
148
+
149
+ CREATE TABLE IF NOT EXISTS event_outbox (
150
+ id TEXT PRIMARY KEY,
151
+ event_type TEXT NOT NULL,
152
+ payload TEXT NOT NULL,
153
+ source TEXT NOT NULL,
154
+ correlation_id TEXT,
155
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
156
+ scheduled_at TEXT NOT NULL DEFAULT (datetime('now')),
157
+ delivered_at TEXT,
158
+ attempts INTEGER NOT NULL DEFAULT 0,
159
+ last_error TEXT,
160
+ status TEXT NOT NULL DEFAULT 'pending'
161
+ CHECK (status IN ('pending', 'delivered', 'failed', 'dead_letter'))
162
+ );
163
+
164
+ CREATE INDEX IF NOT EXISTS idx_event_outbox_status ON event_outbox (status, scheduled_at);
165
+ CREATE INDEX IF NOT EXISTS idx_event_outbox_created ON event_outbox (created_at);
166
+
167
+ CREATE TABLE IF NOT EXISTS event_processed (
168
+ event_id TEXT PRIMARY KEY,
169
+ event_type TEXT NOT NULL,
170
+ processed_at TEXT NOT NULL DEFAULT (datetime('now'))
171
+ );
172
+
173
+ CREATE INDEX IF NOT EXISTS idx_event_processed_type ON event_processed (event_type, processed_at);
174
+ `;
175
+ }
176
+
177
+ export function emitSqliteAuditSchema(): string {
178
+ return `-- BoneScript: Audit log (SQLite)
179
+ -- Generated by BoneScript compiler. DO NOT EDIT.
180
+
181
+ CREATE TABLE IF NOT EXISTS audit_log (
182
+ id TEXT PRIMARY KEY,
183
+ actor_id TEXT,
184
+ action TEXT NOT NULL,
185
+ entity_type TEXT,
186
+ entity_id TEXT,
187
+ payload TEXT,
188
+ ip_address TEXT,
189
+ user_agent TEXT,
190
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
191
+ );
192
+
193
+ CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log (actor_id, created_at);
194
+ CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log (entity_type, entity_id);
195
+ CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log (action, created_at);
196
+ `;
197
+ }
198
+
199
+ // ─── DB client (SQLite flavor, drop-in API replacement for the pg client) ─────
200
+
201
+ export function emitSqliteDbClient(system: IR.IRSystem): string {
202
+ return [
203
+ `// Generated by BoneScript compiler. DO NOT EDIT.`,
204
+ `// SQLite database client. API-compatible with the Postgres client so route`,
205
+ `// handlers don't need to change between targets.`,
206
+ `import Database from "better-sqlite3";`,
207
+ `import * as path from "path";`,
208
+ ``,
209
+ `let _db: Database.Database | null = null;`,
210
+ ``,
211
+ `function getDb(): Database.Database {`,
212
+ ` if (!_db) {`,
213
+ ` const dbPath = process.env.SQLITE_PATH || path.resolve(process.cwd(), "${toSnakeCase(system.name)}.db");`,
214
+ ` _db = new Database(dbPath);`,
215
+ ` _db.pragma("journal_mode = WAL");`,
216
+ ` _db.pragma("foreign_keys = ON");`,
217
+ ` _db.pragma("synchronous = NORMAL");`,
218
+ ` }`,
219
+ ` return _db;`,
220
+ `}`,
221
+ ``,
222
+ `// Translate Postgres-style SQL to SQLite-compatible SQL.`,
223
+ `//`,
224
+ `// Handles:`,
225
+ `// - $1, $2, ... → ? (and rebuilds params for out-of-order references)`,
226
+ `// - NOW() → datetime('now')`,
227
+ `// - row_to_json(t.*) → json_object(...) — best-effort, see comment below`,
228
+ `//`,
229
+ `// We can't translate every Postgres-ism. Capability effects that use`,
230
+ `// jsonb_set / jsonb_agg / jsonb_array_elements still fail at runtime; if`,
231
+ `// you hit those, refactor the capability or use the Express target.`,
232
+ `function translateSql(sql: string, params: any[]): { sql: string; params: any[] } {`,
233
+ ` let out = sql.replace(/\\bNOW\\s*\\(\\s*\\)/gi, "datetime('now')");`,
234
+ ``,
235
+ ` // row_to_json(t.*) → json_object('col', t.col, ...) is hard to do without`,
236
+ ` // knowing the columns. As a degraded fallback, replace it with the table`,
237
+ ` // alias so the LIST join still returns *something* (the joined table's id).`,
238
+ ` // The "as <name>" still works; consumers will see a string instead of an`,
239
+ ` // object. This is a known limitation.`,
240
+ ` out = out.replace(/row_to_json\\((\\w+)\\.\\*\\)/gi, "$1.id");`,
241
+ ``,
242
+ ` // Postgres-style placeholders → SQLite ?, with param rewrite for`,
243
+ ` // out-of-order references (e.g. "WHERE id = $1 AND ... = $2" vs`,
244
+ ` // "SET col = $2 WHERE id = $1").`,
245
+ ` const newParams: any[] = [];`,
246
+ ` out = out.replace(/\\$(\\d+)/g, (_m, n) => {`,
247
+ ` newParams.push(params[parseInt(n, 10) - 1]);`,
248
+ ` return "?";`,
249
+ ` });`,
250
+ ` return { sql: out, params: newParams };`,
251
+ `}`,
252
+ ``,
253
+ `// Strip Postgres-only RETURNING * — better-sqlite3 returns the inserted/updated`,
254
+ `// row separately via .get(). We post-process by looking up the row by id.`,
255
+ `function stripReturning(sql: string): { sql: string; hadReturning: boolean } {`,
256
+ ` const m = sql.match(/^(.*?)\\s+RETURNING\\s+\\*\\s*;?\\s*$/is);`,
257
+ ` if (m) return { sql: m[1], hadReturning: true };`,
258
+ ` return { sql, hadReturning: false };`,
259
+ `}`,
260
+ ``,
261
+ `// Extract table name from an INSERT or UPDATE for RETURNING * emulation.`,
262
+ `function extractTable(sql: string): string | null {`,
263
+ ` const ins = sql.match(/INSERT\\s+INTO\\s+([\\w]+)/i);`,
264
+ ` if (ins) return ins[1];`,
265
+ ` const upd = sql.match(/UPDATE\\s+([\\w]+)/i);`,
266
+ ` if (upd) return upd[1];`,
267
+ ` return null;`,
268
+ `}`,
269
+ ``,
270
+ // Parse the WHERE clause to find the param index that holds the id. The
271
+ // generated SQL uses two shapes:
272
+ // - emit_runtime PUT: "UPDATE t SET ... WHERE id = $1" → idIdx = 1
273
+ // - emit_capability: "UPDATE t SET col = $1 WHERE id = $2" → idIdx = 2
274
+ // For INSERT the row id is always $1 by convention.
275
+ `function findIdParamIndex(sql: string, params: any[]): any {`,
276
+ ` // Look for "WHERE id = $N" or "WHERE id = ?" (after translation).`,
277
+ ` const m = sql.match(/WHERE\\s+id\\s*=\\s*\\$(\\d+)/i);`,
278
+ ` if (m) {`,
279
+ ` const idx = parseInt(m[1], 10) - 1;`,
280
+ ` return params[idx];`,
281
+ ` }`,
282
+ ` // Translated form: count which "?" corresponds to the WHERE id.`,
283
+ ` const beforeWhere = sql.split(/WHERE\\s+id\\s*=\\s*\\?/i)[0] || "";`,
284
+ ` const placeholdersBeforeId = (beforeWhere.match(/\\?/g) || []).length;`,
285
+ ` return params[placeholdersBeforeId];`,
286
+ `}`,
287
+ ``,
288
+ `export async function query<T = any>(text: string, params: any[] = []): Promise<T[]> {`,
289
+ ` const db = getDb();`,
290
+ ` const { sql: stripped, hadReturning } = stripReturning(text);`,
291
+ ` const { sql, params: translatedParams } = translateSql(stripped, params);`,
292
+ ` const trimmed = sql.trim().toUpperCase();`,
293
+ ` if (trimmed.startsWith("SELECT") || trimmed.startsWith("WITH")) {`,
294
+ ` return db.prepare(sql).all(...translatedParams) as T[];`,
295
+ ` }`,
296
+ ` // INSERT / UPDATE / DELETE`,
297
+ ` const stmt = db.prepare(sql);`,
298
+ ` const info = stmt.run(...translatedParams);`,
299
+ ` if (hadReturning) {`,
300
+ ` const table = extractTable(sql);`,
301
+ ` if (!table) return [];`,
302
+ ` if (trimmed.startsWith("INSERT")) {`,
303
+ ` // Generated INSERTs have id as the first column → params[0].`,
304
+ ` const id = params[0];`,
305
+ ` const row = db.prepare(\`SELECT * FROM \${table} WHERE id = ?\`).get(id);`,
306
+ ` return row ? [row as T] : [];`,
307
+ ` }`,
308
+ ` if (trimmed.startsWith("UPDATE")) {`,
309
+ ` // Find the id from the WHERE clause regardless of param order.`,
310
+ ` const id = findIdParamIndex(stripped, params);`,
311
+ ` if (id === undefined) return [];`,
312
+ ` const row = db.prepare(\`SELECT * FROM \${table} WHERE id = ?\`).get(id);`,
313
+ ` return row ? [row as T] : [];`,
314
+ ` }`,
315
+ ` }`,
316
+ ` // Fall back to changes count expressed as a single-row response`,
317
+ ` return [{ rowCount: info.changes } as unknown as T];`,
318
+ `}`,
319
+ ``,
320
+ `export async function queryOne<T = any>(text: string, params: any[] = []): Promise<T | null> {`,
321
+ ` const rows = await query<T>(text, params);`,
322
+ ` return rows[0] || null;`,
323
+ `}`,
324
+ ``,
325
+ `export async function execute(text: string, params: any[] = []): Promise<number> {`,
326
+ ` const db = getDb();`,
327
+ ` const { sql: stripped } = stripReturning(text);`,
328
+ ` const { sql, params: translatedParams } = translateSql(stripped, params);`,
329
+ ` const info = db.prepare(sql).run(...translatedParams);`,
330
+ ` return info.changes;`,
331
+ `}`,
332
+ ``,
333
+ `// Run a function inside a SQLite transaction.`,
334
+ `//`,
335
+ `// IMPORTANT: better-sqlite3 transactions are synchronous. The callback runs`,
336
+ `// to completion before COMMIT — but if you do "await" inside it for a Promise`,
337
+ `// other than another query()/execute(), the transaction will commit before`,
338
+ `// the awaited work finishes.`,
339
+ `//`,
340
+ `// In practice this is fine: query()/execute() above are async only at the`,
341
+ `// type level; their bodies are synchronous because better-sqlite3 is.`,
342
+ `// The transaction will be safely committed once the callback returns, and`,
343
+ `// any chained query/execute calls inside it run on the same database in the`,
344
+ `// same transaction.`,
345
+ `//`,
346
+ `// Don't put fetch(), setTimeout, or other genuinely-async work inside fn.`,
347
+ `export async function transaction<T>(fn: (client: { query: typeof query; execute: typeof execute }) => Promise<T> | T): Promise<T> {`,
348
+ ` const db = getDb();`,
349
+ ` let result!: T;`,
350
+ ` let promise: Promise<T> | null = null;`,
351
+ ` const txn = db.transaction(() => {`,
352
+ ` const r = fn({ query, execute });`,
353
+ ` if (r instanceof Promise) {`,
354
+ ` // Capture the promise; await outside the synchronous transaction.`,
355
+ ` // Note: this means async work inside fn runs OUTSIDE the transaction,`,
356
+ ` // which may be a bug in caller code. Better to keep fn synchronous.`,
357
+ ` promise = r;`,
358
+ ` return;`,
359
+ ` }`,
360
+ ` result = r as T;`,
361
+ ` });`,
362
+ ` txn();`,
363
+ ` if (promise) return await promise;`,
364
+ ` return result;`,
365
+ `}`,
366
+ ``,
367
+ `// Compatibility shim: the Postgres path uses pool.connect()/release() for nested`,
368
+ `// transactions. SQLite has no pool. We expose a no-op client wrapper so generated`,
369
+ `// route handlers can call __client.query() / __client.release() unchanged.`,
370
+ `export const pool = {`,
371
+ ` async connect() {`,
372
+ ` const db = getDb();`,
373
+ ` let inTxn = false;`,
374
+ ` return {`,
375
+ ` async query(text: string, params: any[] = []) {`,
376
+ ` const upper = text.trim().toUpperCase();`,
377
+ ` if (upper === "BEGIN") { db.prepare("BEGIN").run(); inTxn = true; return { rows: [] }; }`,
378
+ ` if (upper === "COMMIT") { db.prepare("COMMIT").run(); inTxn = false; return { rows: [] }; }`,
379
+ ` if (upper === "ROLLBACK") { try { db.prepare("ROLLBACK").run(); } catch {} inTxn = false; return { rows: [] }; }`,
380
+ ` const rows = await query(text, params);`,
381
+ ` return { rows, rowCount: rows.length };`,
382
+ ` },`,
383
+ ` release() { /* no-op for sqlite */ },`,
384
+ ` };`,
385
+ ` },`,
386
+ ` async query(text: string, params: any[] = []) {`,
387
+ ` const rows = await query(text, params);`,
388
+ ` return { rows, rowCount: rows.length };`,
389
+ ` },`,
390
+ ` async end() { if (_db) { _db.close(); _db = null; } },`,
391
+ ` on() { /* no-op */ },`,
392
+ `};`,
393
+ ``,
394
+ ].join("\n");
395
+ }
396
+
397
+ // ─── Migration runner (SQLite flavor) ─────────────────────────────────────────
398
+
399
+ export function emitSqliteMigration(_system: IR.IRSystem, schemas: string[]): string {
400
+ const system = _system; // alias for use below — kept underscore for backwards compat
401
+ // Build deterministic blocks just like the Postgres path does.
402
+ const lines: string[] = [];
403
+ lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
404
+ lines.push(`// SQLite migration runner with a schema_migrations ledger.`);
405
+ lines.push(`require("dotenv").config();`);
406
+ lines.push(`import * as fs from "fs";`);
407
+ lines.push(`import * as path from "path";`);
408
+ lines.push(`import { createHash } from "crypto";`);
409
+ lines.push(`import Database from "better-sqlite3";`);
410
+ lines.push(``);
411
+ lines.push(`interface Block { id: string; checksum: string; sql: string; }`);
412
+ lines.push(``);
413
+ lines.push(`const GENERATED_BLOCKS: Block[] = [`);
414
+
415
+ // Use the same deterministic ordering as the Postgres migrate emitter
416
+ schemas.forEach((sql, i) => {
417
+ const checksum = require("crypto").createHash("sha256").update(sql).digest("hex");
418
+ const tableMatch = sql.match(/CREATE TABLE IF NOT EXISTS\s+(\w+)/i);
419
+ const slug = (tableMatch?.[1] || `block_${i}`).toLowerCase();
420
+ const id = `${String(i).padStart(4, "0")}_${slug}`;
421
+ const escaped = sql.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$/g, "\\$");
422
+ lines.push(` { id: ${JSON.stringify(id)}, checksum: ${JSON.stringify(checksum)}, sql: \`${escaped}\` },`);
423
+ });
424
+
425
+ lines.push(`];`);
426
+ lines.push(``);
427
+ lines.push(`function loadManualBlocks(): Block[] {`);
428
+ lines.push(` const dir = path.resolve(__dirname, "..", "migrations", "_manual");`);
429
+ lines.push(` if (!fs.existsSync(dir)) return [];`);
430
+ lines.push(` return fs.readdirSync(dir)`);
431
+ lines.push(` .filter(f => f.endsWith(".sql"))`);
432
+ lines.push(` .sort()`);
433
+ lines.push(` .map(f => {`);
434
+ lines.push(` const sql = fs.readFileSync(path.join(dir, f), "utf-8");`);
435
+ lines.push(` return { id: "manual_" + f.replace(/\\.sql$/, ""), checksum: createHash("sha256").update(sql).digest("hex"), sql };`);
436
+ lines.push(` });`);
437
+ lines.push(`}`);
438
+ lines.push(``);
439
+ lines.push(`async function migrate() {`);
440
+ lines.push(` const dbPath = process.env.SQLITE_PATH || path.resolve(process.cwd(), "${toSnakeCase(_system.name)}.db");`);
441
+ lines.push(` const db = new Database(dbPath);`);
442
+ lines.push(` db.pragma("foreign_keys = ON");`);
443
+ lines.push(``);
444
+ lines.push(` db.exec(\`CREATE TABLE IF NOT EXISTS schema_migrations (`);
445
+ lines.push(` id TEXT PRIMARY KEY,`);
446
+ lines.push(` checksum TEXT NOT NULL,`);
447
+ lines.push(` applied_at TEXT NOT NULL DEFAULT (datetime('now')),`);
448
+ lines.push(` duration_ms INTEGER NOT NULL DEFAULT 0`);
449
+ lines.push(` );\`);`);
450
+ lines.push(``);
451
+ lines.push(` const all = [...GENERATED_BLOCKS, ...loadManualBlocks()];`);
452
+ lines.push(` const existing = db.prepare("SELECT id, checksum FROM schema_migrations").all() as Array<{ id: string; checksum: string }>;`);
453
+ lines.push(` const seen = new Map(existing.map(r => [r.id, r.checksum]));`);
454
+ lines.push(``);
455
+ lines.push(` let applied = 0, skipped = 0;`);
456
+ lines.push(` for (const block of all) {`);
457
+ lines.push(` const prior = seen.get(block.id);`);
458
+ lines.push(` if (prior === block.checksum) { skipped++; continue; }`);
459
+ lines.push(` if (prior && prior !== block.checksum) {`);
460
+ lines.push(` throw new Error(\`Migration \${block.id} was previously applied with a different checksum.\`);`);
461
+ lines.push(` }`);
462
+ lines.push(` const start = Date.now();`);
463
+ lines.push(` db.exec("BEGIN");`);
464
+ lines.push(` try {`);
465
+ lines.push(` db.exec(block.sql);`);
466
+ lines.push(` db.prepare("INSERT INTO schema_migrations (id, checksum, duration_ms) VALUES (?, ?, ?)")`);
467
+ lines.push(` .run(block.id, block.checksum, Date.now() - start);`);
468
+ lines.push(` db.exec("COMMIT");`);
469
+ lines.push(` console.log(\` applied \${block.id} (\${Date.now() - start}ms)\`);`);
470
+ lines.push(` applied++;`);
471
+ lines.push(` } catch (e) {`);
472
+ lines.push(` try { db.exec("ROLLBACK"); } catch {}`);
473
+ lines.push(` throw e;`);
474
+ lines.push(` }`);
475
+ lines.push(` }`);
476
+ lines.push(` console.log(\`Migrations complete: \${applied} applied, \${skipped} already up to date.\`);`);
477
+ lines.push(` db.close();`);
478
+ lines.push(`}`);
479
+ lines.push(``);
480
+ lines.push(`migrate().catch(e => { console.error(e); process.exit(1); });`);
481
+
482
+ return lines.join("\n");
483
+ }
484
+
485
+ // ─── Package.json (SQLite flavor) ─────────────────────────────────────────────
486
+
487
+ export function emitSqlitePackageJson(system: IR.IRSystem): string {
488
+ // Full backend deps, but with better-sqlite3 instead of pg.
489
+ const pkg = {
490
+ name: toSnakeCase(system.name),
491
+ version: system.version,
492
+ private: true,
493
+ scripts: {
494
+ build: "tsc",
495
+ start: "node dist/index.js",
496
+ dev: "ts-node src/index.ts",
497
+ migrate: "ts-node src/migrate.ts",
498
+ seed: "ts-node src/seed.ts",
499
+ },
500
+ dependencies: {
501
+ // Pinned versions match the Express target except pg → better-sqlite3.
502
+ express: "4.22.2",
503
+ "better-sqlite3": "11.5.0",
504
+ ws: "8.18.0",
505
+ uuid: "10.0.0",
506
+ cors: "2.8.5",
507
+ helmet: "8.0.0",
508
+ "express-rate-limit": "7.5.0",
509
+ jsonwebtoken: "9.0.2",
510
+ dotenv: "16.4.7",
511
+ "node-cron": "3.0.3",
512
+ zod: "3.23.8",
513
+ },
514
+ devDependencies: {
515
+ "@types/express": "4.17.21",
516
+ "@types/node": "20.14.0",
517
+ "@types/better-sqlite3": "7.6.11",
518
+ "@types/ws": "8.5.13",
519
+ "@types/cors": "2.8.17",
520
+ "@types/jsonwebtoken": "9.0.7",
521
+ "@types/uuid": "10.0.0",
522
+ "@types/node-cron": "3.0.11",
523
+ typescript: "5.6.3",
524
+ "ts-node": "10.9.2",
525
+ },
526
+ };
527
+ return JSON.stringify(pkg, null, 2);
528
+ }
529
+
530
+ // ─── Top-level emitter ────────────────────────────────────────────────────────
531
+
532
+ import { FullEmitter, FullEmitterOptions } from "./emit_full";
533
+
534
+ export class SqliteEmitter {
535
+ /**
536
+ * Emit a complete SQLite-backed Express project.
537
+ *
538
+ * Strategy: delegate the bulk of generation to FullEmitter (routes, auth,
539
+ * websocket, etc), then swap out the Postgres-specific pieces for SQLite
540
+ * equivalents. The generated routes use a Postgres-shaped SQL surface that
541
+ * our SQLite db.ts client translates at runtime (NOW() → datetime('now'),
542
+ * $N → ?, RETURNING * → re-select by id).
543
+ *
544
+ * What's swapped:
545
+ * - package.json SQLite deps (better-sqlite3) instead of pg
546
+ * - .env.example SQLite-shaped env vars
547
+ * - src/db.ts better-sqlite3 client with SQL translation
548
+ * - src/migrate.ts SQLite migration runner
549
+ * - migrations/*.sql SQLite-flavored DDL (TEXT, no JSONB, etc.)
550
+ * - README.md SQLite-aware quick start
551
+ *
552
+ * What's removed:
553
+ * - docker-compose.yaml, Dockerfile, k8s/deployment.yaml — SQLite is
554
+ * single-file; no service orchestration needed
555
+ *
556
+ * Known limitations:
557
+ * - Capability bodies that use jsonb_set / jsonb_agg / jsonb_array_elements
558
+ * fail at runtime. Refactor those capabilities or use the Express target.
559
+ * - Durable event delivery (EVENT_MODE=durable) requires Postgres-only
560
+ * features (FOR UPDATE SKIP LOCKED). Default is in_process which works
561
+ * fine on SQLite.
562
+ * - row_to_json(t.*) in LIST joins degrades to t.id (no full join object).
563
+ */
564
+ emit(system: IR.IRSystem, options: FullEmitterOptions = {}): EmittedFile[] {
565
+ // 1. Generate the full Express project
566
+ const fullFiles = new FullEmitter().emit(system, options);
567
+
568
+ // 2. Files we replace with SQLite equivalents (keyed by path)
569
+ const replacements = this.buildReplacements(system);
570
+
571
+ // 3. Files we drop entirely (Postgres-specific infra)
572
+ const drops = new Set<string>([
573
+ "docker-compose.yaml",
574
+ "Dockerfile",
575
+ ".dockerignore",
576
+ "k8s/deployment.yaml",
577
+ "src/migration_diff.ts", // Postgres-specific schema diff
578
+ ]);
579
+
580
+ const out: EmittedFile[] = [];
581
+ for (const f of fullFiles) {
582
+ if (drops.has(f.path)) continue;
583
+
584
+ // Migrations need wholesale replacement: regenerate per-entity SQLite SQL.
585
+ if (f.path.startsWith("migrations/") && f.path.endsWith(".sql") &&
586
+ f.path !== "migrations/event_outbox.sql" &&
587
+ f.path !== "migrations/audit_log.sql") {
588
+ // We re-emit these from scratch below.
589
+ continue;
590
+ }
591
+
592
+ const replacement = replacements.get(f.path);
593
+ if (replacement) {
594
+ out.push(replacement);
595
+ replacements.delete(f.path);
596
+ continue;
597
+ }
598
+ out.push(f);
599
+ }
600
+
601
+ // 4. Append per-entity SQLite migrations
602
+ const seenPaths = new Set<string>();
603
+ for (const mod of system.modules) {
604
+ if (mod.kind === "data_store" || mod.kind === "api_service") {
605
+ for (const model of mod.models) {
606
+ const file = emitSqliteSchema(model, mod, system);
607
+ if (seenPaths.has(file.path)) continue;
608
+ seenPaths.add(file.path);
609
+ out.push(file);
610
+ }
611
+ }
612
+ }
613
+
614
+ // 5. Add the SQLite migration runner (rebuilt with all schemas in scope)
615
+ const allSchemas: string[] = out
616
+ .filter(f => f.path.startsWith("migrations/") && f.path.endsWith(".sql"))
617
+ .map(f => f.content);
618
+ out.push({
619
+ path: "src/migrate.ts",
620
+ content: emitSqliteMigration(system, allSchemas),
621
+ language: "typescript",
622
+ source_module: "infra",
623
+ });
624
+
625
+ // 6. Add any replacements that weren't already handled (i.e. files unique to SQLite)
626
+ for (const r of replacements.values()) {
627
+ out.push(r);
628
+ }
629
+
630
+ return out;
631
+ }
632
+
633
+ private buildReplacements(system: IR.IRSystem): Map<string, EmittedFile> {
634
+ const map = new Map<string, EmittedFile>();
635
+
636
+ map.set("package.json", {
637
+ path: "package.json",
638
+ content: emitSqlitePackageJson(system),
639
+ language: "json",
640
+ source_module: "root",
641
+ });
642
+
643
+ map.set(".env.example", {
644
+ path: ".env.example",
645
+ content: this.emitEnvExample(system),
646
+ language: "yaml",
647
+ source_module: "root",
648
+ });
649
+
650
+ map.set("src/db.ts", {
651
+ path: "src/db.ts",
652
+ content: emitSqliteDbClient(system),
653
+ language: "typescript",
654
+ source_module: "infra",
655
+ });
656
+
657
+ map.set("src/events.ts", {
658
+ path: "src/events.ts",
659
+ content: this.emitInProcessEventBus(system),
660
+ language: "typescript",
661
+ source_module: "infra",
662
+ });
663
+
664
+ map.set("migrations/event_outbox.sql", {
665
+ path: "migrations/event_outbox.sql",
666
+ content: emitSqliteOutboxSchema(),
667
+ language: "sql",
668
+ source_module: "infra",
669
+ });
670
+
671
+ map.set("migrations/audit_log.sql", {
672
+ path: "migrations/audit_log.sql",
673
+ content: emitSqliteAuditSchema(),
674
+ language: "sql",
675
+ source_module: "infra",
676
+ });
677
+
678
+ map.set("README.md", {
679
+ path: "README.md",
680
+ content: this.emitReadme(system),
681
+ language: "yaml",
682
+ source_module: "root",
683
+ });
684
+
685
+ return map;
686
+ }
687
+
688
+ /**
689
+ * In-process-only event bus for SQLite.
690
+ *
691
+ * The default Express target's events.ts has a durable mode backed by a
692
+ * Postgres outbox with FOR UPDATE SKIP LOCKED. SQLite has no equivalent,
693
+ * so we ship a simpler bus that only does in-memory delivery. Callers
694
+ * can still use the same eventBus.publish() / .subscribe() API; only
695
+ * `EVENT_MODE=durable` is unsupported.
696
+ */
697
+ private emitInProcessEventBus(system: IR.IRSystem): string {
698
+ const lines: string[] = [];
699
+ lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
700
+ lines.push(`// In-process event bus (SQLite target — durable mode is Postgres-only).`);
701
+ lines.push(``);
702
+ lines.push(`import { v4 as uuid } from "uuid";`);
703
+ lines.push(`import { logger } from "./logger";`);
704
+ lines.push(`import { counter } from "./metrics";`);
705
+ lines.push(``);
706
+ lines.push(`export interface EventMetadata {`);
707
+ lines.push(` source: string;`);
708
+ lines.push(` timestamp: Date;`);
709
+ lines.push(` correlation_id: string;`);
710
+ lines.push(` causation_id: string;`);
711
+ lines.push(`}`);
712
+ lines.push(``);
713
+ lines.push(`export interface SystemEvent {`);
714
+ lines.push(` type: string;`);
715
+ lines.push(` payload: Record<string, unknown>;`);
716
+ lines.push(` metadata: EventMetadata;`);
717
+ lines.push(`}`);
718
+ lines.push(``);
719
+ lines.push(`type Handler = (event: SystemEvent) => Promise<void>;`);
720
+ lines.push(``);
721
+ lines.push(`class InProcessBus {`);
722
+ lines.push(` private handlers: Map<string, Handler[]> = new Map();`);
723
+ lines.push(``);
724
+ lines.push(` subscribe(type: string, handler: Handler): void {`);
725
+ lines.push(` const existing = this.handlers.get(type) || [];`);
726
+ lines.push(` existing.push(handler);`);
727
+ lines.push(` this.handlers.set(type, existing);`);
728
+ lines.push(` }`);
729
+ lines.push(``);
730
+ lines.push(` async publish(`);
731
+ lines.push(` type: string,`);
732
+ lines.push(` payload: Record<string, unknown>,`);
733
+ lines.push(` source: string,`);
734
+ lines.push(` correlationId?: string,`);
735
+ lines.push(` _client?: unknown,`);
736
+ lines.push(` ): Promise<void> {`);
737
+ lines.push(` const event: SystemEvent = {`);
738
+ lines.push(` type,`);
739
+ lines.push(` payload,`);
740
+ lines.push(` metadata: {`);
741
+ lines.push(` source,`);
742
+ lines.push(` timestamp: new Date(),`);
743
+ lines.push(` correlation_id: correlationId || uuid(),`);
744
+ lines.push(` causation_id: uuid(),`);
745
+ lines.push(` },`);
746
+ lines.push(` };`);
747
+ lines.push(` counter("event.published", { type, mode: "in_process" });`);
748
+ lines.push(` const handlers = this.handlers.get(type) || [];`);
749
+ lines.push(` for (const handler of handlers) {`);
750
+ lines.push(` try {`);
751
+ lines.push(` await handler(event);`);
752
+ lines.push(` counter("event.delivered", { type, mode: "in_process" });`);
753
+ lines.push(` } catch (e: any) {`);
754
+ lines.push(` counter("event.delivery_failed", { type, mode: "in_process" });`);
755
+ lines.push(` logger.error("event_handler_failed", { event: type, metadata: { error: e.message } });`);
756
+ lines.push(` }`);
757
+ lines.push(` }`);
758
+ lines.push(` }`);
759
+ lines.push(``);
760
+ lines.push(` startWorker(_intervalMs?: number): null {`);
761
+ lines.push(` // No-op for in-process mode.`);
762
+ lines.push(` return null;`);
763
+ lines.push(` }`);
764
+ lines.push(`}`);
765
+ lines.push(``);
766
+ lines.push(`if (process.env.EVENT_MODE === "durable") {`);
767
+ lines.push(` console.warn("[events] EVENT_MODE=durable is not supported on the SQLite target. Falling back to in_process.");`);
768
+ lines.push(`}`);
769
+ lines.push(``);
770
+ lines.push(`export const eventBus = new InProcessBus();`);
771
+ lines.push(``);
772
+ return lines.join("\n");
773
+ }
774
+
775
+ private emitTsConfig(): string {
776
+ const cfg = {
777
+ compilerOptions: {
778
+ target: "ES2020",
779
+ module: "commonjs",
780
+ lib: ["ES2020", "DOM"],
781
+ outDir: "./dist",
782
+ rootDir: "./src",
783
+ strict: true,
784
+ esModuleInterop: true,
785
+ skipLibCheck: true,
786
+ forceConsistentCasingInFileNames: true,
787
+ declaration: true,
788
+ sourceMap: true,
789
+ },
790
+ include: ["src/**/*"],
791
+ exclude: ["node_modules", "dist"],
792
+ };
793
+ return JSON.stringify(cfg, null, 2);
794
+ }
795
+
796
+ private emitEnvExample(system: IR.IRSystem): string {
797
+ return `# ${system.name} (SQLite target)
798
+ # Copy to .env and customize.
799
+
800
+ # --- Database ---
801
+ # Path to the SQLite database file. Defaults to ./<system_name>.db
802
+ SQLITE_PATH=./${toSnakeCase(system.name)}.db
803
+
804
+ # --- Required in production ---
805
+ # Generate with: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
806
+ JWT_SECRET=
807
+
808
+ # --- Server ---
809
+ PORT=3000
810
+ NODE_ENV=development
811
+
812
+ # --- CORS ---
813
+ # Comma-separated list of allowed origins. Leave empty to disallow cross-origin requests.
814
+ ALLOWED_ORIGINS=
815
+
816
+ # --- Event delivery ---
817
+ # in_process: in-memory bus, no durability — recommended for SQLite.
818
+ # durable mode (Postgres outbox + FOR UPDATE SKIP LOCKED) is not supported.
819
+ EVENT_MODE=in_process
820
+
821
+ # --- Request timeout ---
822
+ REQUEST_TIMEOUT_MS=30000
823
+
824
+ # --- Notifications ---
825
+ # NOTIFY_PROVIDER=log|resend|sendgrid|webhook (default: log)
826
+ NOTIFY_PROVIDER=log
827
+ NOTIFY_API_KEY=
828
+ NOTIFY_FROM_EMAIL=noreply@example.com
829
+
830
+ # --- Webhook delivery ---
831
+ NOTIFY_WEBHOOK_URL=
832
+ NOTIFY_WEBHOOK_SECRET=
833
+ `;
834
+ }
835
+
836
+ private emitReadme(system: IR.IRSystem): string {
837
+ return `# ${system.name} (SQLite target)
838
+
839
+ Generated by BoneScript compiler. Source hash: ${system.source_hash}
840
+
841
+ ## Quick Start
842
+
843
+ \`\`\`bash
844
+ npm install
845
+ npm run migrate
846
+ npm run dev
847
+ \`\`\`
848
+
849
+ The database file will be created at \`./${toSnakeCase(system.name)}.db\` by
850
+ default. Override with the \`SQLITE_PATH\` environment variable.
851
+
852
+ The server starts at http://localhost:3000 with all routes wired:
853
+
854
+ - CRUD endpoints for every entity
855
+ - Capability endpoints (\`POST /<entity>s/<capability-name>\`)
856
+ - Health checks at \`/health/live\`, \`/health/ready\`, \`/health/metrics\`
857
+ - Admin panel at \`/admin\`
858
+
859
+ ## Why SQLite
860
+
861
+ Self-contained, single-file database. No external services to start, no
862
+ Docker, no Postgres. The whole database is one file you can copy, version,
863
+ or back up.
864
+
865
+ Ideal for: local development, demos, integration tests, single-node
866
+ deployments. For production multi-writer workloads, use the default Express
867
+ target (Postgres-backed).
868
+
869
+ ## Auth
870
+
871
+ Send a Bearer token in the Authorization header:
872
+
873
+ \`\`\`
874
+ Authorization: Bearer <jwt-token>
875
+ \`\`\`
876
+
877
+ Generate a token by signing with the same \`JWT_SECRET\` from your \`.env\`.
878
+
879
+ ## Differences from the Postgres target
880
+
881
+ - Schema uses \`TEXT\`/\`INTEGER\` instead of \`VARCHAR\`/\`BIGINT\`/\`JSONB\`
882
+ - The DB client (\`src/db.ts\`) translates Postgres-flavored SQL at runtime:
883
+ - \`$N\` placeholders → \`?\` (with param array reordering)
884
+ - \`NOW()\` → \`datetime('now')\`
885
+ - \`RETURNING *\` → re-select by id
886
+ - \`row_to_json(t.*)\` in LIST joins degrades to \`t.id\` only
887
+ - Durable event mode (\`EVENT_MODE=durable\`) is not supported on SQLite —
888
+ it requires Postgres-only \`FOR UPDATE SKIP LOCKED\`. Use \`in_process\`.
889
+ - Capability bodies that reference \`jsonb_set\`, \`jsonb_agg\`, or
890
+ \`jsonb_array_elements\` will fail at runtime. Refactor those capabilities
891
+ or compile to the Express target.
892
+
893
+ ## Environment
894
+
895
+ Copy \`.env.example\` to \`.env\` and configure.
896
+ `;
897
+ }
898
+ }