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.
- package/dist/cli.js +114 -8
- package/dist/cli.js.map +1 -1
- package/dist/emit_full.js +11 -1
- package/dist/emit_full.js.map +1 -1
- package/dist/emit_graphql.js +19 -7
- package/dist/emit_graphql.js.map +1 -1
- package/dist/emit_notify.js +84 -1
- package/dist/emit_notify.js.map +1 -1
- package/dist/emit_prisma.js +10 -2
- package/dist/emit_prisma.js.map +1 -1
- package/dist/emit_react.d.ts +24 -0
- package/dist/emit_react.js +222 -0
- package/dist/emit_react.js.map +1 -0
- package/dist/emit_sqlite.d.ts +74 -0
- package/dist/emit_sqlite.js +863 -0
- package/dist/emit_sqlite.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/lowering.js +27 -0
- package/dist/lowering.js.map +1 -1
- package/package.json +14 -4
- package/src/cli.ts +121 -9
- package/src/emit_full.ts +11 -1
- package/src/emit_graphql.ts +18 -7
- package/src/emit_notify.ts +84 -1
- package/src/emit_prisma.ts +10 -2
- package/src/emit_react.ts +236 -0
- package/src/emit_sqlite.ts +898 -0
- package/src/index.ts +2 -0
- package/src/lowering.ts +26 -0
|
@@ -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
|
+
}
|