chore(ts-migration): add generated entity types
Phase 3.5. Entity record shapes, generated from the backend's resource
registry rather than transcribed from it. Type-only: every touched file emits
byte-identical JavaScript, and the two type modules emit nothing at all.
`scripts/gen-entity-types.mjs` reads
`krow-backend/go-api/internal/domain/resources_gen.go` - itself generated out of
information_schema, so it cannot drift from the migrations - and writes
`src/types/entities.generated.ts`: 15 resources, 298 columns. It checks for
drift by default and rewrites with --write, the same arrangement seed-fixture.mjs
uses, and skips cleanly when the backend is not checked out beside this repo.
Field types come from `Column.SelectExpr()` in `domain/resource.go`, which is
what the read projection actually emits, not from the Postgres type. The two
differ: uuid and citext are cast to text, numeric to float8, dates and
timestamps to formatted strings, and - the case that justifies generating rather
than typing by hand - `user_activity.id` is an identity bigint cast to text, so
it arrives as a STRING. Written by hand it would have been called a number, and
nothing would have contradicted that until a comparison quietly stopped
matching.
The five Phase 3 leaf utilities swap their `any` placeholders for these records,
as `Partial<...>`: each takes `= {}` or guards every read because it renders
before the query resolves, and requiring the whole record would force those
defaults out - a behaviour change in a scoring path.
jsonb is where the generator stops. Thirteen columns across eight entities are
typed `unknown`, correctly: what sits inside a jsonb column is not in
information_schema and nothing on the backend declares it. Where a module reads
through one it is narrowed to `unknown[]`, `any[]` or `any` - what kind of value
it is, and nothing about its contents. An earlier draft declared the fields
these modules read off them; it was removed. That would have been inventing a
schema the database does not hold, with the compiler then defending the guess.
Not wired into skill-check.mjs: that file is being changed concurrently by
unrelated feature work. Adding `"types:check": "node scripts/gen-entity-types.mjs"`
beside the existing seed:check is the natural next step and is deliberately left
for when that file is quiet.
Verified in isolation from the parallel feature work (commit 1775395 plus these
eight files only): tsc 64 errors with an unchanged histogram, skill-check
1641/1642 with only the known stale-fixture failure, Owliver baseline 59/59,
lint 0 errors, production build succeeds with the API origin inlined.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
209
scripts/gen-entity-types.mjs
Normal file
209
scripts/gen-entity-types.mjs
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Generates `src/types/entities.generated.ts` from the backend's resource
|
||||
* registry.
|
||||
*
|
||||
* node scripts/gen-entity-types.mjs # check: exits non-zero if stale
|
||||
* node scripts/gen-entity-types.mjs --write # regenerate
|
||||
*
|
||||
* The same arrangement as `seed-fixture.mjs`, and for the same reason: two
|
||||
* descriptions of one shape, maintained by hand, only ever fail quietly. Here
|
||||
* the two are the columns Postgres actually has and the fields this app
|
||||
* believes it will be sent.
|
||||
*
|
||||
* ## Why generate rather than transcribe
|
||||
*
|
||||
* `krow-backend/go-api/internal/domain/resources_gen.go` is itself generated,
|
||||
* out of `information_schema`, and says so: "Column names, types, enum values
|
||||
* and nullability are read out of information_schema so they cannot drift from
|
||||
* the migrations." It is the closest thing to the database that can be read
|
||||
* without connecting to one — 15 resources, 298 columns, every one carrying its
|
||||
* kind, its nullability and its permitted values.
|
||||
*
|
||||
* Two hundred and ninety-eight fields retyped by hand would be two hundred and
|
||||
* ninety-eight chances to be confidently wrong, and a type that is wrong is
|
||||
* worse than no type: it is a claim the compiler will defend.
|
||||
*
|
||||
* ## Where the TypeScript types come from
|
||||
*
|
||||
* Not from the Postgres type — from `Column.SelectExpr()` in
|
||||
* `domain/resource.go`, which is what the read projection actually emits. The
|
||||
* two differ, and the differences are the whole point of reading the code
|
||||
* rather than the schema:
|
||||
*
|
||||
* uuid ::text -> string (never a byte array)
|
||||
* numeric ::float8 -> number (never pgtype.Numeric)
|
||||
* date to_char(…,'YYYY-MM-DD') -> string (a date, not a timestamp)
|
||||
* timestamptz to_char(…ISO with ms…) -> string
|
||||
* citext ::text -> string
|
||||
* bigint ::text -> string ← user_activity.id only
|
||||
* int (uncast) -> number
|
||||
*
|
||||
* That last one is the case worth the whole exercise: `user_activity.id` is an
|
||||
* identity bigint and arrives as a STRING, because every id the frontend
|
||||
* handles is an opaque string. A hand-written interface would have called it a
|
||||
* number, and nothing would have contradicted that until a comparison silently
|
||||
* stopped matching.
|
||||
*
|
||||
* Nullability is the column's: `NotNull: true` becomes a required field, its
|
||||
* absence becomes `| null`. Every column is emitted because the projection
|
||||
* emits every column — `Repo.selectList()` maps over `res.Columns` with no
|
||||
* filter, and §4.1 of the API contract states the record is complete.
|
||||
*
|
||||
* ## What this does NOT generate
|
||||
*
|
||||
* Three of the eighteen entities the client knows are absent from the registry
|
||||
* because they are not served by the generic entity machinery: AgentDefinition
|
||||
* and SkillDefinition have dedicated handlers in `httpserver/definitions.go`,
|
||||
* and the user is `httpserver/me.go`. Their shapes are hand-written in
|
||||
* `src/types/user.ts` and `src/types/entities.ts`. This file does not invent
|
||||
* them.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
/** The registry, read from the sibling checkout. */
|
||||
export const REGISTRY_PATH = join(
|
||||
process.cwd(), '..', 'krow-backend', 'go-api', 'internal', 'domain', 'resources_gen.go');
|
||||
|
||||
export const OUTPUT_PATH = join(process.cwd(), 'src', 'types', 'entities.generated.ts');
|
||||
|
||||
/**
|
||||
* Kind -> TypeScript, following `Column.SelectExpr()` rather than the column's
|
||||
* Postgres type. `pgType` is needed because two kinds cast conditionally.
|
||||
*/
|
||||
function tsTypeFor(kind, pgType, enumValues) {
|
||||
switch (kind) {
|
||||
case 'KindUUID': return 'string'; // ::text
|
||||
case 'KindString': return 'string'; // citext also ::text
|
||||
case 'KindBool': return 'boolean';
|
||||
case 'KindFloat': return 'number'; // ::float8
|
||||
case 'KindInt': return pgType === 'bigint' ? 'string' : 'number';
|
||||
case 'KindDate': return 'string'; // 'YYYY-MM-DD'
|
||||
case 'KindTimestamp': return 'string'; // ISO-8601 with ms
|
||||
case 'KindTextArray': return 'string[]';
|
||||
case 'KindEnum': return enumValues.map((v) => `'${v}'`).join(' | ');
|
||||
case 'KindJSON': return 'unknown'; // jsonb: shape is the column's own
|
||||
default: throw new Error(`unmapped Kind: ${kind}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses the Go registry into `[{ name, path, table, columns }]`. */
|
||||
export function parseRegistry(source) {
|
||||
const resources = [];
|
||||
/* Each resource opens with a lone `{` one tab in, then a `Name:` line two
|
||||
tabs in. Splitting on that opener gives one block per resource; the header
|
||||
match rejects anything that is not one. */
|
||||
const blocks = source.split(/\n\t\{\n/).slice(1);
|
||||
for (const block of blocks) {
|
||||
const head = block.match(/^\t\tName: "(\w+)", Path: "([\w-]+)", Table: "(\w+)",/);
|
||||
if (!head) continue;
|
||||
const [, name, path, table] = head;
|
||||
const columns = [];
|
||||
const colRe = /\{Name: "(\w+)", Kind: (Kind\w+), PGType: "([^"]+)"([^}]*)\}/g;
|
||||
let m;
|
||||
while ((m = colRe.exec(block)) !== null) {
|
||||
const [, colName, kind, pgType, rest] = m;
|
||||
/* `rest` is already cut at the first `}`, which for an enum column is
|
||||
the one closing `[]string{...}` — so the values are in it but the
|
||||
brace is not. Requiring a closing brace here matched nothing and
|
||||
silently produced empty unions. */
|
||||
const enumMatch = rest.match(/Enum: \[\]string\{([^}]*)/);
|
||||
const enumValues = enumMatch
|
||||
? [...enumMatch[1].matchAll(/"([^"]*)"/g)].map((e) => e[1])
|
||||
: [];
|
||||
columns.push({
|
||||
name: colName,
|
||||
kind,
|
||||
pgType,
|
||||
notNull: /NotNull: true/.test(rest),
|
||||
readOnly: /ReadOnly: true/.test(rest),
|
||||
required: /Required: true/.test(rest),
|
||||
enumValues,
|
||||
});
|
||||
}
|
||||
if (columns.length) resources.push({ name, path, table, columns });
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
const FLAGS = (c) => {
|
||||
const notes = [];
|
||||
if (c.readOnly) notes.push('server-owned');
|
||||
if (c.required) notes.push('required on create');
|
||||
return notes.length ? ` /** ${notes.join('; ')}. */\n` : '';
|
||||
};
|
||||
|
||||
/** One resource -> one exported interface. */
|
||||
function renderInterface(res) {
|
||||
const fields = res.columns.map((c) => {
|
||||
const ts = tsTypeFor(c.kind, c.pgType, c.enumValues);
|
||||
/* A nullable column returns JSON null, so the field is present and null
|
||||
rather than absent. `?:` would describe a key that can be missing, which
|
||||
is not what the projection does. */
|
||||
const type = c.notNull ? ts : `${ts} | null`;
|
||||
return `${FLAGS(c)} ${c.name}: ${type};`;
|
||||
}).join('\n');
|
||||
return `/** \`${res.path}\` — the \`${res.table}\` table, every column the projection returns. */\nexport interface ${res.name} {\n${fields}\n}`;
|
||||
}
|
||||
|
||||
export function render(resources) {
|
||||
const banner = `/**
|
||||
* GENERATED FILE — DO NOT EDIT BY HAND.
|
||||
*
|
||||
* Regenerate with: node scripts/gen-entity-types.mjs --write
|
||||
* Source of truth: krow-backend/go-api/internal/domain/resources_gen.go
|
||||
* (itself generated from information_schema)
|
||||
*
|
||||
* Field types follow \`Column.SelectExpr()\` in \`domain/resource.go\` — what the
|
||||
* read projection emits — not the raw Postgres type. See the generator's header
|
||||
* for the mapping and for why the two differ.
|
||||
*
|
||||
* ${resources.length} resources, ${resources.reduce((n, r) => n + r.columns.length, 0)} columns.
|
||||
*/
|
||||
`;
|
||||
const interfaces = resources.map(renderInterface).join('\n\n');
|
||||
const names = resources.map((r) => ` | '${r.name}'`).join('\n');
|
||||
const mapEntries = resources.map((r) => ` ${r.name}: ${r.name};`).join('\n');
|
||||
const tail = `/** The entity names the generic registry serves. Three more exist — see \`entities.ts\`. */
|
||||
export type GeneratedEntityName =
|
||||
${names};
|
||||
|
||||
/** Entity name -> its record type, for looking a record up by name. */
|
||||
export interface GeneratedEntityRecords {
|
||||
${mapEntries}
|
||||
}
|
||||
`;
|
||||
return `${banner}\n${interfaces}\n\n${tail}`;
|
||||
}
|
||||
|
||||
/* ── CLI ──────────────────────────────────────────────────────────────────── */
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
if (!existsSync(REGISTRY_PATH)) {
|
||||
console.log('krow-backend is not checked out beside this repo; entity types not checked here.');
|
||||
console.log('The types in src/types/entities.generated.ts are committed, so this is not fatal.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const resources = parseRegistry(readFileSync(REGISTRY_PATH, 'utf8'));
|
||||
if (!resources.length) {
|
||||
console.error('parsed no resources out of resources_gen.go — has its shape changed?');
|
||||
process.exit(1);
|
||||
}
|
||||
const generated = render(resources);
|
||||
|
||||
if (process.argv.includes('--write')) {
|
||||
writeFileSync(OUTPUT_PATH, generated);
|
||||
const cols = resources.reduce((n, r) => n + r.columns.length, 0);
|
||||
console.log(`entities.generated.ts written — ${resources.length} resources, ${cols} columns.`);
|
||||
} else if (!existsSync(OUTPUT_PATH)) {
|
||||
console.error('entities.generated.ts is missing. Run: node scripts/gen-entity-types.mjs --write');
|
||||
process.exit(1);
|
||||
} else if (readFileSync(OUTPUT_PATH, 'utf8') !== generated) {
|
||||
console.error('entities.generated.ts is stale — it no longer matches the backend registry.');
|
||||
console.error('Run: node scripts/gen-entity-types.mjs --write');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('entities.generated.ts is in step with the backend registry.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user