179 lines
5.6 KiB
TypeScript
179 lines
5.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 { 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/<surface>/<entity>.ts`,
|
|
` 3. Add ${camel}Migrations to src/app/server.ts runMigrations call`,
|
|
'',
|
|
].join('\n');
|
|
|
|
process.stdout.write(out);
|