207 lines
6.6 KiB
TypeScript
207 lines
6.6 KiB
TypeScript
#!/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);
|