bonescript-compiler 0.7.0 → 0.8.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 +61 -4
- 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_notify.js +49 -1
- package/dist/emit_notify.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 +33 -0
- package/dist/emit_sqlite.js +539 -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/test_notify.d.ts +11 -0
- package/dist/test_notify.js +220 -0
- package/dist/test_notify.js.map +1 -0
- package/dist/test_react.d.ts +10 -0
- package/dist/test_react.js +177 -0
- package/dist/test_react.js.map +1 -0
- package/dist/test_sqlite.d.ts +13 -0
- package/dist/test_sqlite.js +262 -0
- package/dist/test_sqlite.js.map +1 -0
- package/package.json +7 -4
- package/src/cli.ts +68 -5
- package/src/emit_full.ts +11 -1
- package/src/emit_notify.ts +49 -1
- package/src/emit_react.ts +236 -0
- package/src/emit_sqlite.ts +562 -0
- package/src/index.ts +2 -0
|
@@ -0,0 +1,562 @@
|
|
|
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 $1, $2, ... placeholders to SQLite ? placeholders.`,
|
|
223
|
+
`// The generated route handlers use $N because the Postgres backend is the`,
|
|
224
|
+
`// canonical target; this keeps the surface uniform.`,
|
|
225
|
+
`function translateSql(sql: string): string {`,
|
|
226
|
+
` return sql.replace(/\\$(\\d+)/g, "?");`,
|
|
227
|
+
`}`,
|
|
228
|
+
``,
|
|
229
|
+
`// Strip Postgres-only RETURNING * — better-sqlite3 returns the inserted/updated`,
|
|
230
|
+
`// row separately via .get(). We post-process by looking up the row by id.`,
|
|
231
|
+
`function stripReturning(sql: string): { sql: string; hadReturning: boolean } {`,
|
|
232
|
+
` const m = sql.match(/^(.*?)\\s+RETURNING\\s+\\*\\s*;?\\s*$/is);`,
|
|
233
|
+
` if (m) return { sql: m[1], hadReturning: true };`,
|
|
234
|
+
` return { sql, hadReturning: false };`,
|
|
235
|
+
`}`,
|
|
236
|
+
``,
|
|
237
|
+
`// Extract table name from an INSERT or UPDATE for RETURNING * emulation.`,
|
|
238
|
+
`function extractTable(sql: string): string | null {`,
|
|
239
|
+
` const ins = sql.match(/INSERT\\s+INTO\\s+([\\w]+)/i);`,
|
|
240
|
+
` if (ins) return ins[1];`,
|
|
241
|
+
` const upd = sql.match(/UPDATE\\s+([\\w]+)/i);`,
|
|
242
|
+
` if (upd) return upd[1];`,
|
|
243
|
+
` return null;`,
|
|
244
|
+
`}`,
|
|
245
|
+
``,
|
|
246
|
+
`export async function query<T = any>(text: string, params: any[] = []): Promise<T[]> {`,
|
|
247
|
+
` const db = getDb();`,
|
|
248
|
+
` const { sql: stripped, hadReturning } = stripReturning(text);`,
|
|
249
|
+
` const sql = translateSql(stripped);`,
|
|
250
|
+
` const trimmed = sql.trim().toUpperCase();`,
|
|
251
|
+
` if (trimmed.startsWith("SELECT") || trimmed.startsWith("WITH")) {`,
|
|
252
|
+
` return db.prepare(sql).all(...params) as T[];`,
|
|
253
|
+
` }`,
|
|
254
|
+
` // INSERT / UPDATE / DELETE`,
|
|
255
|
+
` const stmt = db.prepare(sql);`,
|
|
256
|
+
` const info = stmt.run(...params);`,
|
|
257
|
+
` if (hadReturning) {`,
|
|
258
|
+
` const table = extractTable(sql);`,
|
|
259
|
+
` if (!table) return [];`,
|
|
260
|
+
` if (trimmed.startsWith("INSERT")) {`,
|
|
261
|
+
` // Look up by id (which is in params[0] for our generated INSERT shape)`,
|
|
262
|
+
` const id = params[0];`,
|
|
263
|
+
` const row = db.prepare(\`SELECT * FROM \${table} WHERE id = ?\`).get(id);`,
|
|
264
|
+
` return row ? [row as T] : [];`,
|
|
265
|
+
` }`,
|
|
266
|
+
` if (trimmed.startsWith("UPDATE")) {`,
|
|
267
|
+
` // The id parameter for our generated UPDATE shape is params[0] (WHERE id = $1).`,
|
|
268
|
+
` const id = params[0];`,
|
|
269
|
+
` const row = db.prepare(\`SELECT * FROM \${table} WHERE id = ?\`).get(id);`,
|
|
270
|
+
` return row ? [row as T] : [];`,
|
|
271
|
+
` }`,
|
|
272
|
+
` }`,
|
|
273
|
+
` // Fall back to changes count expressed as a single-row response`,
|
|
274
|
+
` return [{ rowCount: info.changes } as unknown as T];`,
|
|
275
|
+
`}`,
|
|
276
|
+
``,
|
|
277
|
+
`export async function queryOne<T = any>(text: string, params: any[] = []): Promise<T | null> {`,
|
|
278
|
+
` const rows = await query<T>(text, params);`,
|
|
279
|
+
` return rows[0] || null;`,
|
|
280
|
+
`}`,
|
|
281
|
+
``,
|
|
282
|
+
`export async function execute(text: string, params: any[] = []): Promise<number> {`,
|
|
283
|
+
` const db = getDb();`,
|
|
284
|
+
` const { sql: stripped } = stripReturning(text);`,
|
|
285
|
+
` const sql = translateSql(stripped);`,
|
|
286
|
+
` const info = db.prepare(sql).run(...params);`,
|
|
287
|
+
` return info.changes;`,
|
|
288
|
+
`}`,
|
|
289
|
+
``,
|
|
290
|
+
`export async function transaction<T>(fn: (client: any) => Promise<T>): Promise<T> {`,
|
|
291
|
+
` const db = getDb();`,
|
|
292
|
+
` // better-sqlite3 transactions are synchronous; we adapt with deasync-style`,
|
|
293
|
+
` // immediate execution. The fn here uses query/execute which read the same db.`,
|
|
294
|
+
` const txn = db.transaction(async () => await fn({ query: query, execute: execute }));`,
|
|
295
|
+
` return await txn();`,
|
|
296
|
+
`}`,
|
|
297
|
+
``,
|
|
298
|
+
`// Compatibility shim: the Postgres path uses pool.connect()/release() for nested`,
|
|
299
|
+
`// transactions. SQLite has no pool. We expose a no-op client wrapper so generated`,
|
|
300
|
+
`// route handlers can call __client.query() / __client.release() unchanged.`,
|
|
301
|
+
`export const pool = {`,
|
|
302
|
+
` async connect() {`,
|
|
303
|
+
` const db = getDb();`,
|
|
304
|
+
` let inTxn = false;`,
|
|
305
|
+
` return {`,
|
|
306
|
+
` async query(text: string, params: any[] = []) {`,
|
|
307
|
+
` const upper = text.trim().toUpperCase();`,
|
|
308
|
+
` if (upper === "BEGIN") { db.prepare("BEGIN").run(); inTxn = true; return { rows: [] }; }`,
|
|
309
|
+
` if (upper === "COMMIT") { db.prepare("COMMIT").run(); inTxn = false; return { rows: [] }; }`,
|
|
310
|
+
` if (upper === "ROLLBACK") { try { db.prepare("ROLLBACK").run(); } catch {} inTxn = false; return { rows: [] }; }`,
|
|
311
|
+
` const rows = await query(text, params);`,
|
|
312
|
+
` return { rows, rowCount: rows.length };`,
|
|
313
|
+
` },`,
|
|
314
|
+
` release() { /* no-op for sqlite */ },`,
|
|
315
|
+
` };`,
|
|
316
|
+
` },`,
|
|
317
|
+
` async query(text: string, params: any[] = []) {`,
|
|
318
|
+
` const rows = await query(text, params);`,
|
|
319
|
+
` return { rows, rowCount: rows.length };`,
|
|
320
|
+
` },`,
|
|
321
|
+
` async end() { if (_db) { _db.close(); _db = null; } },`,
|
|
322
|
+
` on() { /* no-op */ },`,
|
|
323
|
+
`};`,
|
|
324
|
+
``,
|
|
325
|
+
].join("\n");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ─── Migration runner (SQLite flavor) ─────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
export function emitSqliteMigration(_system: IR.IRSystem, schemas: string[]): string {
|
|
331
|
+
// Build deterministic blocks just like the Postgres path does.
|
|
332
|
+
const lines: string[] = [];
|
|
333
|
+
lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
|
|
334
|
+
lines.push(`// SQLite migration runner with a schema_migrations ledger.`);
|
|
335
|
+
lines.push(`require("dotenv").config();`);
|
|
336
|
+
lines.push(`import * as fs from "fs";`);
|
|
337
|
+
lines.push(`import * as path from "path";`);
|
|
338
|
+
lines.push(`import { createHash } from "crypto";`);
|
|
339
|
+
lines.push(`import Database from "better-sqlite3";`);
|
|
340
|
+
lines.push(``);
|
|
341
|
+
lines.push(`interface Block { id: string; checksum: string; sql: string; }`);
|
|
342
|
+
lines.push(``);
|
|
343
|
+
lines.push(`const GENERATED_BLOCKS: Block[] = [`);
|
|
344
|
+
|
|
345
|
+
// Use the same deterministic ordering as the Postgres migrate emitter
|
|
346
|
+
schemas.forEach((sql, i) => {
|
|
347
|
+
const checksum = require("crypto").createHash("sha256").update(sql).digest("hex");
|
|
348
|
+
const tableMatch = sql.match(/CREATE TABLE IF NOT EXISTS\s+(\w+)/i);
|
|
349
|
+
const slug = (tableMatch?.[1] || `block_${i}`).toLowerCase();
|
|
350
|
+
const id = `${String(i).padStart(4, "0")}_${slug}`;
|
|
351
|
+
const escaped = sql.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$/g, "\\$");
|
|
352
|
+
lines.push(` { id: ${JSON.stringify(id)}, checksum: ${JSON.stringify(checksum)}, sql: \`${escaped}\` },`);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
lines.push(`];`);
|
|
356
|
+
lines.push(``);
|
|
357
|
+
lines.push(`function loadManualBlocks(): Block[] {`);
|
|
358
|
+
lines.push(` const dir = path.resolve(__dirname, "..", "migrations", "_manual");`);
|
|
359
|
+
lines.push(` if (!fs.existsSync(dir)) return [];`);
|
|
360
|
+
lines.push(` return fs.readdirSync(dir)`);
|
|
361
|
+
lines.push(` .filter(f => f.endsWith(".sql"))`);
|
|
362
|
+
lines.push(` .sort()`);
|
|
363
|
+
lines.push(` .map(f => {`);
|
|
364
|
+
lines.push(` const sql = fs.readFileSync(path.join(dir, f), "utf-8");`);
|
|
365
|
+
lines.push(` return { id: "manual_" + f.replace(/\\.sql$/, ""), checksum: createHash("sha256").update(sql).digest("hex"), sql };`);
|
|
366
|
+
lines.push(` });`);
|
|
367
|
+
lines.push(`}`);
|
|
368
|
+
lines.push(``);
|
|
369
|
+
lines.push(`async function migrate() {`);
|
|
370
|
+
lines.push(` const dbPath = process.env.SQLITE_PATH || path.resolve(process.cwd(), "app.db");`);
|
|
371
|
+
lines.push(` const db = new Database(dbPath);`);
|
|
372
|
+
lines.push(` db.pragma("foreign_keys = ON");`);
|
|
373
|
+
lines.push(``);
|
|
374
|
+
lines.push(` db.exec(\`CREATE TABLE IF NOT EXISTS schema_migrations (`);
|
|
375
|
+
lines.push(` id TEXT PRIMARY KEY,`);
|
|
376
|
+
lines.push(` checksum TEXT NOT NULL,`);
|
|
377
|
+
lines.push(` applied_at TEXT NOT NULL DEFAULT (datetime('now')),`);
|
|
378
|
+
lines.push(` duration_ms INTEGER NOT NULL DEFAULT 0`);
|
|
379
|
+
lines.push(` );\`);`);
|
|
380
|
+
lines.push(``);
|
|
381
|
+
lines.push(` const all = [...GENERATED_BLOCKS, ...loadManualBlocks()];`);
|
|
382
|
+
lines.push(` const existing = db.prepare("SELECT id, checksum FROM schema_migrations").all() as Array<{ id: string; checksum: string }>;`);
|
|
383
|
+
lines.push(` const seen = new Map(existing.map(r => [r.id, r.checksum]));`);
|
|
384
|
+
lines.push(``);
|
|
385
|
+
lines.push(` let applied = 0, skipped = 0;`);
|
|
386
|
+
lines.push(` for (const block of all) {`);
|
|
387
|
+
lines.push(` const prior = seen.get(block.id);`);
|
|
388
|
+
lines.push(` if (prior === block.checksum) { skipped++; continue; }`);
|
|
389
|
+
lines.push(` if (prior && prior !== block.checksum) {`);
|
|
390
|
+
lines.push(` throw new Error(\`Migration \${block.id} was previously applied with a different checksum.\`);`);
|
|
391
|
+
lines.push(` }`);
|
|
392
|
+
lines.push(` const start = Date.now();`);
|
|
393
|
+
lines.push(` db.exec("BEGIN");`);
|
|
394
|
+
lines.push(` try {`);
|
|
395
|
+
lines.push(` db.exec(block.sql);`);
|
|
396
|
+
lines.push(` db.prepare("INSERT INTO schema_migrations (id, checksum, duration_ms) VALUES (?, ?, ?)")`);
|
|
397
|
+
lines.push(` .run(block.id, block.checksum, Date.now() - start);`);
|
|
398
|
+
lines.push(` db.exec("COMMIT");`);
|
|
399
|
+
lines.push(` console.log(\` applied \${block.id} (\${Date.now() - start}ms)\`);`);
|
|
400
|
+
lines.push(` applied++;`);
|
|
401
|
+
lines.push(` } catch (e) {`);
|
|
402
|
+
lines.push(` try { db.exec("ROLLBACK"); } catch {}`);
|
|
403
|
+
lines.push(` throw e;`);
|
|
404
|
+
lines.push(` }`);
|
|
405
|
+
lines.push(` }`);
|
|
406
|
+
lines.push(` console.log(\`Migrations complete: \${applied} applied, \${skipped} already up to date.\`);`);
|
|
407
|
+
lines.push(` db.close();`);
|
|
408
|
+
lines.push(`}`);
|
|
409
|
+
lines.push(``);
|
|
410
|
+
lines.push(`migrate().catch(e => { console.error(e); process.exit(1); });`);
|
|
411
|
+
|
|
412
|
+
return lines.join("\n");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ─── Package.json (SQLite flavor) ─────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
export function emitSqlitePackageJson(system: IR.IRSystem): string {
|
|
418
|
+
const pkg = {
|
|
419
|
+
name: toSnakeCase(system.name),
|
|
420
|
+
version: system.version,
|
|
421
|
+
private: true,
|
|
422
|
+
scripts: {
|
|
423
|
+
build: "tsc",
|
|
424
|
+
start: "node dist/index.js",
|
|
425
|
+
dev: "ts-node src/index.ts",
|
|
426
|
+
migrate: "ts-node src/migrate.ts",
|
|
427
|
+
},
|
|
428
|
+
dependencies: {
|
|
429
|
+
express: "4.22.2",
|
|
430
|
+
"better-sqlite3": "11.5.0",
|
|
431
|
+
uuid: "10.0.0",
|
|
432
|
+
cors: "2.8.5",
|
|
433
|
+
helmet: "8.0.0",
|
|
434
|
+
"express-rate-limit": "7.5.0",
|
|
435
|
+
jsonwebtoken: "9.0.2",
|
|
436
|
+
dotenv: "16.4.7",
|
|
437
|
+
"node-cron": "3.0.3",
|
|
438
|
+
zod: "3.23.8",
|
|
439
|
+
},
|
|
440
|
+
devDependencies: {
|
|
441
|
+
"@types/express": "4.17.21",
|
|
442
|
+
"@types/node": "20.14.0",
|
|
443
|
+
"@types/better-sqlite3": "7.6.11",
|
|
444
|
+
"@types/cors": "2.8.17",
|
|
445
|
+
"@types/jsonwebtoken": "9.0.7",
|
|
446
|
+
"@types/uuid": "10.0.0",
|
|
447
|
+
"@types/node-cron": "3.0.11",
|
|
448
|
+
typescript: "5.6.3",
|
|
449
|
+
"ts-node": "10.9.2",
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
return JSON.stringify(pkg, null, 2);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ─── Top-level emitter ────────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
export class SqliteEmitter {
|
|
458
|
+
emit(system: IR.IRSystem): EmittedFile[] {
|
|
459
|
+
const files: EmittedFile[] = [];
|
|
460
|
+
|
|
461
|
+
// 1. Package + tsconfig
|
|
462
|
+
files.push({ path: "package.json", content: emitSqlitePackageJson(system), language: "json", source_module: "root" });
|
|
463
|
+
files.push({ path: "tsconfig.json", content: this.emitTsConfig(), language: "json", source_module: "root" });
|
|
464
|
+
files.push({ path: ".env.example", content: this.emitEnvExample(system), language: "yaml", source_module: "root" });
|
|
465
|
+
|
|
466
|
+
// 2. Schema files + collect for migration runner
|
|
467
|
+
const schemas: string[] = [];
|
|
468
|
+
const seenPaths = new Set<string>();
|
|
469
|
+
for (const mod of system.modules) {
|
|
470
|
+
if (mod.kind === "data_store" || mod.kind === "api_service") {
|
|
471
|
+
for (const model of mod.models) {
|
|
472
|
+
const file = emitSqliteSchema(model, mod, system);
|
|
473
|
+
if (seenPaths.has(file.path)) continue;
|
|
474
|
+
seenPaths.add(file.path);
|
|
475
|
+
files.push(file);
|
|
476
|
+
schemas.push(file.content);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// 3. Outbox + audit infra schemas
|
|
482
|
+
files.push({ path: "migrations/event_outbox.sql", content: emitSqliteOutboxSchema(), language: "sql", source_module: "infra" });
|
|
483
|
+
files.push({ path: "migrations/audit_log.sql", content: emitSqliteAuditSchema(), language: "sql", source_module: "infra" });
|
|
484
|
+
schemas.push(emitSqliteOutboxSchema());
|
|
485
|
+
schemas.push(emitSqliteAuditSchema());
|
|
486
|
+
|
|
487
|
+
// 4. DB client
|
|
488
|
+
files.push({ path: "src/db.ts", content: emitSqliteDbClient(system), language: "typescript", source_module: "infra" });
|
|
489
|
+
|
|
490
|
+
// 5. Migration runner
|
|
491
|
+
files.push({ path: "src/migrate.ts", content: emitSqliteMigration(system, schemas), language: "typescript", source_module: "infra" });
|
|
492
|
+
|
|
493
|
+
// 6. README
|
|
494
|
+
files.push({ path: "README.md", content: this.emitReadme(system), language: "yaml", source_module: "root" });
|
|
495
|
+
|
|
496
|
+
return files;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private emitTsConfig(): string {
|
|
500
|
+
const cfg = {
|
|
501
|
+
compilerOptions: {
|
|
502
|
+
target: "ES2020",
|
|
503
|
+
module: "commonjs",
|
|
504
|
+
lib: ["ES2020", "DOM"],
|
|
505
|
+
outDir: "./dist",
|
|
506
|
+
rootDir: "./src",
|
|
507
|
+
strict: true,
|
|
508
|
+
esModuleInterop: true,
|
|
509
|
+
skipLibCheck: true,
|
|
510
|
+
forceConsistentCasingInFileNames: true,
|
|
511
|
+
declaration: true,
|
|
512
|
+
sourceMap: true,
|
|
513
|
+
},
|
|
514
|
+
include: ["src/**/*"],
|
|
515
|
+
exclude: ["node_modules", "dist"],
|
|
516
|
+
};
|
|
517
|
+
return JSON.stringify(cfg, null, 2);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private emitEnvExample(system: IR.IRSystem): string {
|
|
521
|
+
return `# ${system.name} (SQLite target)
|
|
522
|
+
|
|
523
|
+
# Path to the SQLite database file. Defaults to ./<system_name>.db
|
|
524
|
+
SQLITE_PATH=./${toSnakeCase(system.name)}.db
|
|
525
|
+
|
|
526
|
+
PORT=3000
|
|
527
|
+
NODE_ENV=development
|
|
528
|
+
|
|
529
|
+
JWT_SECRET=
|
|
530
|
+
EVENT_MODE=in_process
|
|
531
|
+
`;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private emitReadme(system: IR.IRSystem): string {
|
|
535
|
+
return `# ${system.name} (SQLite target)
|
|
536
|
+
|
|
537
|
+
Generated by BoneScript compiler. Source hash: ${system.source_hash}
|
|
538
|
+
|
|
539
|
+
## Quick Start
|
|
540
|
+
|
|
541
|
+
\`\`\`bash
|
|
542
|
+
npm install
|
|
543
|
+
npm run migrate
|
|
544
|
+
npm run dev
|
|
545
|
+
\`\`\`
|
|
546
|
+
|
|
547
|
+
The database file will be created at \`./${toSnakeCase(system.name)}.db\` by default.
|
|
548
|
+
Override with the \`SQLITE_PATH\` environment variable.
|
|
549
|
+
|
|
550
|
+
## Why SQLite
|
|
551
|
+
|
|
552
|
+
This target produces a self-contained backend with no external services:
|
|
553
|
+
- No Postgres / Redis to start
|
|
554
|
+
- No Docker
|
|
555
|
+
- The whole database is one file you can copy, version, or back up easily
|
|
556
|
+
|
|
557
|
+
It's ideal for local development, demos, integration tests, and small
|
|
558
|
+
single-node deployments. For production multi-writer workloads, use the
|
|
559
|
+
default Express target which generates a Postgres-backed project.
|
|
560
|
+
`;
|
|
561
|
+
}
|
|
562
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type { SolverResult } from "./solver";
|
|
|
17
17
|
export { FullEmitter } from "./emit_full";
|
|
18
18
|
export { NakamaEmitter } from "./emit_nakama";
|
|
19
19
|
export { PrismaEmitter } from "./emit_prisma";
|
|
20
|
+
export { SqliteEmitter } from "./emit_sqlite";
|
|
20
21
|
export type { NakamaEmittedFile } from "./emit_nakama";
|
|
21
22
|
export type { EmittedFile } from "./emitter";
|
|
22
23
|
export { Verifier } from "./verifier";
|
|
@@ -44,6 +45,7 @@ export { mergeWithExisting, extractImplementations, validateExtensions } from ".
|
|
|
44
45
|
// New emitters
|
|
45
46
|
export { emitOpenApiSpec, emitOpenApiJson } from "./emit_openapi";
|
|
46
47
|
export { emitTypescriptSdk, emitSdkPackageJson } from "./emit_sdk";
|
|
48
|
+
export { emitReactHooks } from "./emit_react";
|
|
47
49
|
export { emitZodSchemas } from "./emit_zod";
|
|
48
50
|
export { emitPostmanCollection } from "./emit_postman";
|
|
49
51
|
export { emitSeedFile } from "./emit_seed";
|