lilith-platform.live/codebase/@features/api/scripts/scaffold-entity.ts

208 lines
6.6 KiB
TypeScript
Raw Normal View History

#!/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 <name>\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<void> {
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<readonly ${pascal}[]> {
try {
const rows = filter?.status
? await sql<Row[]>\`SELECT * FROM ${tableName} WHERE status = \${filter.status} ORDER BY updated_at DESC\`
: await sql<Row[]>\`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<Row[]>\`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<Row[]>\`
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<Row[]>\`
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<void> {
try {
const rows = await sql<Row[]>\`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/<surface>/<entity>.ts`,
` 3. Add ${camel}Migrations to src/app/server.ts runMigrations call`,
'',
].join('\n');
process.stdout.write(out);