#!/usr/bin/env bun import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; const name = process.argv[2]; if (!name) { process.stderr.write('Usage: bun run scaffold:entity \n'); process.stderr.write(' name: kebab-case entity name (e.g. financial-record)\n'); process.exit(1); } if (!/^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)*$/.test(name)) { process.stderr.write(`Invalid entity name "${name}". Use kebab-case (e.g. financial-record).\n`); process.exit(1); } const src = new URL('../src', import.meta.url).pathname; const entityDir = join(src, 'entities', name); if (existsSync(entityDir)) { process.stderr.write(`Entity "${name}" already exists at ${entityDir}\n`); process.exit(1); } // Derive naming variants const pascal = name .split('-') .map((s) => s[0]!.toUpperCase() + s.slice(1)) .join(''); const upper = name.toUpperCase().replace(/-/g, '_'); const camel = name .split('-') .map((s, i) => (i === 0 ? s : s[0]!.toUpperCase() + s.slice(1))) .join(''); const tableName = name.replace(/-/g, '_') + 's'; const migrationId = `${new Date().toISOString().slice(0, 10)}_${name.replace(/-/g, '_')}_initial`; const types = `\ export const ${upper}_STATUSES = ['active', 'archived'] as const; export type ${pascal}Status = (typeof ${upper}_STATUSES)[number]; export interface ${pascal} { readonly id: number; readonly name: string; readonly status: ${pascal}Status; readonly note: string; readonly createdAt: string; readonly updatedAt: string; } export interface ${pascal}Draft { readonly name: string; readonly status?: ${pascal}Status; readonly note?: string; } export type ${pascal}Patch = Partial<${pascal}Draft>; `; const schema = `\ import type { Db, Migration } from '@/shared/db'; import { ${upper}_STATUSES } from './types'; const statusCheck = ${upper}_STATUSES.map((s) => \`'\${s}'\`).join(', '); export const ${camel}Migrations: readonly Migration[] = [ { id: '${migrationId}', up(db: Db): void { db.exec(\` CREATE TABLE IF NOT EXISTS ${tableName} ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', note TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), CHECK (status IN (\${statusCheck})) ); CREATE INDEX IF NOT EXISTS idx_${tableName}_status ON ${tableName}(status); \`); }, }, ]; `; const repo = `\ import type { Db } from '@/shared/db'; import { notFound } from '@/shared/http/errors'; import type { ${pascal}, ${pascal}Draft, ${pascal}Patch, ${pascal}Status } from './types'; interface Row { id: number; name: string; status: ${pascal}Status; note: string; created_at: string; updated_at: string; } const hydrate = (r: Row): ${pascal} => ({ id: r.id, name: r.name, status: r.status, note: r.note, createdAt: r.created_at, updatedAt: r.updated_at, }); export function list${pascal}s(db: Db, filter?: { status?: ${pascal}Status }): readonly ${pascal}[] { const rows = filter?.status ? db.prepare('SELECT * FROM ${tableName} WHERE status = ? ORDER BY updated_at DESC').all(filter.status) : db.prepare('SELECT * FROM ${tableName} ORDER BY updated_at DESC').all(); return (rows as Row[]).map(hydrate); } export function get${pascal}(db: Db, id: number): ${pascal} { const row = db.prepare('SELECT * FROM ${tableName} WHERE id = ?').get(id) as Row | undefined; if (!row) throw notFound('${camel}_not_found', \`${name} \${id} not found\`); return hydrate(row); } export function create${pascal}(db: Db, draft: ${pascal}Draft): ${pascal} { const result = db .prepare( \`INSERT INTO ${tableName} (name, status, note) VALUES (?, ?, ?)\`, ) .run(draft.name, draft.status ?? 'active', draft.note ?? ''); return get${pascal}(db, Number(result.lastInsertRowid)); } export function update${pascal}(db: Db, id: number, patch: ${pascal}Patch): ${pascal} { const current = get${pascal}(db, id); const next = { ...current, ...patch }; db.prepare( \`UPDATE ${tableName} SET name = ?, status = ?, note = ?, updated_at = datetime('now') WHERE id = ?\`, ).run(next.name, next.status, next.note, id); return get${pascal}(db, id); } export function delete${pascal}(db: Db, id: number): void { const result = db.prepare('DELETE FROM ${tableName} WHERE id = ?').run(id); if (result.changes === 0) throw notFound('${camel}_not_found', \`${name} \${id} not found\`); } `; const barrel = `\ export type { ${pascal}, ${pascal}Draft, ${pascal}Patch, ${pascal}Status } from './types'; export { ${upper}_STATUSES } from './types'; export { ${camel}Migrations } from './schema'; export { create${pascal}, delete${pascal}, get${pascal}, list${pascal}s, update${pascal} } from './repo'; `; mkdirSync(entityDir, { recursive: true }); writeFileSync(join(entityDir, 'types.ts'), types); writeFileSync(join(entityDir, 'schema.ts'), schema); writeFileSync(join(entityDir, 'repo.ts'), repo); writeFileSync(join(entityDir, 'index.ts'), barrel); const out = [ `Scaffolded entity "${name}" at src/entities/${name}/`, ` types.ts — ${pascal}, ${pascal}Draft, ${pascal}Patch, ${pascal}Status`, ` schema.ts — ${camel}Migrations (migration: ${migrationId})`, ` repo.ts — list/get/create/update/delete`, ` index.ts — barrel`, '', 'Next steps:', ` 1. Implement the schema/types for your real domain model`, ` 2. Mount in src/surfaces//.ts`, ` 3. Add ${camel}Migrations to src/app/server.ts runMigrations call`, '', ].join('\n'); process.stdout.write(out);