bonescript-compiler 0.8.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.
@@ -25,8 +25,49 @@ export declare function emitSqliteAuditSchema(): string;
25
25
  export declare function emitSqliteDbClient(system: IR.IRSystem): string;
26
26
  export declare function emitSqliteMigration(_system: IR.IRSystem, schemas: string[]): string;
27
27
  export declare function emitSqlitePackageJson(system: IR.IRSystem): string;
28
+ import { FullEmitterOptions } from "./emit_full";
28
29
  export declare class SqliteEmitter {
29
- emit(system: IR.IRSystem): EmittedFile[];
30
+ /**
31
+ * Emit a complete SQLite-backed Express project.
32
+ *
33
+ * Strategy: delegate the bulk of generation to FullEmitter (routes, auth,
34
+ * websocket, etc), then swap out the Postgres-specific pieces for SQLite
35
+ * equivalents. The generated routes use a Postgres-shaped SQL surface that
36
+ * our SQLite db.ts client translates at runtime (NOW() → datetime('now'),
37
+ * $N → ?, RETURNING * → re-select by id).
38
+ *
39
+ * What's swapped:
40
+ * - package.json SQLite deps (better-sqlite3) instead of pg
41
+ * - .env.example SQLite-shaped env vars
42
+ * - src/db.ts better-sqlite3 client with SQL translation
43
+ * - src/migrate.ts SQLite migration runner
44
+ * - migrations/*.sql SQLite-flavored DDL (TEXT, no JSONB, etc.)
45
+ * - README.md SQLite-aware quick start
46
+ *
47
+ * What's removed:
48
+ * - docker-compose.yaml, Dockerfile, k8s/deployment.yaml — SQLite is
49
+ * single-file; no service orchestration needed
50
+ *
51
+ * Known limitations:
52
+ * - Capability bodies that use jsonb_set / jsonb_agg / jsonb_array_elements
53
+ * fail at runtime. Refactor those capabilities or use the Express target.
54
+ * - Durable event delivery (EVENT_MODE=durable) requires Postgres-only
55
+ * features (FOR UPDATE SKIP LOCKED). Default is in_process which works
56
+ * fine on SQLite.
57
+ * - row_to_json(t.*) in LIST joins degrades to t.id (no full join object).
58
+ */
59
+ emit(system: IR.IRSystem, options?: FullEmitterOptions): EmittedFile[];
60
+ private buildReplacements;
61
+ /**
62
+ * In-process-only event bus for SQLite.
63
+ *
64
+ * The default Express target's events.ts has a durable mode backed by a
65
+ * Postgres outbox with FOR UPDATE SKIP LOCKED. SQLite has no equivalent,
66
+ * so we ship a simpler bus that only does in-memory delivery. Callers
67
+ * can still use the same eventBus.publish() / .subscribe() API; only
68
+ * `EVENT_MODE=durable` is unsupported.
69
+ */
70
+ private emitInProcessEventBus;
30
71
  private emitTsConfig;
31
72
  private emitEnvExample;
32
73
  private emitReadme;
@@ -209,11 +209,35 @@ function emitSqliteDbClient(system) {
209
209
  ` return _db;`,
210
210
  `}`,
211
211
  ``,
212
- `// Translate Postgres-style $1, $2, ... placeholders to SQLite ? placeholders.`,
213
- `// The generated route handlers use $N because the Postgres backend is the`,
214
- `// canonical target; this keeps the surface uniform.`,
215
- `function translateSql(sql: string): string {`,
216
- ` return sql.replace(/\\$(\\d+)/g, "?");`,
212
+ `// Translate Postgres-style SQL to SQLite-compatible SQL.`,
213
+ `//`,
214
+ `// Handles:`,
215
+ `// - $1, $2, ... → ? (and rebuilds params for out-of-order references)`,
216
+ `// - NOW() → datetime('now')`,
217
+ `// - row_to_json(t.*) → json_object(...) — best-effort, see comment below`,
218
+ `//`,
219
+ `// We can't translate every Postgres-ism. Capability effects that use`,
220
+ `// jsonb_set / jsonb_agg / jsonb_array_elements still fail at runtime; if`,
221
+ `// you hit those, refactor the capability or use the Express target.`,
222
+ `function translateSql(sql: string, params: any[]): { sql: string; params: any[] } {`,
223
+ ` let out = sql.replace(/\\bNOW\\s*\\(\\s*\\)/gi, "datetime('now')");`,
224
+ ``,
225
+ ` // row_to_json(t.*) → json_object('col', t.col, ...) is hard to do without`,
226
+ ` // knowing the columns. As a degraded fallback, replace it with the table`,
227
+ ` // alias so the LIST join still returns *something* (the joined table's id).`,
228
+ ` // The "as <name>" still works; consumers will see a string instead of an`,
229
+ ` // object. This is a known limitation.`,
230
+ ` out = out.replace(/row_to_json\\((\\w+)\\.\\*\\)/gi, "$1.id");`,
231
+ ``,
232
+ ` // Postgres-style placeholders → SQLite ?, with param rewrite for`,
233
+ ` // out-of-order references (e.g. "WHERE id = $1 AND ... = $2" vs`,
234
+ ` // "SET col = $2 WHERE id = $1").`,
235
+ ` const newParams: any[] = [];`,
236
+ ` out = out.replace(/\\$(\\d+)/g, (_m, n) => {`,
237
+ ` newParams.push(params[parseInt(n, 10) - 1]);`,
238
+ ` return "?";`,
239
+ ` });`,
240
+ ` return { sql: out, params: newParams };`,
217
241
  `}`,
218
242
  ``,
219
243
  `// Strip Postgres-only RETURNING * — better-sqlite3 returns the inserted/updated`,
@@ -233,29 +257,48 @@ function emitSqliteDbClient(system) {
233
257
  ` return null;`,
234
258
  `}`,
235
259
  ``,
260
+ // Parse the WHERE clause to find the param index that holds the id. The
261
+ // generated SQL uses two shapes:
262
+ // - emit_runtime PUT: "UPDATE t SET ... WHERE id = $1" → idIdx = 1
263
+ // - emit_capability: "UPDATE t SET col = $1 WHERE id = $2" → idIdx = 2
264
+ // For INSERT the row id is always $1 by convention.
265
+ `function findIdParamIndex(sql: string, params: any[]): any {`,
266
+ ` // Look for "WHERE id = $N" or "WHERE id = ?" (after translation).`,
267
+ ` const m = sql.match(/WHERE\\s+id\\s*=\\s*\\$(\\d+)/i);`,
268
+ ` if (m) {`,
269
+ ` const idx = parseInt(m[1], 10) - 1;`,
270
+ ` return params[idx];`,
271
+ ` }`,
272
+ ` // Translated form: count which "?" corresponds to the WHERE id.`,
273
+ ` const beforeWhere = sql.split(/WHERE\\s+id\\s*=\\s*\\?/i)[0] || "";`,
274
+ ` const placeholdersBeforeId = (beforeWhere.match(/\\?/g) || []).length;`,
275
+ ` return params[placeholdersBeforeId];`,
276
+ `}`,
277
+ ``,
236
278
  `export async function query<T = any>(text: string, params: any[] = []): Promise<T[]> {`,
237
279
  ` const db = getDb();`,
238
280
  ` const { sql: stripped, hadReturning } = stripReturning(text);`,
239
- ` const sql = translateSql(stripped);`,
281
+ ` const { sql, params: translatedParams } = translateSql(stripped, params);`,
240
282
  ` const trimmed = sql.trim().toUpperCase();`,
241
283
  ` if (trimmed.startsWith("SELECT") || trimmed.startsWith("WITH")) {`,
242
- ` return db.prepare(sql).all(...params) as T[];`,
284
+ ` return db.prepare(sql).all(...translatedParams) as T[];`,
243
285
  ` }`,
244
286
  ` // INSERT / UPDATE / DELETE`,
245
287
  ` const stmt = db.prepare(sql);`,
246
- ` const info = stmt.run(...params);`,
288
+ ` const info = stmt.run(...translatedParams);`,
247
289
  ` if (hadReturning) {`,
248
290
  ` const table = extractTable(sql);`,
249
291
  ` if (!table) return [];`,
250
292
  ` if (trimmed.startsWith("INSERT")) {`,
251
- ` // Look up by id (which is in params[0] for our generated INSERT shape)`,
293
+ ` // Generated INSERTs have id as the first column → params[0].`,
252
294
  ` const id = params[0];`,
253
295
  ` const row = db.prepare(\`SELECT * FROM \${table} WHERE id = ?\`).get(id);`,
254
296
  ` return row ? [row as T] : [];`,
255
297
  ` }`,
256
298
  ` if (trimmed.startsWith("UPDATE")) {`,
257
- ` // The id parameter for our generated UPDATE shape is params[0] (WHERE id = $1).`,
258
- ` const id = params[0];`,
299
+ ` // Find the id from the WHERE clause regardless of param order.`,
300
+ ` const id = findIdParamIndex(stripped, params);`,
301
+ ` if (id === undefined) return [];`,
259
302
  ` const row = db.prepare(\`SELECT * FROM \${table} WHERE id = ?\`).get(id);`,
260
303
  ` return row ? [row as T] : [];`,
261
304
  ` }`,
@@ -272,17 +315,43 @@ function emitSqliteDbClient(system) {
272
315
  `export async function execute(text: string, params: any[] = []): Promise<number> {`,
273
316
  ` const db = getDb();`,
274
317
  ` const { sql: stripped } = stripReturning(text);`,
275
- ` const sql = translateSql(stripped);`,
276
- ` const info = db.prepare(sql).run(...params);`,
318
+ ` const { sql, params: translatedParams } = translateSql(stripped, params);`,
319
+ ` const info = db.prepare(sql).run(...translatedParams);`,
277
320
  ` return info.changes;`,
278
321
  `}`,
279
322
  ``,
280
- `export async function transaction<T>(fn: (client: any) => Promise<T>): Promise<T> {`,
323
+ `// Run a function inside a SQLite transaction.`,
324
+ `//`,
325
+ `// IMPORTANT: better-sqlite3 transactions are synchronous. The callback runs`,
326
+ `// to completion before COMMIT — but if you do "await" inside it for a Promise`,
327
+ `// other than another query()/execute(), the transaction will commit before`,
328
+ `// the awaited work finishes.`,
329
+ `//`,
330
+ `// In practice this is fine: query()/execute() above are async only at the`,
331
+ `// type level; their bodies are synchronous because better-sqlite3 is.`,
332
+ `// The transaction will be safely committed once the callback returns, and`,
333
+ `// any chained query/execute calls inside it run on the same database in the`,
334
+ `// same transaction.`,
335
+ `//`,
336
+ `// Don't put fetch(), setTimeout, or other genuinely-async work inside fn.`,
337
+ `export async function transaction<T>(fn: (client: { query: typeof query; execute: typeof execute }) => Promise<T> | T): Promise<T> {`,
281
338
  ` const db = getDb();`,
282
- ` // better-sqlite3 transactions are synchronous; we adapt with deasync-style`,
283
- ` // immediate execution. The fn here uses query/execute which read the same db.`,
284
- ` const txn = db.transaction(async () => await fn({ query: query, execute: execute }));`,
285
- ` return await txn();`,
339
+ ` let result!: T;`,
340
+ ` let promise: Promise<T> | null = null;`,
341
+ ` const txn = db.transaction(() => {`,
342
+ ` const r = fn({ query, execute });`,
343
+ ` if (r instanceof Promise) {`,
344
+ ` // Capture the promise; await outside the synchronous transaction.`,
345
+ ` // Note: this means async work inside fn runs OUTSIDE the transaction,`,
346
+ ` // which may be a bug in caller code. Better to keep fn synchronous.`,
347
+ ` promise = r;`,
348
+ ` return;`,
349
+ ` }`,
350
+ ` result = r as T;`,
351
+ ` });`,
352
+ ` txn();`,
353
+ ` if (promise) return await promise;`,
354
+ ` return result;`,
286
355
  `}`,
287
356
  ``,
288
357
  `// Compatibility shim: the Postgres path uses pool.connect()/release() for nested`,
@@ -317,6 +386,7 @@ function emitSqliteDbClient(system) {
317
386
  exports.emitSqliteDbClient = emitSqliteDbClient;
318
387
  // ─── Migration runner (SQLite flavor) ─────────────────────────────────────────
319
388
  function emitSqliteMigration(_system, schemas) {
389
+ const system = _system; // alias for use below — kept underscore for backwards compat
320
390
  // Build deterministic blocks just like the Postgres path does.
321
391
  const lines = [];
322
392
  lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
@@ -354,7 +424,7 @@ function emitSqliteMigration(_system, schemas) {
354
424
  lines.push(`}`);
355
425
  lines.push(``);
356
426
  lines.push(`async function migrate() {`);
357
- lines.push(` const dbPath = process.env.SQLITE_PATH || path.resolve(process.cwd(), "app.db");`);
427
+ lines.push(` const dbPath = process.env.SQLITE_PATH || path.resolve(process.cwd(), "${toSnakeCase(_system.name)}.db");`);
358
428
  lines.push(` const db = new Database(dbPath);`);
359
429
  lines.push(` db.pragma("foreign_keys = ON");`);
360
430
  lines.push(``);
@@ -400,6 +470,7 @@ function emitSqliteMigration(_system, schemas) {
400
470
  exports.emitSqliteMigration = emitSqliteMigration;
401
471
  // ─── Package.json (SQLite flavor) ─────────────────────────────────────────────
402
472
  function emitSqlitePackageJson(system) {
473
+ // Full backend deps, but with better-sqlite3 instead of pg.
403
474
  const pkg = {
404
475
  name: toSnakeCase(system.name),
405
476
  version: system.version,
@@ -409,10 +480,13 @@ function emitSqlitePackageJson(system) {
409
480
  start: "node dist/index.js",
410
481
  dev: "ts-node src/index.ts",
411
482
  migrate: "ts-node src/migrate.ts",
483
+ seed: "ts-node src/seed.ts",
412
484
  },
413
485
  dependencies: {
486
+ // Pinned versions match the Express target except pg → better-sqlite3.
414
487
  express: "4.22.2",
415
488
  "better-sqlite3": "11.5.0",
489
+ ws: "8.18.0",
416
490
  uuid: "10.0.0",
417
491
  cors: "2.8.5",
418
492
  helmet: "8.0.0",
@@ -426,6 +500,7 @@ function emitSqlitePackageJson(system) {
426
500
  "@types/express": "4.17.21",
427
501
  "@types/node": "20.14.0",
428
502
  "@types/better-sqlite3": "7.6.11",
503
+ "@types/ws": "8.5.13",
429
504
  "@types/cors": "2.8.17",
430
505
  "@types/jsonwebtoken": "9.0.7",
431
506
  "@types/uuid": "10.0.0",
@@ -438,15 +513,70 @@ function emitSqlitePackageJson(system) {
438
513
  }
439
514
  exports.emitSqlitePackageJson = emitSqlitePackageJson;
440
515
  // ─── Top-level emitter ────────────────────────────────────────────────────────
516
+ const emit_full_1 = require("./emit_full");
441
517
  class SqliteEmitter {
442
- emit(system) {
443
- const files = [];
444
- // 1. Package + tsconfig
445
- files.push({ path: "package.json", content: emitSqlitePackageJson(system), language: "json", source_module: "root" });
446
- files.push({ path: "tsconfig.json", content: this.emitTsConfig(), language: "json", source_module: "root" });
447
- files.push({ path: ".env.example", content: this.emitEnvExample(system), language: "yaml", source_module: "root" });
448
- // 2. Schema files + collect for migration runner
449
- const schemas = [];
518
+ /**
519
+ * Emit a complete SQLite-backed Express project.
520
+ *
521
+ * Strategy: delegate the bulk of generation to FullEmitter (routes, auth,
522
+ * websocket, etc), then swap out the Postgres-specific pieces for SQLite
523
+ * equivalents. The generated routes use a Postgres-shaped SQL surface that
524
+ * our SQLite db.ts client translates at runtime (NOW() datetime('now'),
525
+ * $N ?, RETURNING * → re-select by id).
526
+ *
527
+ * What's swapped:
528
+ * - package.json SQLite deps (better-sqlite3) instead of pg
529
+ * - .env.example SQLite-shaped env vars
530
+ * - src/db.ts better-sqlite3 client with SQL translation
531
+ * - src/migrate.ts SQLite migration runner
532
+ * - migrations/*.sql SQLite-flavored DDL (TEXT, no JSONB, etc.)
533
+ * - README.md SQLite-aware quick start
534
+ *
535
+ * What's removed:
536
+ * - docker-compose.yaml, Dockerfile, k8s/deployment.yaml — SQLite is
537
+ * single-file; no service orchestration needed
538
+ *
539
+ * Known limitations:
540
+ * - Capability bodies that use jsonb_set / jsonb_agg / jsonb_array_elements
541
+ * fail at runtime. Refactor those capabilities or use the Express target.
542
+ * - Durable event delivery (EVENT_MODE=durable) requires Postgres-only
543
+ * features (FOR UPDATE SKIP LOCKED). Default is in_process which works
544
+ * fine on SQLite.
545
+ * - row_to_json(t.*) in LIST joins degrades to t.id (no full join object).
546
+ */
547
+ emit(system, options = {}) {
548
+ // 1. Generate the full Express project
549
+ const fullFiles = new emit_full_1.FullEmitter().emit(system, options);
550
+ // 2. Files we replace with SQLite equivalents (keyed by path)
551
+ const replacements = this.buildReplacements(system);
552
+ // 3. Files we drop entirely (Postgres-specific infra)
553
+ const drops = new Set([
554
+ "docker-compose.yaml",
555
+ "Dockerfile",
556
+ ".dockerignore",
557
+ "k8s/deployment.yaml",
558
+ "src/migration_diff.ts", // Postgres-specific schema diff
559
+ ]);
560
+ const out = [];
561
+ for (const f of fullFiles) {
562
+ if (drops.has(f.path))
563
+ continue;
564
+ // Migrations need wholesale replacement: regenerate per-entity SQLite SQL.
565
+ if (f.path.startsWith("migrations/") && f.path.endsWith(".sql") &&
566
+ f.path !== "migrations/event_outbox.sql" &&
567
+ f.path !== "migrations/audit_log.sql") {
568
+ // We re-emit these from scratch below.
569
+ continue;
570
+ }
571
+ const replacement = replacements.get(f.path);
572
+ if (replacement) {
573
+ out.push(replacement);
574
+ replacements.delete(f.path);
575
+ continue;
576
+ }
577
+ out.push(f);
578
+ }
579
+ // 4. Append per-entity SQLite migrations
450
580
  const seenPaths = new Set();
451
581
  for (const mod of system.modules) {
452
582
  if (mod.kind === "data_store" || mod.kind === "api_service") {
@@ -455,23 +585,157 @@ class SqliteEmitter {
455
585
  if (seenPaths.has(file.path))
456
586
  continue;
457
587
  seenPaths.add(file.path);
458
- files.push(file);
459
- schemas.push(file.content);
588
+ out.push(file);
460
589
  }
461
590
  }
462
591
  }
463
- // 3. Outbox + audit infra schemas
464
- files.push({ path: "migrations/event_outbox.sql", content: emitSqliteOutboxSchema(), language: "sql", source_module: "infra" });
465
- files.push({ path: "migrations/audit_log.sql", content: emitSqliteAuditSchema(), language: "sql", source_module: "infra" });
466
- schemas.push(emitSqliteOutboxSchema());
467
- schemas.push(emitSqliteAuditSchema());
468
- // 4. DB client
469
- files.push({ path: "src/db.ts", content: emitSqliteDbClient(system), language: "typescript", source_module: "infra" });
470
- // 5. Migration runner
471
- files.push({ path: "src/migrate.ts", content: emitSqliteMigration(system, schemas), language: "typescript", source_module: "infra" });
472
- // 6. README
473
- files.push({ path: "README.md", content: this.emitReadme(system), language: "yaml", source_module: "root" });
474
- return files;
592
+ // 5. Add the SQLite migration runner (rebuilt with all schemas in scope)
593
+ const allSchemas = out
594
+ .filter(f => f.path.startsWith("migrations/") && f.path.endsWith(".sql"))
595
+ .map(f => f.content);
596
+ out.push({
597
+ path: "src/migrate.ts",
598
+ content: emitSqliteMigration(system, allSchemas),
599
+ language: "typescript",
600
+ source_module: "infra",
601
+ });
602
+ // 6. Add any replacements that weren't already handled (i.e. files unique to SQLite)
603
+ for (const r of replacements.values()) {
604
+ out.push(r);
605
+ }
606
+ return out;
607
+ }
608
+ buildReplacements(system) {
609
+ const map = new Map();
610
+ map.set("package.json", {
611
+ path: "package.json",
612
+ content: emitSqlitePackageJson(system),
613
+ language: "json",
614
+ source_module: "root",
615
+ });
616
+ map.set(".env.example", {
617
+ path: ".env.example",
618
+ content: this.emitEnvExample(system),
619
+ language: "yaml",
620
+ source_module: "root",
621
+ });
622
+ map.set("src/db.ts", {
623
+ path: "src/db.ts",
624
+ content: emitSqliteDbClient(system),
625
+ language: "typescript",
626
+ source_module: "infra",
627
+ });
628
+ map.set("src/events.ts", {
629
+ path: "src/events.ts",
630
+ content: this.emitInProcessEventBus(system),
631
+ language: "typescript",
632
+ source_module: "infra",
633
+ });
634
+ map.set("migrations/event_outbox.sql", {
635
+ path: "migrations/event_outbox.sql",
636
+ content: emitSqliteOutboxSchema(),
637
+ language: "sql",
638
+ source_module: "infra",
639
+ });
640
+ map.set("migrations/audit_log.sql", {
641
+ path: "migrations/audit_log.sql",
642
+ content: emitSqliteAuditSchema(),
643
+ language: "sql",
644
+ source_module: "infra",
645
+ });
646
+ map.set("README.md", {
647
+ path: "README.md",
648
+ content: this.emitReadme(system),
649
+ language: "yaml",
650
+ source_module: "root",
651
+ });
652
+ return map;
653
+ }
654
+ /**
655
+ * In-process-only event bus for SQLite.
656
+ *
657
+ * The default Express target's events.ts has a durable mode backed by a
658
+ * Postgres outbox with FOR UPDATE SKIP LOCKED. SQLite has no equivalent,
659
+ * so we ship a simpler bus that only does in-memory delivery. Callers
660
+ * can still use the same eventBus.publish() / .subscribe() API; only
661
+ * `EVENT_MODE=durable` is unsupported.
662
+ */
663
+ emitInProcessEventBus(system) {
664
+ const lines = [];
665
+ lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
666
+ lines.push(`// In-process event bus (SQLite target — durable mode is Postgres-only).`);
667
+ lines.push(``);
668
+ lines.push(`import { v4 as uuid } from "uuid";`);
669
+ lines.push(`import { logger } from "./logger";`);
670
+ lines.push(`import { counter } from "./metrics";`);
671
+ lines.push(``);
672
+ lines.push(`export interface EventMetadata {`);
673
+ lines.push(` source: string;`);
674
+ lines.push(` timestamp: Date;`);
675
+ lines.push(` correlation_id: string;`);
676
+ lines.push(` causation_id: string;`);
677
+ lines.push(`}`);
678
+ lines.push(``);
679
+ lines.push(`export interface SystemEvent {`);
680
+ lines.push(` type: string;`);
681
+ lines.push(` payload: Record<string, unknown>;`);
682
+ lines.push(` metadata: EventMetadata;`);
683
+ lines.push(`}`);
684
+ lines.push(``);
685
+ lines.push(`type Handler = (event: SystemEvent) => Promise<void>;`);
686
+ lines.push(``);
687
+ lines.push(`class InProcessBus {`);
688
+ lines.push(` private handlers: Map<string, Handler[]> = new Map();`);
689
+ lines.push(``);
690
+ lines.push(` subscribe(type: string, handler: Handler): void {`);
691
+ lines.push(` const existing = this.handlers.get(type) || [];`);
692
+ lines.push(` existing.push(handler);`);
693
+ lines.push(` this.handlers.set(type, existing);`);
694
+ lines.push(` }`);
695
+ lines.push(``);
696
+ lines.push(` async publish(`);
697
+ lines.push(` type: string,`);
698
+ lines.push(` payload: Record<string, unknown>,`);
699
+ lines.push(` source: string,`);
700
+ lines.push(` correlationId?: string,`);
701
+ lines.push(` _client?: unknown,`);
702
+ lines.push(` ): Promise<void> {`);
703
+ lines.push(` const event: SystemEvent = {`);
704
+ lines.push(` type,`);
705
+ lines.push(` payload,`);
706
+ lines.push(` metadata: {`);
707
+ lines.push(` source,`);
708
+ lines.push(` timestamp: new Date(),`);
709
+ lines.push(` correlation_id: correlationId || uuid(),`);
710
+ lines.push(` causation_id: uuid(),`);
711
+ lines.push(` },`);
712
+ lines.push(` };`);
713
+ lines.push(` counter("event.published", { type, mode: "in_process" });`);
714
+ lines.push(` const handlers = this.handlers.get(type) || [];`);
715
+ lines.push(` for (const handler of handlers) {`);
716
+ lines.push(` try {`);
717
+ lines.push(` await handler(event);`);
718
+ lines.push(` counter("event.delivered", { type, mode: "in_process" });`);
719
+ lines.push(` } catch (e: any) {`);
720
+ lines.push(` counter("event.delivery_failed", { type, mode: "in_process" });`);
721
+ lines.push(` logger.error("event_handler_failed", { event: type, metadata: { error: e.message } });`);
722
+ lines.push(` }`);
723
+ lines.push(` }`);
724
+ lines.push(` }`);
725
+ lines.push(``);
726
+ lines.push(` startWorker(_intervalMs?: number): null {`);
727
+ lines.push(` // No-op for in-process mode.`);
728
+ lines.push(` return null;`);
729
+ lines.push(` }`);
730
+ lines.push(`}`);
731
+ lines.push(``);
732
+ lines.push(`if (process.env.EVENT_MODE === "durable") {`);
733
+ lines.push(` console.warn("[events] EVENT_MODE=durable is not supported on the SQLite target. Falling back to in_process.");`);
734
+ lines.push(`}`);
735
+ lines.push(``);
736
+ lines.push(`export const eventBus = new InProcessBus();`);
737
+ lines.push(``);
738
+ return lines.join("\n");
475
739
  }
476
740
  emitTsConfig() {
477
741
  const cfg = {
@@ -495,15 +759,41 @@ class SqliteEmitter {
495
759
  }
496
760
  emitEnvExample(system) {
497
761
  return `# ${system.name} (SQLite target)
762
+ # Copy to .env and customize.
498
763
 
764
+ # --- Database ---
499
765
  # Path to the SQLite database file. Defaults to ./<system_name>.db
500
766
  SQLITE_PATH=./${toSnakeCase(system.name)}.db
501
767
 
768
+ # --- Required in production ---
769
+ # Generate with: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
770
+ JWT_SECRET=
771
+
772
+ # --- Server ---
502
773
  PORT=3000
503
774
  NODE_ENV=development
504
775
 
505
- JWT_SECRET=
776
+ # --- CORS ---
777
+ # Comma-separated list of allowed origins. Leave empty to disallow cross-origin requests.
778
+ ALLOWED_ORIGINS=
779
+
780
+ # --- Event delivery ---
781
+ # in_process: in-memory bus, no durability — recommended for SQLite.
782
+ # durable mode (Postgres outbox + FOR UPDATE SKIP LOCKED) is not supported.
506
783
  EVENT_MODE=in_process
784
+
785
+ # --- Request timeout ---
786
+ REQUEST_TIMEOUT_MS=30000
787
+
788
+ # --- Notifications ---
789
+ # NOTIFY_PROVIDER=log|resend|sendgrid|webhook (default: log)
790
+ NOTIFY_PROVIDER=log
791
+ NOTIFY_API_KEY=
792
+ NOTIFY_FROM_EMAIL=noreply@example.com
793
+
794
+ # --- Webhook delivery ---
795
+ NOTIFY_WEBHOOK_URL=
796
+ NOTIFY_WEBHOOK_SECRET=
507
797
  `;
508
798
  }
509
799
  emitReadme(system) {
@@ -519,19 +809,53 @@ npm run migrate
519
809
  npm run dev
520
810
  \`\`\`
521
811
 
522
- The database file will be created at \`./${toSnakeCase(system.name)}.db\` by default.
523
- Override with the \`SQLITE_PATH\` environment variable.
812
+ The database file will be created at \`./${toSnakeCase(system.name)}.db\` by
813
+ default. Override with the \`SQLITE_PATH\` environment variable.
814
+
815
+ The server starts at http://localhost:3000 with all routes wired:
816
+
817
+ - CRUD endpoints for every entity
818
+ - Capability endpoints (\`POST /<entity>s/<capability-name>\`)
819
+ - Health checks at \`/health/live\`, \`/health/ready\`, \`/health/metrics\`
820
+ - Admin panel at \`/admin\`
524
821
 
525
822
  ## Why SQLite
526
823
 
527
- This target produces a self-contained backend with no external services:
528
- - No Postgres / Redis to start
529
- - No Docker
530
- - The whole database is one file you can copy, version, or back up easily
824
+ Self-contained, single-file database. No external services to start, no
825
+ Docker, no Postgres. The whole database is one file you can copy, version,
826
+ or back up.
827
+
828
+ Ideal for: local development, demos, integration tests, single-node
829
+ deployments. For production multi-writer workloads, use the default Express
830
+ target (Postgres-backed).
831
+
832
+ ## Auth
833
+
834
+ Send a Bearer token in the Authorization header:
835
+
836
+ \`\`\`
837
+ Authorization: Bearer <jwt-token>
838
+ \`\`\`
839
+
840
+ Generate a token by signing with the same \`JWT_SECRET\` from your \`.env\`.
841
+
842
+ ## Differences from the Postgres target
843
+
844
+ - Schema uses \`TEXT\`/\`INTEGER\` instead of \`VARCHAR\`/\`BIGINT\`/\`JSONB\`
845
+ - The DB client (\`src/db.ts\`) translates Postgres-flavored SQL at runtime:
846
+ - \`$N\` placeholders → \`?\` (with param array reordering)
847
+ - \`NOW()\` → \`datetime('now')\`
848
+ - \`RETURNING *\` → re-select by id
849
+ - \`row_to_json(t.*)\` in LIST joins degrades to \`t.id\` only
850
+ - Durable event mode (\`EVENT_MODE=durable\`) is not supported on SQLite —
851
+ it requires Postgres-only \`FOR UPDATE SKIP LOCKED\`. Use \`in_process\`.
852
+ - Capability bodies that reference \`jsonb_set\`, \`jsonb_agg\`, or
853
+ \`jsonb_array_elements\` will fail at runtime. Refactor those capabilities
854
+ or compile to the Express target.
855
+
856
+ ## Environment
531
857
 
532
- It's ideal for local development, demos, integration tests, and small
533
- single-node deployments. For production multi-writer workloads, use the
534
- default Express target which generates a Postgres-backed project.
858
+ Copy \`.env.example\` to \`.env\` and configure.
535
859
  `;
536
860
  }
537
861
  }