#!/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 { Migration, Sql } from '@/shared/db'; import { ${upper}_STATUSES } from './types'; const statusCheck = ${upper}_STATUSES.map((s) => \`'\${s}'\`).join(', '); export const ${camel}Migrations: readonly Migration[] = [ { id: '${migrationId}', async up(sql: Sql): Promise { await sql.unsafe(\` CREATE TABLE IF NOT EXISTS ${tableName} ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', note TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CHECK (status IN (\${statusCheck})) ) \`); await sql\`CREATE INDEX IF NOT EXISTS idx_${tableName}_status ON ${tableName}(status)\`; }, }, ]; `; const repo = `\ import type { Sql } from '@/shared/db'; import { HttpError, 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 async function list${pascal}s(sql: Sql, filter?: { status?: ${pascal}Status }): Promise { try { const rows = filter?.status ? await sql\`SELECT * FROM ${tableName} WHERE status = \${filter.status} ORDER BY updated_at DESC\` : await sql\`SELECT * FROM ${tableName} ORDER BY updated_at DESC\`; return rows.map(hydrate); } catch (err) { if (err instanceof HttpError) throw err; throw new Error(\`list${pascal}s failed: \${String(err)}\`); } } export async function get${pascal}(sql: Sql, id: number): Promise<${pascal}> { try { const rows = await sql\`SELECT * FROM ${tableName} WHERE id = \${id}\`; if (!rows[0]) throw notFound('${camel}_not_found', \`${name} \${id} not found\`); return hydrate(rows[0]); } catch (err) { if (err instanceof HttpError) throw err; throw new Error(\`get${pascal} failed: \${String(err)}\`); } } export async function create${pascal}(sql: Sql, draft: ${pascal}Draft): Promise<${pascal}> { try { const rows = await sql\` INSERT INTO ${tableName} (name, status, note) VALUES (\${draft.name}, \${draft.status ?? 'active'}, \${draft.note ?? ''}) RETURNING * \`; return hydrate(rows[0]!); } catch (err) { if (err instanceof HttpError) throw err; throw new Error(\`create${pascal} failed: \${String(err)}\`); } } export async function update${pascal}(sql: Sql, id: number, patch: ${pascal}Patch): Promise<${pascal}> { try { const current = await get${pascal}(sql, id); const next = { ...current, ...patch }; const rows = await sql\` UPDATE ${tableName} SET name = \${next.name}, status = \${next.status}, note = \${next.note}, updated_at = now() WHERE id = \${id} RETURNING * \`; if (!rows[0]) throw notFound('${camel}_not_found', \`${name} \${id} not found\`); return hydrate(rows[0]); } catch (err) { if (err instanceof HttpError) throw err; throw new Error(\`update${pascal} failed: \${String(err)}\`); } } export async function delete${pascal}(sql: Sql, id: number): Promise { try { const rows = await sql\`DELETE FROM ${tableName} WHERE id = \${id} RETURNING id\`; if (rows.length === 0) throw notFound('${camel}_not_found', \`${name} \${id} not found\`); } catch (err) { if (err instanceof HttpError) throw err; throw new Error(\`delete${pascal} failed: \${String(err)}\`); } } `; 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);