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.
@@ -219,11 +219,35 @@ export function emitSqliteDbClient(system: IR.IRSystem): string {
219
219
  ` return _db;`,
220
220
  `}`,
221
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, "?");`,
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 };`,
227
251
  `}`,
228
252
  ``,
229
253
  `// Strip Postgres-only RETURNING * — better-sqlite3 returns the inserted/updated`,
@@ -243,29 +267,48 @@ export function emitSqliteDbClient(system: IR.IRSystem): string {
243
267
  ` return null;`,
244
268
  `}`,
245
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
+ ``,
246
288
  `export async function query<T = any>(text: string, params: any[] = []): Promise<T[]> {`,
247
289
  ` const db = getDb();`,
248
290
  ` const { sql: stripped, hadReturning } = stripReturning(text);`,
249
- ` const sql = translateSql(stripped);`,
291
+ ` const { sql, params: translatedParams } = translateSql(stripped, params);`,
250
292
  ` const trimmed = sql.trim().toUpperCase();`,
251
293
  ` if (trimmed.startsWith("SELECT") || trimmed.startsWith("WITH")) {`,
252
- ` return db.prepare(sql).all(...params) as T[];`,
294
+ ` return db.prepare(sql).all(...translatedParams) as T[];`,
253
295
  ` }`,
254
296
  ` // INSERT / UPDATE / DELETE`,
255
297
  ` const stmt = db.prepare(sql);`,
256
- ` const info = stmt.run(...params);`,
298
+ ` const info = stmt.run(...translatedParams);`,
257
299
  ` if (hadReturning) {`,
258
300
  ` const table = extractTable(sql);`,
259
301
  ` if (!table) return [];`,
260
302
  ` if (trimmed.startsWith("INSERT")) {`,
261
- ` // Look up by id (which is in params[0] for our generated INSERT shape)`,
303
+ ` // Generated INSERTs have id as the first column → params[0].`,
262
304
  ` const id = params[0];`,
263
305
  ` const row = db.prepare(\`SELECT * FROM \${table} WHERE id = ?\`).get(id);`,
264
306
  ` return row ? [row as T] : [];`,
265
307
  ` }`,
266
308
  ` if (trimmed.startsWith("UPDATE")) {`,
267
- ` // The id parameter for our generated UPDATE shape is params[0] (WHERE id = $1).`,
268
- ` const id = params[0];`,
309
+ ` // Find the id from the WHERE clause regardless of param order.`,
310
+ ` const id = findIdParamIndex(stripped, params);`,
311
+ ` if (id === undefined) return [];`,
269
312
  ` const row = db.prepare(\`SELECT * FROM \${table} WHERE id = ?\`).get(id);`,
270
313
  ` return row ? [row as T] : [];`,
271
314
  ` }`,
@@ -282,17 +325,43 @@ export function emitSqliteDbClient(system: IR.IRSystem): string {
282
325
  `export async function execute(text: string, params: any[] = []): Promise<number> {`,
283
326
  ` const db = getDb();`,
284
327
  ` const { sql: stripped } = stripReturning(text);`,
285
- ` const sql = translateSql(stripped);`,
286
- ` const info = db.prepare(sql).run(...params);`,
328
+ ` const { sql, params: translatedParams } = translateSql(stripped, params);`,
329
+ ` const info = db.prepare(sql).run(...translatedParams);`,
287
330
  ` return info.changes;`,
288
331
  `}`,
289
332
  ``,
290
- `export async function transaction<T>(fn: (client: any) => Promise<T>): Promise<T> {`,
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> {`,
291
348
  ` 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();`,
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;`,
296
365
  `}`,
297
366
  ``,
298
367
  `// Compatibility shim: the Postgres path uses pool.connect()/release() for nested`,
@@ -328,6 +397,7 @@ export function emitSqliteDbClient(system: IR.IRSystem): string {
328
397
  // ─── Migration runner (SQLite flavor) ─────────────────────────────────────────
329
398
 
330
399
  export function emitSqliteMigration(_system: IR.IRSystem, schemas: string[]): string {
400
+ const system = _system; // alias for use below — kept underscore for backwards compat
331
401
  // Build deterministic blocks just like the Postgres path does.
332
402
  const lines: string[] = [];
333
403
  lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
@@ -367,7 +437,7 @@ export function emitSqliteMigration(_system: IR.IRSystem, schemas: string[]): st
367
437
  lines.push(`}`);
368
438
  lines.push(``);
369
439
  lines.push(`async function migrate() {`);
370
- lines.push(` const dbPath = process.env.SQLITE_PATH || path.resolve(process.cwd(), "app.db");`);
440
+ lines.push(` const dbPath = process.env.SQLITE_PATH || path.resolve(process.cwd(), "${toSnakeCase(_system.name)}.db");`);
371
441
  lines.push(` const db = new Database(dbPath);`);
372
442
  lines.push(` db.pragma("foreign_keys = ON");`);
373
443
  lines.push(``);
@@ -415,6 +485,7 @@ export function emitSqliteMigration(_system: IR.IRSystem, schemas: string[]): st
415
485
  // ─── Package.json (SQLite flavor) ─────────────────────────────────────────────
416
486
 
417
487
  export function emitSqlitePackageJson(system: IR.IRSystem): string {
488
+ // Full backend deps, but with better-sqlite3 instead of pg.
418
489
  const pkg = {
419
490
  name: toSnakeCase(system.name),
420
491
  version: system.version,
@@ -424,10 +495,13 @@ export function emitSqlitePackageJson(system: IR.IRSystem): string {
424
495
  start: "node dist/index.js",
425
496
  dev: "ts-node src/index.ts",
426
497
  migrate: "ts-node src/migrate.ts",
498
+ seed: "ts-node src/seed.ts",
427
499
  },
428
500
  dependencies: {
501
+ // Pinned versions match the Express target except pg → better-sqlite3.
429
502
  express: "4.22.2",
430
503
  "better-sqlite3": "11.5.0",
504
+ ws: "8.18.0",
431
505
  uuid: "10.0.0",
432
506
  cors: "2.8.5",
433
507
  helmet: "8.0.0",
@@ -441,6 +515,7 @@ export function emitSqlitePackageJson(system: IR.IRSystem): string {
441
515
  "@types/express": "4.17.21",
442
516
  "@types/node": "20.14.0",
443
517
  "@types/better-sqlite3": "7.6.11",
518
+ "@types/ws": "8.5.13",
444
519
  "@types/cors": "2.8.17",
445
520
  "@types/jsonwebtoken": "9.0.7",
446
521
  "@types/uuid": "10.0.0",
@@ -454,17 +529,76 @@ export function emitSqlitePackageJson(system: IR.IRSystem): string {
454
529
 
455
530
  // ─── Top-level emitter ────────────────────────────────────────────────────────
456
531
 
532
+ import { FullEmitter, FullEmitterOptions } from "./emit_full";
533
+
457
534
  export class SqliteEmitter {
458
- emit(system: IR.IRSystem): EmittedFile[] {
459
- const files: EmittedFile[] = [];
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
+ }
460
591
 
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" });
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
+ }
465
600
 
466
- // 2. Schema files + collect for migration runner
467
- const schemas: string[] = [];
601
+ // 4. Append per-entity SQLite migrations
468
602
  const seenPaths = new Set<string>();
469
603
  for (const mod of system.modules) {
470
604
  if (mod.kind === "data_store" || mod.kind === "api_service") {
@@ -472,28 +606,170 @@ export class SqliteEmitter {
472
606
  const file = emitSqliteSchema(model, mod, system);
473
607
  if (seenPaths.has(file.path)) continue;
474
608
  seenPaths.add(file.path);
475
- files.push(file);
476
- schemas.push(file.content);
609
+ out.push(file);
477
610
  }
478
611
  }
479
612
  }
480
613
 
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" });
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
+ }
489
629
 
490
- // 5. Migration runner
491
- files.push({ path: "src/migrate.ts", content: emitSqliteMigration(system, schemas), language: "typescript", source_module: "infra" });
630
+ return out;
631
+ }
492
632
 
493
- // 6. README
494
- files.push({ path: "README.md", content: this.emitReadme(system), language: "yaml", source_module: "root" });
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
+ }
495
687
 
496
- return files;
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");
497
773
  }
498
774
 
499
775
  private emitTsConfig(): string {
@@ -519,15 +795,41 @@ export class SqliteEmitter {
519
795
 
520
796
  private emitEnvExample(system: IR.IRSystem): string {
521
797
  return `# ${system.name} (SQLite target)
798
+ # Copy to .env and customize.
522
799
 
800
+ # --- Database ---
523
801
  # Path to the SQLite database file. Defaults to ./<system_name>.db
524
802
  SQLITE_PATH=./${toSnakeCase(system.name)}.db
525
803
 
804
+ # --- Required in production ---
805
+ # Generate with: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
806
+ JWT_SECRET=
807
+
808
+ # --- Server ---
526
809
  PORT=3000
527
810
  NODE_ENV=development
528
811
 
529
- JWT_SECRET=
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.
530
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=
531
833
  `;
532
834
  }
533
835
 
@@ -544,19 +846,53 @@ npm run migrate
544
846
  npm run dev
545
847
  \`\`\`
546
848
 
547
- The database file will be created at \`./${toSnakeCase(system.name)}.db\` by default.
548
- Override with the \`SQLITE_PATH\` environment variable.
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\`
549
858
 
550
859
  ## Why SQLite
551
860
 
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
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
556
894
 
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.
895
+ Copy \`.env.example\` to \`.env\` and configure.
560
896
  `;
561
897
  }
562
898
  }
package/src/lowering.ts CHANGED
@@ -246,6 +246,32 @@ export class Lowering {
246
246
  fields.push(this.lowerField(f));
247
247
  }
248
248
 
249
+ // Auto-add foreign key columns for `belongs_to` relations.
250
+ //
251
+ // Without this, generated migrations reference a column that doesn't exist
252
+ // (e.g. `FOREIGN KEY (seller_id) REFERENCES sellers(id)` when no seller_id
253
+ // is declared in `owns:`). Users were having to duplicate the FK in
254
+ // `owns:` to make it work; we now synthesize it.
255
+ //
256
+ // If the user already declared a column with the same name (the legacy
257
+ // pattern), we don't add a duplicate — their declaration wins so they
258
+ // can override nullability or add @sensitive.
259
+ const existingFieldNames = new Set(fields.map(f => f.name));
260
+ for (const rel of entity.relations) {
261
+ if (rel.relationType !== "belongs_to") continue;
262
+ const fkName = toSnakeCase(rel.target) + "_id";
263
+ if (existingFieldNames.has(fkName)) continue;
264
+ fields.push({
265
+ name: fkName,
266
+ type: "uuid",
267
+ nullable: false,
268
+ unique: false,
269
+ indexed: true,
270
+ default_value: null,
271
+ });
272
+ existingFieldNames.add(fkName);
273
+ }
274
+
249
275
  // Add derived fields as generated columns (stored: false = virtual)
250
276
  const derivedFields: IR.IRField[] = entity.derived.map(d => ({
251
277
  name: d.name,
@@ -1,11 +0,0 @@
1
- /**
2
- * Notification service tests.
3
- *
4
- * Generates the notify.ts file from a sample IR and verifies:
5
- * - webhook is in the provider union
6
- * - HMAC signing helper is emitted
7
- * - URL validation rejects non-http(s) protocols
8
- * - sendWebhook signs requests when NOTIFY_WEBHOOK_SECRET is set
9
- * - sendWebhook posts to NOTIFY_WEBHOOK_URL with the right shape
10
- */
11
- export {};