mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
1301 lines
66 KiB
TypeScript
1301 lines
66 KiB
TypeScript
/**
|
|
* Re-runnable entity generator wrapper (ORM migration Plan 2, Task 2).
|
|
*
|
|
* `@mikro-orm/entity-generator` 7.2.1 already produces the Phase 0 shape
|
|
* (`Days`/`DayNotes`/`Trips`, see `docs/superpowers/specs/2026-09-21-orm-repository-migration-design.md`
|
|
* D1/D2) from the migrated schema once configured with a naming strategy whose
|
|
* `columnNameToProperty` is the identity — every scalar and FK twin comes out
|
|
* named exactly like its column. What it does NOT do on its own (verified by
|
|
* reading `node_modules/@mikro-orm/entity-generator/*.js` and by running this
|
|
* wrapper against the real schema, not by assumption):
|
|
*
|
|
* - it leaves multi-word relation names snake_case and inverse-collection
|
|
* names camelCase (the opposite of what Phase 0 does for each);
|
|
* - it never marks a relation `.hidden()`;
|
|
* - it emits `p.datetime()` / `p.unknown().columnType('REAL')` instead of
|
|
* `p.type(DbTimestampType)` / `p.double()`;
|
|
* - it leaves `updateRule('no action')` / `deleteRule('no action')` noise
|
|
* (SQLite reports "NO ACTION" for every FK that never named a rule);
|
|
* - integer autoincrement primary keys come out `.primary().nullable()`
|
|
* (SQLite's `PRAGMA table_info` carries no AUTOINCREMENT flag);
|
|
* - it never binds a `repository:`;
|
|
* - the "collision class" (an FK column with no `_id` suffix, e.g.
|
|
* `country`, `created_by`) gets a relation named after the column AND a
|
|
* same-named scalar twin — colliding — and even once renamed, the
|
|
* generator's own override mechanism for a to-one relation only ever
|
|
* emits `.name(<column>)`, which Task 0/1 proved is inert for a relation
|
|
* (`.joinColumn(<column>)` is the only thing that actually pins the FK
|
|
* column; see `.superpowers/sdd/2026-09-22-orm-phase2-entity-rewrite/task-0-report.md`
|
|
* and `task-1-report.md`, fact (c)).
|
|
*
|
|
* Each gap above is closed by a single named rule function, applied either at
|
|
* the metadata level (`onProcessedMetadata`, preferred — the MikroORM-
|
|
* documented hook for this exact purpose) or, only where the metadata cannot
|
|
* carry the fix into the rendered `defineEntity` call (traced against the
|
|
* actual renderer in `SourceFile.js`/`DefineEntitySourceFile.js`, not
|
|
* guessed), as a small text pass over the generated source. See
|
|
* `task-2-report.md` for which rule lives at which level and why.
|
|
*
|
|
* This script never writes into `src/db/entities/` on its own — `--write` is
|
|
* accepted (Task 3 uses it) but this task never passes it. It never opens
|
|
* `data/travel.db` either: it migrates a throwaway temp file (via the same
|
|
* `Migrator` extension `buildApp()` uses, configured directly rather than by
|
|
* importing `src/db/orm.ts` — that module transitively imports
|
|
* `src/db/database.ts`, which opens `resolveDbPath()` — i.e. the real
|
|
* database file — as a side effect of being imported at all).
|
|
*
|
|
* Usage:
|
|
* node --import tsx scripts/generate-entities.ts --out <dir>
|
|
* node --import tsx scripts/generate-entities.ts --write # Task 3 only
|
|
*/
|
|
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import {
|
|
ReferenceKind,
|
|
UnderscoreNamingStrategy,
|
|
type EntityMetadata,
|
|
type EntityProperty,
|
|
type ImportsResolver,
|
|
type Platform,
|
|
} from '@mikro-orm/core';
|
|
import { EntityGenerator } from '@mikro-orm/entity-generator';
|
|
import { Migrator } from '@mikro-orm/migrations';
|
|
import { MikroORM as SqliteMikroORM } from '@mikro-orm/sqlite';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Paths
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** `server/` — every relative path below (migrations, repositories, entities) is anchored here. */
|
|
const SERVER_ROOT = path.join(__dirname, '..');
|
|
const ENTITIES_DIR = path.join(SERVER_ROOT, 'src/db/entities');
|
|
const REPOSITORIES_DIR = path.join(SERVER_ROOT, 'src/db/repositories');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Naming strategy — D1: scalars and FK twins are named exactly like their
|
|
// column (`day_id`, `sort_order`), never camelCased. Only `columnNameToProperty`
|
|
// is overridden; table names and everything else (`classToTableName`,
|
|
// `joinKeyColumnName`, `inverseSideName`, `manyToManyPropertyName`, …) stay the
|
|
// stock `UnderscoreNamingStrategy` behaviour, which is what lets the renamed
|
|
// relations below still compute the *same* FK column the schema already has
|
|
// (see RULE5_renameOwningRelations).
|
|
// ---------------------------------------------------------------------------
|
|
export class SnakeProps extends UnderscoreNamingStrategy {
|
|
override columnNameToProperty(column: string): string {
|
|
return column;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// KNOWN_DIFFS — the only allowed differences between this wrapper's output
|
|
// (into a scratch dir) and the five hand-written reference entities
|
|
// (`Days`, `DayNotes`, `Trips`, `BudgetCategoryOrder`, `VacayUserSettings`).
|
|
// Anything else in the diff is a rule bug, not an entry to add here.
|
|
// ---------------------------------------------------------------------------
|
|
export const KNOWN_DIFFS = `
|
|
1. Index duplicated onto the FK twin. The generator carries a relation's named
|
|
index (".index('idx_days_trip_id')") onto BOTH the owning relation AND its
|
|
persist(false) scalar twin; the hand-written files only ever declare it on
|
|
the relation. Harmless (SQLite would just create the same index once) —
|
|
verified against the actual generated output, not assumed.
|
|
2. Class-field TS annotation on an integer autoincrement primary key. The
|
|
hand-written files declare it "id?: number | null;" as an ergonomic hint
|
|
(you can build the entity before an id is assigned); the generator's own
|
|
renderer ties both the "?" and the "| null" suffix to metadata property
|
|
"nullable" (see SourceFile.js#getPropertyDefinition), and rule 3 below sets
|
|
nullable=false on this exact property so the metadata BUILDER CHAIN has no
|
|
stray ".nullable()" (matching the reference's *metadata*, which is what the
|
|
parity tests and the ORM itself read) — at the cost of the class field
|
|
rendering "id!: number & Opt;" instead (and the resulting "type Opt" import).
|
|
Purely a TypeScript-side annotation; it does not change runtime behaviour,
|
|
hydration, or the metadata the schema parity test compares. Confirmed by
|
|
generating the real schema, not assumed. It is also safe at the database
|
|
level despite the metadata's builder chain having no explicit
|
|
".autoincrement()" call for this property: MikroORM's own
|
|
MetadataDiscovery#initAutoincrement (node_modules/@mikro-orm/core/metadata/MetadataDiscovery.js)
|
|
defaults "autoincrement ??= true" for any single numeric PK at discovery
|
|
time, so the ORM treats the generated and hand-written metadata
|
|
identically once loaded — confirmed by generating the real schema and
|
|
inserting through the written entity (Days.repository.test.ts's insert
|
|
passes). Task 3 should have its own parity test (a PARITY-010 or sibling)
|
|
asserting "autoincrement === true" in the *discovered* metadata for every
|
|
integer PK, so this inference is pinned rather than assumed going
|
|
forward.
|
|
3. Method-call order within a builder chain (e.g. ".deleteRule('cascade').hidden().index(...)"
|
|
vs the hand-written files' ".hidden().deleteRule('cascade').index(...)").
|
|
The renderer assembles each property's options in its OWN fixed sequence
|
|
(kind-specific options — fieldName/deleteRule/updateRule/primary/generated —
|
|
then common flags — nullable/hidden/etc. — then index/unique), which the
|
|
hand-written files, authored before this generator produced this shape, did
|
|
not follow. Cosmetic only: every option present is the same, only the
|
|
textual order of independent chained calls differs.
|
|
4. "defaultRaw" renders as a backtick template literal ("\`CURRENT_TIMESTAMP\`")
|
|
rather than a single-quoted string ("'CURRENT_TIMESTAMP'"). SourceFile.js's
|
|
own renderer always uses a template literal for defaultRaw specifically (so
|
|
raw SQL containing "\${" is escaped rather than interpolated) — the
|
|
hand-written files predate this generator and used a plain string.
|
|
Semantically identical TypeScript.
|
|
5. A nullable column's DB-level default. Phase 0's own hand-written files are
|
|
not internally consistent about restating a NULLABLE column's default in
|
|
metadata at all: "DayNotes.sort_order" (nullable, "REAL DEFAULT 0") has
|
|
".default(0)"; "DayNotes.icon" (nullable, "TEXT DEFAULT '📝'") has none
|
|
at all, relying only on the class-field initialiser. The wrapper's default
|
|
rule (RULE_normalizeLiteralDefaults) deliberately scopes itself to NOT
|
|
NULL columns only — see that rule's doc comment — and leaves whatever the
|
|
generator's own (occasionally "defaultRaw" instead of "default", or
|
|
entirely absent) rendering produces for a nullable column. This is exactly
|
|
Task 0's skipped PARITY-005 (column defaults) territory, not this task's.
|
|
6. Trailing blank line / whitespace at end of file, if any — a generator
|
|
formatting convention, immaterial to the parsed module.
|
|
|
|
Fix round 1 (task-2-fix-brief.md) resolved the former #6 (the
|
|
VacayUserSettings.user_id PK-twin nullability judgment call) by taking the
|
|
generator's answer as correct and updating the hand-written file to match
|
|
(see .superpowers/sdd/2026-09-22-orm-phase2-entity-rewrite/task-2-review.md,
|
|
"#6 — generator or hand-written file?") — it is no longer a diff at all, so
|
|
it is no longer listed here.
|
|
`.trim();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// JSON columns — SQLite has no JSON storage class, so PRAGMA table_info can
|
|
// never tell the generator "this TEXT column is JSON-shaped"; that's always a
|
|
// judgement call an entity author made (today: exactly one column in the
|
|
// whole schema, `addons.config`, declared `p.json<AddonConfig>()`, grepped
|
|
// from src/db/entities). `JSON_COLUMNS` maps `table.column` -> the TypeScript
|
|
// type name to render (`p.json<AddonConfig>()`, never a bare `p.json()` or
|
|
// `p.json<unknown>()` — Fix round 1, task-2-review.md C3: a stale
|
|
// `runtimeType` left the class field as `IType<string, any>`, a new `any` in
|
|
// generated output). Every value here needs a matching declaration in
|
|
// `JSON_INTERFACES` below — `injectJsonInterfaces` throws otherwise.
|
|
// ---------------------------------------------------------------------------
|
|
export const JSON_COLUMNS: ReadonlyMap<string, string> = new Map([['addons.config', 'AddonConfig']]);
|
|
|
|
/**
|
|
* The interface declaration text `injectJsonInterfaces` inserts into a
|
|
* generated file for every `JSON_COLUMNS` type name it references. Not
|
|
* reconstructable from the schema (SQLite has no notion of a JSON column's
|
|
* shape) — this is a maintained list, the same shape as `JSON_COLUMNS`
|
|
* itself. `AddonConfig`'s shape is carried forward from the current
|
|
* hand-written `Addons.entity.ts`.
|
|
*/
|
|
const JSON_INTERFACES: Readonly<Record<string, string>> = {
|
|
AddonConfig: 'export interface AddonConfig {\n [key: string]: unknown;\n}\n',
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Boolean columns — same problem as JSON, the opposite direction: SQLite has
|
|
// no BOOLEAN storage class either (every "boolean" column is really
|
|
// INTEGER-affinity), so a plain `p.boolean()` column re-introspects as
|
|
// `p.integer()` and the class field silently degrades `boolean` -> `number`
|
|
// (Fix round 1, task-2-review.md I4). One column in the whole schema today
|
|
// (`addons.enabled`, grepped from src/db/entities — the only `p.boolean()` in
|
|
// the tree) — a maintained allow-list, same shape as `JSON_COLUMNS`.
|
|
// ---------------------------------------------------------------------------
|
|
export const BOOLEAN_COLUMNS: ReadonlySet<string> = new Set(['addons.enabled']);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rule 1 (metadata level): scalar type mapping.
|
|
//
|
|
// The naming strategy already makes every scalar/twin property name equal its
|
|
// column (no rule needed for that half of D1). What's left is the TYPE: the
|
|
// generator leaves a column it can't map cleanly as `p.unknown().columnType(<T>)`.
|
|
// On the current schema the only such family is SQLite's REAL affinity
|
|
// (MikroORM's SQLite platform never overrides `getDoubleDeclarationSQL`, so it
|
|
// stays abstract — confirmed in task-0-report.md while building the parity
|
|
// test's own normaliser). Anything else is unexpected: fail loudly, naming the
|
|
// column, rather than silently emitting a wrong or lossy type.
|
|
// ---------------------------------------------------------------------------
|
|
const UNKNOWN_COLUMN_TYPE_MAP: Readonly<Record<string, { type: string; runtimeType: string }>> = {
|
|
real: { type: 'double', runtimeType: 'number' },
|
|
};
|
|
|
|
/**
|
|
* `prop.runtimeType` must move with `prop.type` here, not just the type name.
|
|
* Left stale (introspection sets it to `'unknown'` alongside `type: 'unknown'`),
|
|
* `SourceFile.js#breakdownOfIType` sees `prop.runtimeType !== rawType` (the
|
|
* freshly-retyped `'double'`'s own runtime, `'number'`) and treats that
|
|
* mismatch as genuine type ambiguity — it wraps the class field in
|
|
* `IType<unknown, number>` and, separately, `needsExplicitColumnType()` also
|
|
* disagrees with itself and adds a redundant `.columnType('REAL')`. Both
|
|
* disappear once `runtimeType` is corrected. Confirmed by generating the real
|
|
* schema (`DayNotes.sort_order`) before and after this fix, not assumed.
|
|
*/
|
|
/** A scalar this run retyped (Rule 1's `p.unknown()` fix or Rule 2's `DbTimestampType`) — its metadata line may carry a redundant `.columnType()` (see below). */
|
|
export interface RetypedScalarFixup {
|
|
className: string;
|
|
propName: string;
|
|
}
|
|
|
|
export function RULE1_fixUnknownScalarTypes(metadata: EntityMetadata[]): RetypedScalarFixup[] {
|
|
const fixups: RetypedScalarFixup[] = [];
|
|
for (const meta of metadata) {
|
|
for (const prop of Object.values(meta.properties)) {
|
|
if (prop.kind !== undefined && prop.kind !== ReferenceKind.SCALAR) continue;
|
|
if (prop.type !== 'unknown') continue;
|
|
const columnType = (prop.columnTypes?.[0] ?? '').toLowerCase();
|
|
const mapped = UNKNOWN_COLUMN_TYPE_MAP[columnType];
|
|
if (!mapped) {
|
|
throw new Error(
|
|
`generate-entities: ${meta.tableName}.${prop.fieldNames.join(',')} has an unmapped column type ` +
|
|
`"${prop.columnTypes?.[0]}" (property "${meta.className}.${prop.name}"). Add it to ` +
|
|
'UNKNOWN_COLUMN_TYPE_MAP once you know what it should render as — never guess.',
|
|
);
|
|
}
|
|
prop.type = mapped.type;
|
|
prop.runtimeType = mapped.runtimeType;
|
|
fixups.push({ className: meta.className, propName: prop.name });
|
|
}
|
|
}
|
|
return fixups;
|
|
}
|
|
|
|
/**
|
|
* A JSON column this run marked — like `RetypedScalarFixup` (it needs the
|
|
* same `.columnType()` strip and is folded into `retypedScalars`) plus the
|
|
* TypeScript type name to render.
|
|
*/
|
|
export interface JsonColumnFixup extends RetypedScalarFixup {
|
|
typeName: string;
|
|
}
|
|
|
|
/**
|
|
* Rule 1b (metadata level): mark known JSON columns so they render
|
|
* `p.json()` (see JSON_COLUMNS above). `prop.runtimeType` must move with
|
|
* `prop.type` here for the same reason Rule 1's doc comment gives — left at
|
|
* its introspected value (`'string'`, since SQLite stores JSON as TEXT), the
|
|
* mismatch against `JsonType`'s own always-`'any'` runtime type would still
|
|
* make `SourceFile.js#breakdownOfIType` treat the property as ambiguous and
|
|
* wrap the class field in `IType<..., any>`. `fixJsonClassFieldTypes` (a text
|
|
* pass, see below) closes what setting `runtimeType` here does not: MikroORM
|
|
* has no metadata escape from `IType<T, any>` for a JSON-typed property at
|
|
* all (`JsonType#runtimeType` is a hardcoded getter, not derived from the
|
|
* property) — confirmed by generating the real schema and reading
|
|
* `JsonType.js`, not assumed.
|
|
*/
|
|
export function RULE1b_markJsonColumns(metadata: EntityMetadata[]): JsonColumnFixup[] {
|
|
const fixups: JsonColumnFixup[] = [];
|
|
for (const meta of metadata) {
|
|
for (const prop of Object.values(meta.properties)) {
|
|
if (prop.kind !== undefined && prop.kind !== ReferenceKind.SCALAR) continue;
|
|
const typeName = JSON_COLUMNS.get(`${meta.tableName}.${prop.fieldNames[0]}`);
|
|
if (!typeName) continue;
|
|
prop.type = 'json';
|
|
prop.runtimeType = typeName;
|
|
fixups.push({ className: meta.className, propName: prop.name, typeName });
|
|
}
|
|
}
|
|
return fixups;
|
|
}
|
|
|
|
/** Rule 1c (metadata level): mark known boolean columns so they render `p.boolean()` (see BOOLEAN_COLUMNS above). */
|
|
export function RULE1c_markBooleanColumns(metadata: EntityMetadata[]): RetypedScalarFixup[] {
|
|
const fixups: RetypedScalarFixup[] = [];
|
|
for (const meta of metadata) {
|
|
for (const prop of Object.values(meta.properties)) {
|
|
if (prop.kind !== undefined && prop.kind !== ReferenceKind.SCALAR) continue;
|
|
if (!BOOLEAN_COLUMNS.has(`${meta.tableName}.${prop.fieldNames[0]}`)) continue;
|
|
prop.type = 'boolean';
|
|
// `runtimeType` must move with `type` here too, for the same reason
|
|
// Rule 1's doc comment gives: left at its introspected value
|
|
// (`'number'`, SQLite has no BOOLEAN storage class), the mismatch
|
|
// against `BooleanType`'s own runtime type ('boolean') would make
|
|
// `breakdownOfIType` treat the property as ambiguous and wrap the
|
|
// class field in `IType<number, boolean>` — confirmed by generating
|
|
// the real schema before this fix, not assumed.
|
|
prop.runtimeType = 'boolean';
|
|
// The introspected DB default is always the raw SQLite value (`0`/`1`,
|
|
// a number, since SQLite has no boolean storage class either) —
|
|
// coerce it to a real boolean so a `p.boolean()` column's `.default()`
|
|
// renders `.default(false)`, not the now ill-typed `.default(0)`.
|
|
if (typeof prop.default === 'number') prop.default = prop.default !== 0;
|
|
if (typeof prop.defaultRaw === 'string' && /^-?\d+$/.test(prop.defaultRaw)) {
|
|
prop.defaultRaw = prop.defaultRaw !== '0' ? 'true' : 'false';
|
|
}
|
|
fixups.push({ className: meta.className, propName: prop.name });
|
|
}
|
|
}
|
|
return fixups;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rule 2 (metadata level): p.datetime() -> p.type(DbTimestampType).
|
|
//
|
|
// `prop.type = 'DbTimestampType'` (a plain string that is NOT a key in
|
|
// @mikro-orm/core's `types` registry) makes the renderer emit `p.type(DbTimestampType)`
|
|
// verbatim (DefineEntitySourceFile#getPropertyBuilder: `!(options.type in types)`
|
|
// -> `p.type(${options.type})`), and it auto-adds 'DbTimestampType' to the
|
|
// file's entityImports (SourceFile#getScalarPropertyDecoratorOptions,
|
|
// isTypeStringMissingFromMap branch) — the IMPORT PATH for that identifier
|
|
// (`../types`) is resolved by the onImport hook below, also metadata/config
|
|
// level, not a text pass. `.nullable()`/`.defaultRaw(...)` come from the
|
|
// already-introspected `prop.nullable`/`prop.defaultRaw` and need no rule;
|
|
// introspection never invents an `onCreate` hook (there is nothing in a
|
|
// PRAGMA to discover one from), so "never onCreate" needs no enforcement here.
|
|
// ---------------------------------------------------------------------------
|
|
/** A scalar this run retyped to `DbTimestampType` — the class field's type still needs a text fix (see below). */
|
|
export type TimestampFixup = RetypedScalarFixup;
|
|
|
|
export function RULE2_datetimeToDbTimestampType(metadata: EntityMetadata[]): TimestampFixup[] {
|
|
const fixups: TimestampFixup[] = [];
|
|
for (const meta of metadata) {
|
|
for (const prop of Object.values(meta.properties)) {
|
|
if (prop.kind !== undefined && prop.kind !== ReferenceKind.SCALAR) continue;
|
|
if (prop.type !== 'datetime') continue;
|
|
prop.type = 'DbTimestampType';
|
|
fixups.push({ className: meta.className, propName: prop.name });
|
|
}
|
|
}
|
|
return fixups;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rule 3 (metadata level): integer `id` PK -> p.integer().primary().autoincrement().
|
|
//
|
|
// All 122 integer PKs in the migrations are AUTOINCREMENT (inputs.md), but
|
|
// SQLite's PRAGMA table_info carries no such flag, so the generator always
|
|
// emits `.primary().nullable()` for one. `nullable = false` is what removes
|
|
// the stray `.nullable()` from the metadata BUILDER CHAIN (SourceFile's
|
|
// getCommonDecoratorOptions emits `.nullable()` whenever `prop.nullable` is
|
|
// true, unconditionally) — the one place this creates a *textual*, not
|
|
// semantic, difference from the hand-written files is documented as
|
|
// KNOWN_DIFFS #2 above. Composite and text PKs are untouched, per the plan
|
|
// ("`.nullable()` on text PKs is parity-correct — keep").
|
|
// ---------------------------------------------------------------------------
|
|
export function RULE3_integerPkAutoincrement(metadata: EntityMetadata[]): void {
|
|
for (const meta of metadata) {
|
|
if (meta.getPrimaryProps().length !== 1) continue; // composite PK — untouched
|
|
const [pk] = meta.getPrimaryProps();
|
|
if (pk.kind !== undefined && pk.kind !== ReferenceKind.SCALAR) continue; // a relation PK — untouched
|
|
const columnType = (pk.columnTypes?.[0] ?? '').toLowerCase();
|
|
if (columnType !== 'integer') continue; // a text PK — untouched
|
|
pk.autoincrement = true;
|
|
pk.nullable = false;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rule 4 (metadata level): every relation, owning and inverse, any kind, hidden().
|
|
// ---------------------------------------------------------------------------
|
|
export function RULE4_hideAllRelations(metadata: EntityMetadata[]): void {
|
|
for (const meta of metadata) {
|
|
for (const prop of meta.relations) {
|
|
prop.hidden = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
function isOwningToOne(prop: EntityProperty): boolean {
|
|
if (prop.kind === ReferenceKind.MANY_TO_ONE) return true;
|
|
return prop.kind === ReferenceKind.ONE_TO_ONE && !prop.mappedBy;
|
|
}
|
|
|
|
function isInverseSide(prop: EntityProperty): boolean {
|
|
if (prop.kind === ReferenceKind.ONE_TO_MANY) return true;
|
|
if (prop.kind === ReferenceKind.ONE_TO_ONE && !!prop.mappedBy) return true;
|
|
if (prop.kind === ReferenceKind.MANY_TO_MANY && !!prop.mappedBy) return true;
|
|
return false;
|
|
}
|
|
|
|
function toCamelCase(snake: string): string {
|
|
return snake.replace(/_([a-z0-9])/g, (_m, c: string) => c.toUpperCase());
|
|
}
|
|
|
|
function toSnakeCase(camel: string): string {
|
|
return camel.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
}
|
|
|
|
/** A collision-class relation this run renamed: needs an explicit `.joinColumn()` text fix. */
|
|
export interface JoinColumnFixup {
|
|
className: string;
|
|
propName: string;
|
|
column: string;
|
|
}
|
|
|
|
/**
|
|
* Renames don't just need `properties[newName] = prop` (what `addProperty`
|
|
* does) — `EntityGenerator`'s `DefineEntitySourceFile` renders the
|
|
* `properties: {...}` object by iterating `Object.entries(meta.properties)`
|
|
* directly (not the sorted `meta.props`/`propertyOrder` MikroORM otherwise
|
|
* uses), so `removeProperty` + `addProperty`'s delete-then-reinsert always
|
|
* moves a renamed property to the END of the file (Fix round 1,
|
|
* task-2-review.md M7). This rebuilds the dict's key order to match
|
|
* `originalOrder` (captured before any renames), substituting each renamed
|
|
* key for its old one — same prop objects, just re-keyed and reordered in
|
|
* place, so `propertyOrder`/`props`/`relations` (already correct from
|
|
* `addProperty`'s own `sync()`) are untouched.
|
|
*/
|
|
function restorePropertyOrder(meta: EntityMetadata, originalOrder: readonly string[], renameMap: ReadonlyMap<string, string>): void {
|
|
const keyOrder = originalOrder.map((oldName) => renameMap.get(oldName) ?? oldName);
|
|
const values = keyOrder.map((key) => meta.properties[key]);
|
|
for (const key of keyOrder) delete meta.properties[key];
|
|
keyOrder.forEach((key, i) => {
|
|
meta.properties[key] = values[i];
|
|
});
|
|
}
|
|
|
|
/** Stock `UnderscoreNamingStrategy` behaviour (our `SnakeProps` override never touches it) — used by RULE5 to verify a rename's round trip (see I2 below). No state, safe to share. */
|
|
const namingStrategy = new SnakeProps();
|
|
|
|
/**
|
|
* Rule 5 (metadata level for the rename; text level for the join column — see
|
|
* task-2-report.md "rule 5"): owning relation property name = camelCase of
|
|
* (column minus a trailing `_id`). A "collision class" column (no `_id`
|
|
* suffix: `created_by`, `country`, … — the FK twin would otherwise be named
|
|
* identically to the relation) gets `<camel>Ref` instead, and — because the
|
|
* renderer for a to-one relation only ever knows how to emit `.name(<column>)`
|
|
* as an override, which Task 0/1 proved does not actually pin the FK column —
|
|
* this function also records the (className, newPropName, column) triple for
|
|
* `injectJoinColumns` to turn into an explicit `.joinColumn(<column>)` in the
|
|
* generated text afterwards.
|
|
*
|
|
* A multi-column FK (`prop.fieldNames.length !== 1`) throws rather than
|
|
* silently keeping the raw snake_case name (Fix round 1, task-2-review.md
|
|
* I1 — the schema has zero of these today; the plan's D1 camelCase-relations
|
|
* rule has no shape for one, and nothing else would catch the drift, since
|
|
* `fieldNames` themselves stay correct).
|
|
*
|
|
* For a NORMAL (non-collision-class) rename, this also verifies the round
|
|
* trip: `namingStrategy.joinKeyColumnName(newName, referencedColumn)` must
|
|
* reproduce the real `column`, or the renderer's implicit FK-column inference
|
|
* (nothing pins it explicitly for this case) would silently point at the
|
|
* wrong column the moment a future migration's naming breaks the assumption
|
|
* (Fix round 1, task-2-review.md I2). A mismatch gets the exact same
|
|
* `JoinColumnFixup` treatment as a collision class — no separate code path.
|
|
*
|
|
* Every inverse `mappedBy` across the whole metadata array that pointed at
|
|
* the OLD owning name is fixed up to the new one in the same pass (mappedBy
|
|
* is a plain string on the inverse side; nothing else references an owning
|
|
* relation by name).
|
|
*/
|
|
export function RULE5_renameOwningRelations(metadata: EntityMetadata[]): JoinColumnFixup[] {
|
|
const fixups: JoinColumnFixup[] = [];
|
|
const renames = new Map<EntityMetadata, Map<string, string>>(); // meta -> oldName -> newName
|
|
|
|
for (const meta of metadata) {
|
|
const originalOrder = Object.keys(meta.properties);
|
|
const renameMap = new Map<string, string>();
|
|
for (const prop of [...meta.relations]) {
|
|
if (!isOwningToOne(prop)) continue;
|
|
if (prop.fieldNames.length !== 1) {
|
|
throw new Error(
|
|
`generate-entities: ${meta.className}.${prop.name} is a multi-column FK ` +
|
|
`(${prop.fieldNames.join(', ')}) — RULE5 only knows how to rename a single-column owning ` +
|
|
'relation. Add explicit handling for this relation before regenerating this entity.',
|
|
);
|
|
}
|
|
const column = prop.fieldNames[0];
|
|
const isCollisionClass = !column.endsWith('_id');
|
|
const stripped = isCollisionClass ? column : column.slice(0, -'_id'.length);
|
|
const newName = isCollisionClass ? `${toCamelCase(stripped)}Ref` : toCamelCase(stripped);
|
|
if (newName === prop.name) continue;
|
|
if (meta.properties[newName]) {
|
|
throw new Error(
|
|
`generate-entities: renaming ${meta.className}.${prop.name} to "${newName}" would collide with an ` +
|
|
`existing property of that name. Column: ${column}.`,
|
|
);
|
|
}
|
|
const oldName = prop.name;
|
|
meta.removeProperty(oldName, false);
|
|
prop.name = newName;
|
|
meta.addProperty(prop);
|
|
renameMap.set(oldName, newName);
|
|
if (isCollisionClass) {
|
|
fixups.push({ className: meta.className, propName: newName, column });
|
|
} else {
|
|
const referencedColumn = prop.referencedColumnNames?.[0];
|
|
const derivedColumn = namingStrategy.joinKeyColumnName(newName, referencedColumn);
|
|
if (derivedColumn !== column) {
|
|
fixups.push({ className: meta.className, propName: newName, column });
|
|
}
|
|
}
|
|
}
|
|
if (renameMap.size > 0) {
|
|
renames.set(meta, renameMap);
|
|
restorePropertyOrder(meta, originalOrder, renameMap);
|
|
}
|
|
}
|
|
|
|
// Fix up every inverse `mappedBy` that pointed at a renamed owning property.
|
|
for (const meta of metadata) {
|
|
for (const prop of meta.relations) {
|
|
if (!prop.mappedBy) continue;
|
|
const targetMeta = metadata.find((m) => m.className === prop.type);
|
|
if (!targetMeta) continue;
|
|
const renameMap = renames.get(targetMeta);
|
|
const renamed = renameMap?.get(prop.mappedBy);
|
|
if (renamed) prop.mappedBy = renamed;
|
|
}
|
|
}
|
|
|
|
return fixups;
|
|
}
|
|
|
|
/**
|
|
* Rule 6 (metadata level): inverse collections and inverse one-to-ones are
|
|
* named snake_case of whatever `bidirectionalRelations` generated (always
|
|
* camelCase, from `namingStrategy.inverseSideName`, a naming-strategy method
|
|
* our `SnakeProps` override does not touch) — matches Phase 0
|
|
* (`day_notes_collection`). Pure case conversion: `dayAccommodationsCollection1`
|
|
* -> `day_accommodations_collection1` (the disambiguating digit the generator
|
|
* itself appends on a naming collision, e.g. two FKs from the same table,
|
|
* survives untouched — the regex only inserts an underscore before an
|
|
* uppercase letter, never before a digit).
|
|
*/
|
|
export function RULE6_renameInverseCollections(metadata: EntityMetadata[]): void {
|
|
for (const meta of metadata) {
|
|
const originalOrder = Object.keys(meta.properties);
|
|
const renameMap = new Map<string, string>();
|
|
for (const prop of [...meta.relations]) {
|
|
if (!isInverseSide(prop)) continue;
|
|
const newName = toSnakeCase(prop.name);
|
|
if (newName === prop.name) continue;
|
|
if (meta.properties[newName]) {
|
|
throw new Error(
|
|
`generate-entities: renaming ${meta.className}.${prop.name} to "${newName}" would collide with an ` +
|
|
'existing property of that name.',
|
|
);
|
|
}
|
|
const oldName = prop.name;
|
|
meta.removeProperty(oldName, false);
|
|
prop.name = newName;
|
|
meta.addProperty(prop);
|
|
renameMap.set(oldName, newName);
|
|
}
|
|
// Same rendering-order fix as RULE5 (see `restorePropertyOrder`'s doc comment) — M7.
|
|
if (renameMap.size > 0) restorePropertyOrder(meta, originalOrder, renameMap);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rule 7 (metadata level): drop `updateRule('no action')` / `deleteRule('no action')`
|
|
* noise. SQLite's `PRAGMA foreign_key_list` reports "NO ACTION" for every FK
|
|
* that never named an ON UPDATE/ON DELETE clause — that's "no information",
|
|
* not a real rule the migration stated, so the generator's raw introspection
|
|
* carries it as noise on nearly every relation. Every other value (cascade,
|
|
* set null, restrict) is real and kept.
|
|
*/
|
|
export function RULE7_dropNoActionRules(metadata: EntityMetadata[]): void {
|
|
for (const meta of metadata) {
|
|
for (const prop of meta.relations) {
|
|
if (prop.updateRule?.toLowerCase() === 'no action') delete prop.updateRule;
|
|
if (prop.deleteRule?.toLowerCase() === 'no action') delete prop.deleteRule;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rule 8 (metadata level): `repository: () => XRepository` + its import.
|
|
* Setting `meta.repositoryClass` is the whole rule — SourceFile's
|
|
* getEntityDeclOptions() (shared by defineEntity/entitySchema output) turns a
|
|
* truthy `repositoryClass` into both the `repository: () => X` option AND an
|
|
* auto-added import for that identifier; the onImport hook below resolves it
|
|
* to `../repositories/X.repository`. Whether the *file* on disk actually gets
|
|
* written (only when missing, never overwritten) is write-mode's job in
|
|
* `writeRepositoriesIfMissing`, not this metadata rule.
|
|
*/
|
|
export function RULE8_bindRepositories(metadata: EntityMetadata[]): void {
|
|
for (const meta of metadata) {
|
|
meta.repositoryClass = `${meta.className}Repository`;
|
|
}
|
|
}
|
|
|
|
/** A scalar property whose literal default the renderer's own heuristic drops or mis-renders — see below. */
|
|
export interface DefaultFixup {
|
|
className: string;
|
|
propName: string;
|
|
/** Valid TS source for the literal, e.g. `0`, `'calendar'`. */
|
|
literal: string;
|
|
}
|
|
|
|
/** A raw SQL default that is a plain literal (number or single-quoted string) rather than an expression like `CURRENT_TIMESTAMP`. */
|
|
const PLAIN_LITERAL_DEFAULT_RE = /^-?\d+(\.\d+)?$|^'.*'$/;
|
|
|
|
/**
|
|
* Rule (metadata level for detection + clearing `defaultRaw`; text level for
|
|
* the render itself — see task-2-report.md "the defaults rule"): a scalar
|
|
* column with a literal `DEFAULT` (`sort_order INTEGER NOT NULL DEFAULT 0`,
|
|
* `year_type TEXT NOT NULL DEFAULT 'calendar'`) is exactly what
|
|
* `DayNotes`/`BudgetCategoryOrder`/`VacayUserSettings` render as
|
|
* `.default(0)`/`.default('calendar')` — plain JS defaults, never
|
|
* `.defaultRaw()`. The stock renderer instead either (a) renders
|
|
* `.defaultRaw('0')` whenever introspection only populated `defaultRaw` and
|
|
* left `prop.default` unset (observed for columns whose declared type the
|
|
* generator leaves `unknown`, e.g. REAL, before Rule 1 retypes them), or (b)
|
|
* for a column where BOTH are populated and consistent, renders *neither*:
|
|
* traced to `SourceFile.js#getCommonDecoratorOptions`'s `.default()` branch,
|
|
* which additionally requires `breakdownOfIType(prop) !== undefined` — true
|
|
* only when the property's type is "ambiguous" in some way, which a boring,
|
|
* unambiguous `INTEGER NOT NULL DEFAULT 0` column never is. Confirmed by
|
|
* generating the real schema and inspecting the metadata mid-hook, not
|
|
* assumed. Since nothing in `GenerateOptions` can force that branch to fire,
|
|
* this is unavoidably a two-part rule: clear the raw default here (so the
|
|
* renderer's OTHER branch, `.defaultRaw()`, never fires either) and record
|
|
* a (className, propName, literal) triple for `injectMissingDefaults` to
|
|
* append `.default(<literal>)` in the rendered text whenever the property's
|
|
* line ends up with neither call. A genuine SQL expression default
|
|
* (`CURRENT_TIMESTAMP`, `datetime('now')`) never matches
|
|
* `PLAIN_LITERAL_DEFAULT_RE` and is left completely alone — that path
|
|
* already renders correctly today (Rule 2's `DbTimestampType` columns).
|
|
*/
|
|
export function RULE_normalizeLiteralDefaults(metadata: EntityMetadata[]): DefaultFixup[] {
|
|
const fixups: DefaultFixup[] = [];
|
|
for (const meta of metadata) {
|
|
for (const prop of Object.values(meta.properties)) {
|
|
if (prop.kind !== undefined && prop.kind !== ReferenceKind.SCALAR) continue;
|
|
// Nullable columns are Task 3's PARITY-005 territory (still `it.skip`d,
|
|
// see task-0-report.md): Phase 0's own hand-written files are not
|
|
// internally consistent about restating a nullable column's DB default
|
|
// in metadata at all (`DayNotes.icon` — nullable, `TEXT DEFAULT '📝'` —
|
|
// has none; `DayNotes.sort_order` — nullable, `REAL DEFAULT 0` — has
|
|
// `.default(0)`). Guessing a rule to match that inconsistency would be
|
|
// exactly the kind of drift Task 0 deferred; only a NOT NULL column
|
|
// is unambiguous — there, the default is not cosmetic, it is the only
|
|
// thing that makes the property optional at all (see this rule's doc
|
|
// comment above).
|
|
if (prop.nullable) continue;
|
|
if (typeof prop.defaultRaw !== 'string') continue;
|
|
if (!PLAIN_LITERAL_DEFAULT_RE.test(prop.defaultRaw)) continue; // an expression — leave to Rule 2's defaultRaw path
|
|
if (prop.default === undefined || prop.default === null) continue; // introspection gave us no typed value — nothing safe to render
|
|
const literal =
|
|
typeof prop.default === 'string' ? `'${prop.default.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` : String(prop.default);
|
|
delete prop.defaultRaw;
|
|
fixups.push({ className: meta.className, propName: prop.name, literal });
|
|
}
|
|
}
|
|
return fixups;
|
|
}
|
|
|
|
export interface RuleFixups {
|
|
joinColumns: JoinColumnFixup[];
|
|
defaults: DefaultFixup[];
|
|
timestamps: TimestampFixup[];
|
|
jsonColumns: JsonColumnFixup[];
|
|
/** Rule 1's + Rule 1b's + Rule 2's retyped scalars together — all three need the same `.columnType()` strip (see `stripRedundantColumnType`). */
|
|
retypedScalars: RetypedScalarFixup[];
|
|
}
|
|
|
|
/** Every rule above, composed into the single hook MikroORM calls. Order matters (see comments). */
|
|
export function applyRules(metadata: EntityMetadata[], _platform: Platform): RuleFixups {
|
|
const retypedByRule1 = RULE1_fixUnknownScalarTypes(metadata);
|
|
const jsonColumns = RULE1b_markJsonColumns(metadata);
|
|
// Unlike Rule 1's/Rule 1b's retypes, a boolean column's own declaration
|
|
// matches its raw INTEGER column type cleanly on SQLite (BooleanType's
|
|
// getColumnType() returns the same spelling needsExplicitColumnType()
|
|
// compares against) — confirmed by generating the real schema: no stray
|
|
// `.columnType()` appears, so RULE1c's fixups do NOT belong in
|
|
// `retypedScalars` (stripRedundantColumnType's "found no .columnType() to
|
|
// strip" throw catches this immediately if that ever stops being true).
|
|
RULE1c_markBooleanColumns(metadata);
|
|
const timestamps = RULE2_datetimeToDbTimestampType(metadata);
|
|
RULE3_integerPkAutoincrement(metadata);
|
|
RULE4_hideAllRelations(metadata);
|
|
const joinColumns = RULE5_renameOwningRelations(metadata);
|
|
RULE6_renameInverseCollections(metadata);
|
|
RULE7_dropNoActionRules(metadata);
|
|
RULE8_bindRepositories(metadata);
|
|
const defaults = RULE_normalizeLiteralDefaults(metadata);
|
|
return { joinColumns, defaults, timestamps, jsonColumns, retypedScalars: [...retypedByRule1, ...timestamps, ...jsonColumns] };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// onImport — resolves the two identifiers the rules above add to a file's
|
|
// entityImports that are NOT sibling `*.entity.ts` classes: `DbTimestampType`
|
|
// (rule 2) and any `<X>Repository` (rule 8). Everything else (another entity
|
|
// class) falls through to the generator's own default resolution.
|
|
// ---------------------------------------------------------------------------
|
|
export const onImport: ImportsResolver = (alias) => {
|
|
if (alias === 'DbTimestampType') return { path: '../types', name: 'DbTimestampType' };
|
|
if (alias.endsWith('Repository')) {
|
|
const entityName = alias.slice(0, -'Repository'.length);
|
|
return { path: `../repositories/${entityName}.repository`, name: alias };
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Text-level passes — only for what the metadata cannot carry into the
|
|
// rendered `defineEntity` call (see the doc comment at the top of this file
|
|
// and task-2-report.md for why each of these specifically cannot be a rule
|
|
// on the metadata).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
/**
|
|
* The property's line inside `properties: {...}` — i.e. the metadata builder
|
|
* call (`propName: p.integer()...` or `propName: () => p.manyToOne(...)...`),
|
|
* never the class-field declaration above it. The two can share an identical
|
|
* `propName:` prefix (an `& Opt`-style field like `sort_order: number & Opt = 0;`
|
|
* has no `!`/`?`/parens between the name and the colon, same as the metadata
|
|
* line `sort_order: p.integer(),` — anchoring on `propName:.*` alone matches
|
|
* whichever comes first in the file, which is always the class field).
|
|
*
|
|
* The leading padding is `[ \t]*`, not `\s*` — `\s` matches a newline too, so
|
|
* on a property preceded by a blank line (common: one blank line separates
|
|
* the class body from the `properties: {...}` block, or separates unrelated
|
|
* groups within it) `\s*^` could swallow that blank line into the captured
|
|
* "line", handing every caller a string with a leading `\n` embedded in it.
|
|
* `appendCallBeforeTrailingComma`'s own `^…$` anchors (Fix round 1, I3) are
|
|
* not `/m`-flagged and silently fail to match a string shaped like that —
|
|
* caught by `TEXT-DEFAULTS-001`, which has exactly that blank line between
|
|
* the class field and the metadata line.
|
|
*/
|
|
function findPropertyBuilderLine(source: string, propName: string): RegExpExecArray | null {
|
|
const lineRe = new RegExp(`^([ \\t]*${escapeRegExp(propName)}: (?:\\(\\) => )?p\\..*)$`, 'm');
|
|
return lineRe.exec(source);
|
|
}
|
|
|
|
/** The class-field declaration line for a scalar property — the mirror image of `findPropertyBuilderLine` (same `[ \t]*` reasoning). */
|
|
function findClassFieldLine(source: string, propName: string): RegExpExecArray | null {
|
|
const lineRe = new RegExp(`^([ \\t]*${escapeRegExp(propName)}[?!]?: (?!\\(\\) => p\\.|p\\.).*)$`, 'm');
|
|
return lineRe.exec(source);
|
|
}
|
|
|
|
/**
|
|
* `DbTimestampType.runtimeType` is `'string'` (it stores the SQLite wire text
|
|
* verbatim, never a JS `Date` — see `src/db/types/db-timestamp.type.ts`), but
|
|
* the generator has no way to know that: `prop.type` is just the plain string
|
|
* `'DbTimestampType'` (Rule 2), unrecognised by `@mikro-orm/core`'s `types`
|
|
* registry, and `SourceFile.js#breakdownOfIType`'s fallback for an
|
|
* unrecognised declared type re-derives the class field's TS type from the
|
|
* COLUMN's own mapped type instead (`DATETIME` -> `DateTimeType` -> `'Date'`)
|
|
* — a fallback that, unlike the metadata builder's `p.type(DbTimestampType)`
|
|
* output, never consults `prop.runtimeType` at all. Confirmed by generating
|
|
* the real schema (`DayNotes.created_at`, `Trips.created_at`/`updated_at`)
|
|
* and inspecting the rendered class field, not assumed; there is no metadata
|
|
* option that changes this fallback's source. Rewriting `Date` to `string` on
|
|
* exactly the class-field line for each Rule-2-retyped property is therefore
|
|
* a text pass, not a rule.
|
|
*/
|
|
export function fixDbTimestampClassFieldTypes(source: string, fixups: readonly TimestampFixup[]): string {
|
|
let result = source;
|
|
for (const { className, propName } of fixups) {
|
|
const match = findClassFieldLine(result, propName);
|
|
if (!match) {
|
|
throw new Error(`generate-entities: fixDbTimestampClassFieldTypes could not find the class field line for "${className}.${propName}".`);
|
|
}
|
|
const line = match[1];
|
|
const newLine = line.replace(/\bDate\b/g, 'string');
|
|
if (newLine === line) {
|
|
throw new Error(
|
|
`generate-entities: fixDbTimestampClassFieldTypes found the class field for "${className}.${propName}" but it had no "Date" to rewrite — check the renderer shape.`,
|
|
);
|
|
}
|
|
result = result.slice(0, match.index) + newLine + result.slice(match.index + line.length);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Every property Rule 1 or Rule 2 retyped (`p.unknown()` -> `p.double()`,
|
|
* `p.datetime()` -> `p.type(DbTimestampType)`) picks up a spurious explicit
|
|
* `.columnType(<raw SQL type>)` from `SourceFile.js#needsExplicitColumnType()`:
|
|
* that check compares the introspected raw column spelling (`'REAL'`,
|
|
* `'DATETIME'`, verbatim from the migration) against the abstract type's OWN
|
|
* declaration string (`DoubleType`/base `Platform#getDoubleDeclarationSQL()`
|
|
* literally returns `'double'`; `DbTimestampType` has no declaration of its
|
|
* own so the comparison falls back the same way) — they can never textually
|
|
* match, so the "is this ambiguous" check is always true for exactly these
|
|
* two retyped families, even though `double`/`DbTimestampType` already
|
|
* correctly mean `REAL`/`DATETIME` on SQLite. Confirmed by generating the
|
|
* real schema (`DayNotes.sort_order`/`.created_at`) and reading
|
|
* `getColumnType()`, not assumed. No `GenerateOptions` flag skips this check,
|
|
* so the explicit call is stripped here rather than never emitted.
|
|
*/
|
|
export function stripRedundantColumnType(source: string, fixups: readonly RetypedScalarFixup[]): string {
|
|
let result = source;
|
|
for (const { className, propName } of fixups) {
|
|
const match = findPropertyBuilderLine(result, propName);
|
|
if (!match) {
|
|
throw new Error(`generate-entities: stripRedundantColumnType could not find the property line for "${className}.${propName}".`);
|
|
}
|
|
const line = match[1];
|
|
const newLine = line.replace(/\.columnType\('[^']*'\)/, '');
|
|
if (newLine === line) {
|
|
throw new Error(
|
|
`generate-entities: stripRedundantColumnType found the property line for "${className}.${propName}" but it had no .columnType() to strip — check the renderer shape.`,
|
|
);
|
|
}
|
|
result = result.slice(0, match.index) + newLine + result.slice(match.index + line.length);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Inserts `injected` right before a builder line's trailing comma, tolerating
|
|
* a trailing line comment after it (`.ref(), // pins country`) — the plain
|
|
* `line.replace(/,\s*$/, …)` this replaced only matched a comma at the very
|
|
* end of the line, so a trailing comment made the append silently do nothing
|
|
* (Fix round 1, task-2-review.md I3, adversarial fixture A6). Greedy `.*`
|
|
* anchors on the LAST comma on the line (the statement terminator), not the
|
|
* first one a string-literal default might contain earlier in the chain.
|
|
* Returns `undefined` if the line has no trailing comma to anchor on at all.
|
|
*/
|
|
function appendCallBeforeTrailingComma(line: string, injected: string): string | undefined {
|
|
const match = /^(.*)(,)(\s*(?:\/\/.*)?)$/.exec(line);
|
|
if (!match) return undefined;
|
|
return `${match[1]}${injected}${match[2]}${match[3]}`;
|
|
}
|
|
|
|
/**
|
|
* For every collision-class relation Rule 5 renamed, turns the renderer's own
|
|
* (inert, for a relation) `.name('<column>')` override into the
|
|
* `.joinColumn('<column>')` that actually pins the FK column — or appends
|
|
* `.joinColumn(...)` if the renderer decided no override was needed at all
|
|
* (belt and braces; not observed in practice, but the rule must not silently
|
|
* do nothing if the renderer's heuristic ever changes). Every branch either
|
|
* makes an edit or throws (Fix round 1, task-2-review.md I3) — a fixup was
|
|
* recorded specifically because this relation's FK column pin is not
|
|
* otherwise correct, so a no-op here would ship a wrong/missing FK silently.
|
|
*/
|
|
export function injectJoinColumns(source: string, fixups: readonly JoinColumnFixup[]): string {
|
|
let result = source;
|
|
for (const { className, propName, column } of fixups) {
|
|
const quotedColumn = `'${column}'`;
|
|
const match = findPropertyBuilderLine(result, propName);
|
|
if (!match) {
|
|
throw new Error(
|
|
`generate-entities: injectJoinColumns could not find the property line for "${className}.${propName}" — the recorded joinColumn fixup produced no edit.`,
|
|
);
|
|
}
|
|
const line = match[1];
|
|
const nameCallRe = new RegExp(`\\.name\\(${escapeRegExp(quotedColumn)}\\)`);
|
|
let newLine: string;
|
|
if (nameCallRe.test(line)) {
|
|
newLine = line.replace(nameCallRe, `.joinColumn(${quotedColumn})`);
|
|
} else if (line.includes('.joinColumn(')) {
|
|
newLine = line; // already explicit — legitimately nothing to do, not a no-op bug
|
|
} else {
|
|
const appended = appendCallBeforeTrailingComma(line, `.joinColumn(${quotedColumn})`);
|
|
if (appended === undefined) {
|
|
throw new Error(
|
|
`generate-entities: injectJoinColumns found the property line for "${className}.${propName}" but could not append .joinColumn(${quotedColumn}) to it — check the renderer shape.`,
|
|
);
|
|
}
|
|
newLine = appended;
|
|
}
|
|
result = result.slice(0, match.index) + newLine + result.slice(match.index + line.length);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* The text half of `RULE_normalizeLiteralDefaults`: appends `.default(<literal>)`
|
|
* to a property's line when it has neither `.default(` nor `.defaultRaw(` —
|
|
* the exact gap that rule's doc comment traces.
|
|
*/
|
|
export function injectMissingDefaults(source: string, fixups: readonly DefaultFixup[]): string {
|
|
let result = source;
|
|
for (const { className, propName, literal } of fixups) {
|
|
const match = findPropertyBuilderLine(result, propName);
|
|
if (!match) {
|
|
throw new Error(`generate-entities: injectMissingDefaults could not find the property line for "${className}.${propName}".`);
|
|
}
|
|
const line = match[1];
|
|
if (line.includes('.default(') || line.includes('.defaultRaw(')) continue; // already renders one — legitimately nothing to do
|
|
const appended = appendCallBeforeTrailingComma(line, `.default(${literal})`);
|
|
if (appended === undefined) {
|
|
throw new Error(
|
|
`generate-entities: injectMissingDefaults found the property line for "${className}.${propName}" but could not append .default(${literal}) to it — check the renderer shape.`,
|
|
);
|
|
}
|
|
result = result.slice(0, match.index) + appended + result.slice(match.index + line.length);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* `p.json()` has no metadata-level channel for a TypeScript generic type
|
|
* parameter (see `JSON_COLUMNS`/`JSON_INTERFACES` above) — this inserts the
|
|
* recorded type name after each known JSON column's bare `p.json(` call, on
|
|
* exactly that property's line (Fix round 1, task-2-review.md M4/I3: the
|
|
* previous blanket `source.replace(/\bp\.json\(\)/g, …)` also rewrote a
|
|
* `p.json()` mentioned inside a comment — adversarial fixture A2). Already
|
|
* being generic (`p.json<Something>()`) is a legitimate no-op, not a bug;
|
|
* anything else finding no bare call throws.
|
|
*/
|
|
export function injectJsonTypeParams(source: string, fixups: readonly JsonColumnFixup[]): string {
|
|
let result = source;
|
|
for (const { className, propName, typeName } of fixups) {
|
|
const match = findPropertyBuilderLine(result, propName);
|
|
if (!match) {
|
|
throw new Error(`generate-entities: injectJsonTypeParams could not find the property line for "${className}.${propName}".`);
|
|
}
|
|
const line = match[1];
|
|
if (/p\.json<[^>]*>\(/.test(line)) continue; // already explicitly typed
|
|
const newLine = line.replace(/\bp\.json\(\)/, `p.json<${typeName}>()`);
|
|
if (newLine === line) {
|
|
throw new Error(
|
|
`generate-entities: injectJsonTypeParams found the property line for "${className}.${propName}" but it had no bare p.json() call to type — check the renderer shape.`,
|
|
);
|
|
}
|
|
result = result.slice(0, match.index) + newLine + result.slice(match.index + line.length);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* `JsonType#runtimeType` (`@mikro-orm/core/types/JsonType.js`) is a hardcoded
|
|
* getter that always returns `'any'`, never consulting the property at all —
|
|
* so even with `prop.runtimeType` correctly set to the interface name (Rule
|
|
* 1b), `SourceFile.js#breakdownOfIType` still sees `prop.runtimeType
|
|
* ('AddonConfig') !== rawType ('any')` and wraps the class field in
|
|
* `IType<AddonConfig, any>` (confirmed by generating the real schema and
|
|
* reading the rendered field, not assumed — there is no metadata option that
|
|
* changes this). `any` in generated output is not acceptable (Fix round 1,
|
|
* task-2-review.md C3), so this text pass simplifies the class field's
|
|
* `IType<TypeName, any>` down to the plain `TypeName`, the same shape a
|
|
* hand-written entity uses. A field already rendering the plain type name is
|
|
* a legitimate no-op; anything else finding neither shape throws.
|
|
*/
|
|
export function fixJsonClassFieldTypes(source: string, fixups: readonly JsonColumnFixup[]): string {
|
|
let result = source;
|
|
for (const { className, propName, typeName } of fixups) {
|
|
const match = findClassFieldLine(result, propName);
|
|
if (!match) {
|
|
throw new Error(`generate-entities: fixJsonClassFieldTypes could not find the class field line for "${className}.${propName}".`);
|
|
}
|
|
const line = match[1];
|
|
const iTypeRe = new RegExp(`IType<${escapeRegExp(typeName)}(?:\\s*,\\s*any)?>`);
|
|
const newLine = line.replace(iTypeRe, typeName);
|
|
if (newLine === line) {
|
|
if (line.includes(typeName) && !line.includes('any')) continue; // already the plain type — nothing to fix
|
|
throw new Error(
|
|
`generate-entities: fixJsonClassFieldTypes found the class field for "${className}.${propName}" but no IType<${typeName}, any> to simplify — check the renderer shape.`,
|
|
);
|
|
}
|
|
result = result.slice(0, match.index) + newLine + result.slice(match.index + line.length);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Inserts the `JSON_INTERFACES` declaration for every distinct type name this
|
|
* file's JSON columns reference, right before `export class <Name>` — the
|
|
* same position a hand-written file like the current `Addons.entity.ts` uses.
|
|
* Throws if `JSON_COLUMNS` ever references a type name with no matching
|
|
* `JSON_INTERFACES` entry (a maintenance gap, never a schema fact to guess
|
|
* at) or if the file has no `export class` line to anchor on.
|
|
*
|
|
* Also strips the bogus self-import the renderer adds for the type name:
|
|
* `breakdownOfIType`'s import-collection step (`SourceFile.js`) treats any
|
|
* non-primitive `prop.runtimeType` as if it names a sibling entity class and
|
|
* adds it to `entityImports` — `onImport` has no "this identifier needs no
|
|
* import, it is declared right here" signal, so the generator's default
|
|
* resolution guesses `import { AddonConfig } from './AddonConfig.entity';`,
|
|
* a file that does not exist. Confirmed by generating the real schema, not
|
|
* assumed.
|
|
*/
|
|
export function injectJsonInterfaces(source: string, fixups: readonly JsonColumnFixup[]): string {
|
|
if (fixups.length === 0) return source;
|
|
const typeNames = [...new Set(fixups.map((f) => f.typeName))];
|
|
const blocks: string[] = [];
|
|
let result = source;
|
|
for (const typeName of typeNames) {
|
|
const decl = JSON_INTERFACES[typeName];
|
|
if (!decl) {
|
|
throw new Error(`generate-entities: JSON_COLUMNS references type "${typeName}" but JSON_INTERFACES has no declaration for it.`);
|
|
}
|
|
blocks.push(decl);
|
|
const escaped = escapeRegExp(typeName);
|
|
const bogusImportRe = new RegExp(`^import \\{ ${escaped} \\} from '\\./${escaped}\\.entity';\\n`, 'm');
|
|
result = result.replace(bogusImportRe, '');
|
|
}
|
|
const classRe = /^export class \w+ /m;
|
|
const match = classRe.exec(result);
|
|
if (!match) {
|
|
throw new Error('generate-entities: injectJsonInterfaces could not find "export class" to insert the interface declaration before.');
|
|
}
|
|
return result.slice(0, match.index) + blocks.join('\n') + '\n' + result.slice(match.index);
|
|
}
|
|
|
|
/**
|
|
* Removes `identifier` from the `@mikro-orm/core` named import line if
|
|
* nothing after that line still references it as a bare word — shared by
|
|
* `stripHiddenTypeAnnotation` (`Hidden`) and `fixJsonClassFieldTypes`'s
|
|
* caller (`IType`), both of which can make an import genuinely unused.
|
|
*/
|
|
function removeUnusedCoreImportIfUnused(source: string, identifier: string): string {
|
|
const importLineRe = /^import \{[^}]*\} from '@mikro-orm\/core';$/m;
|
|
const coreImportLine = importLineRe.exec(source)?.[0];
|
|
if (!coreImportLine) return source;
|
|
const bodyAfterImports = source.slice(source.indexOf(coreImportLine) + coreImportLine.length);
|
|
if (new RegExp(`\\b${escapeRegExp(identifier)}\\b`).test(bodyAfterImports)) return source; // still used elsewhere
|
|
const escaped = escapeRegExp(identifier);
|
|
const cleaned = coreImportLine.replace(new RegExp(`type ${escaped}, `), '').replace(new RegExp(`, type ${escaped}\\b`), '');
|
|
return source.replace(coreImportLine, cleaned);
|
|
}
|
|
|
|
/**
|
|
* Strips the class-field `& Hidden` TypeScript annotation the renderer adds,
|
|
* unconditionally, to every field whose property is `.hidden()`
|
|
* (`SourceFile.js#getPropertyDefinition`: `if (prop.hidden) { hiddenType += ' & Hidden'; }`
|
|
* — there is no metadata option that suppresses it; it does not read a
|
|
* config flag, it reads `prop.hidden` directly). Phase 0's hand-written files
|
|
* never use this marker on any of their many hidden relations/collections —
|
|
* the runtime source of truth is `defineEntity`'s own `.hidden()` call kept
|
|
* on the property builder (rule 4), which this text pass never touches; the
|
|
* class field is compile-time-only TypeScript, so dropping the redundant
|
|
* annotation changes nothing observable.
|
|
*
|
|
* A `Collection<T>` field only gets an explicit `: Collection<T>` type
|
|
* annotation in the first place *because* of `& Hidden` (the same ternary);
|
|
* once the marker is gone the whole annotation is dropped too, reverting to
|
|
* plain `field = new Collection<T>(this);` — Phase 0's actual style.
|
|
*
|
|
* Scoped to the CLASS BODY only — everything from the start of the file up
|
|
* to (not including) `export const <Name>Schema = defineEntity(` — never the
|
|
* `properties: {...}` block that follows it. Fix round 1 (task-2-review.md
|
|
* M3/I3): the previous whole-file `/ & Hidden/g` also matched a string
|
|
* literal default containing that exact text (adversarial fixture A1,
|
|
* `note: p.text().default('a & Hidden b')`), corrupting the default's value;
|
|
* that text only ever appears in the schema block, which this scoping never
|
|
* touches. The `Collection<T>` pattern is also anchored to a single line
|
|
* (`[^=\n]*`, not `[^=]*`) so it cannot span two adjacent field declarations
|
|
* where the first has no initialiser (fixture A3) — unreachable against
|
|
* today's renderer (every hidden `Collection` field DOES get an initialiser,
|
|
* see rule 4's doc comment), but no longer exploitable either way.
|
|
*/
|
|
export function stripHiddenTypeAnnotation(source: string): string {
|
|
const boundaryRe = /^export const \w+Schema = defineEntity\(/m;
|
|
const boundaryMatch = boundaryRe.exec(source);
|
|
const splitIndex = boundaryMatch ? boundaryMatch.index : source.length;
|
|
const classBody = source.slice(0, splitIndex);
|
|
const rest = source.slice(splitIndex);
|
|
|
|
const strippedClassBody = classBody
|
|
// Collection<T> & Hidden = new Collection<T>(this); -> = new Collection<T>(this);
|
|
.replace(/: Collection<[^=\n]*> & Hidden = /g, ' = ')
|
|
// (Ref<X> | null) & Hidden -> Ref<X> | null (parenthesised nullable ref)
|
|
.replace(/\(([^()\n]*)\) & Hidden/g, '$1')
|
|
// trip!: Ref<Trips> & Hidden; -> trip!: Ref<Trips>; (everything else, one class-field line at a time)
|
|
.replace(/^(\s*\S[^\n]*?) & Hidden/gm, '$1');
|
|
|
|
if (/&\s*Hidden\b/.test(strippedClassBody)) {
|
|
throw new Error('generate-entities: stripHiddenTypeAnnotation left an "& Hidden" marker unstripped in the class body — check the renderer shape.');
|
|
}
|
|
|
|
return removeUnusedCoreImportIfUnused(strippedClassBody + rest, 'Hidden');
|
|
}
|
|
|
|
/** Every text pass, applied in order. */
|
|
export function applyTextPasses(source: string, fixups: RuleFixups): string {
|
|
let result = injectJoinColumns(source, fixups.joinColumns);
|
|
result = injectMissingDefaults(result, fixups.defaults);
|
|
result = fixDbTimestampClassFieldTypes(result, fixups.timestamps);
|
|
result = stripRedundantColumnType(result, fixups.retypedScalars);
|
|
result = injectJsonTypeParams(result, fixups.jsonColumns);
|
|
result = fixJsonClassFieldTypes(result, fixups.jsonColumns);
|
|
result = injectJsonInterfaces(result, fixups.jsonColumns);
|
|
result = removeUnusedCoreImportIfUnused(result, 'IType');
|
|
result = stripHiddenTypeAnnotation(result);
|
|
return result;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Temp database — a throwaway file, migrated the same way buildApp() does
|
|
// (same Migrator extension, same migrations path/pathTs), never data/travel.db.
|
|
//
|
|
// This deliberately does NOT import src/db/orm.ts / src/db/database.ts /
|
|
// src/mikro-orm.config.ts: database.ts opens resolveDbPath() (real
|
|
// data/travel.db, absent an env override) as a side effect of being imported
|
|
// at all, and orm.ts/mikro-orm.config.ts both pull that in transitively. A
|
|
// self-contained MikroORM.init() against our own temp path has no such
|
|
// side effect and needs nothing from those modules.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function tempDbPath(): string {
|
|
return path.join(os.tmpdir(), `trek-gen-entities-${crypto.randomUUID()}.db`);
|
|
}
|
|
|
|
async function migrateTempDb(dbPath: string): Promise<void> {
|
|
const orm = await SqliteMikroORM.init({
|
|
entities: [],
|
|
discovery: { warnWhenNoEntities: false },
|
|
dbName: dbPath,
|
|
extensions: [Migrator],
|
|
// Anchored to SERVER_ROOT (__dirname-based), not process.cwd() — Fix
|
|
// round 1, task-2-review.md I5: run from the repo root instead of
|
|
// server/, the old relative paths silently resolved to nothing and
|
|
// `generator.generate()` produced zero files with exit 0. `assertFilesGenerated`
|
|
// below is the second half of the fix: even if some future refactor
|
|
// reintroduces a cwd-relative path, a zero-file run throws instead of
|
|
// looking like a quiet success.
|
|
migrations: { path: path.join(SERVER_ROOT, 'dist/db/migrations'), pathTs: path.join(SERVER_ROOT, 'src/db/migrations'), snapshot: false },
|
|
});
|
|
try {
|
|
const migrator = orm.config.getExtension<Migrator>('@mikro-orm/migrator');
|
|
await migrator.up();
|
|
} finally {
|
|
await orm.close(true);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Generation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface GenerateResult {
|
|
/** `X.entity.ts` -> final, post-text-pass file content. */
|
|
files: Map<string, string>;
|
|
}
|
|
|
|
/**
|
|
* Throws if `files` is empty — a zero-entity run is never a legitimate
|
|
* result of this wrapper (the migrated schema always has tables), only a
|
|
* silent path-resolution failure (Fix round 1, task-2-review.md I5:
|
|
* `node --import tsx server/scripts/generate-entities.ts --out …` run from
|
|
* the repo root instead of `server/` printed `dumped 0 files`, exit 0,
|
|
* before the migrations path was anchored to `SERVER_ROOT`). Exported so it
|
|
* can be pinned by a fixture test independent of a real schema/cwd.
|
|
*/
|
|
export function assertFilesGenerated(files: ReadonlyMap<string, string>): void {
|
|
if (files.size === 0) {
|
|
throw new Error(
|
|
'generate-entities: generated zero entity files — check the migrations path/cwd (should be anchored to SERVER_ROOT) and that the schema actually has tables.',
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generates every entity from the migrated schema into memory (nothing is
|
|
* written to disk by this function alone — see `writeEntities`/`main` below
|
|
* for the on-disk half `--write` uses).
|
|
*/
|
|
export async function generateEntities(): Promise<GenerateResult> {
|
|
const dbPath = tempDbPath();
|
|
try {
|
|
await migrateTempDb(dbPath);
|
|
|
|
const orm = await SqliteMikroORM.init({
|
|
entities: [],
|
|
discovery: { warnWhenNoEntities: false },
|
|
dbName: dbPath,
|
|
namingStrategy: SnakeProps,
|
|
extensions: [EntityGenerator],
|
|
});
|
|
try {
|
|
const generator = orm.config.getExtension<EntityGenerator>('@mikro-orm/entity-generator');
|
|
let fixups: RuleFixups = { joinColumns: [], defaults: [], timestamps: [], jsonColumns: [], retypedScalars: [] };
|
|
const rawFiles = await generator.generate({
|
|
entityDefinition: 'defineEntity',
|
|
scalarPropertiesForRelations: 'always',
|
|
identifiedReferences: true,
|
|
bidirectionalRelations: true,
|
|
forceUndefined: false,
|
|
undefinedDefaults: true,
|
|
skipTables: ['mikro_orm_migrations'],
|
|
fileName: (className: string) => `${className}.entity`,
|
|
onImport,
|
|
onProcessedMetadata: (metadata, platform) => {
|
|
fixups = applyRules(metadata, platform);
|
|
},
|
|
});
|
|
|
|
const files = new Map<string, string>();
|
|
for (const raw of rawFiles) {
|
|
const classMatch = /\nexport class (\w+) /.exec(raw) ?? /^export class (\w+) /.exec(raw);
|
|
if (!classMatch) continue; // a routine/enum file, no class of its own — nothing to text-pass
|
|
const className = classMatch[1];
|
|
const relevantFixups: RuleFixups = {
|
|
joinColumns: fixups.joinColumns.filter((f) => f.className === className),
|
|
defaults: fixups.defaults.filter((f) => f.className === className),
|
|
timestamps: fixups.timestamps.filter((f) => f.className === className),
|
|
jsonColumns: fixups.jsonColumns.filter((f) => f.className === className),
|
|
retypedScalars: fixups.retypedScalars.filter((f) => f.className === className),
|
|
};
|
|
files.set(`${className}.entity.ts`, applyTextPasses(raw, relevantFixups));
|
|
}
|
|
assertFilesGenerated(files);
|
|
return { files };
|
|
} finally {
|
|
await orm.close(true);
|
|
}
|
|
} finally {
|
|
fs.rmSync(dbPath, { force: true });
|
|
}
|
|
}
|
|
|
|
/** Writes `files` into `dir` (creating it if needed). Used for the scratch dump and by `--write`. */
|
|
export function dumpEntities(dir: string, files: ReadonlyMap<string, string>): void {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
for (const [fileName, content] of files) {
|
|
fs.writeFileSync(path.join(dir, fileName), content);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* For every generated entity, writes `src/db/repositories/<X>.repository.ts`
|
|
* if (and only if) it does not already exist — Rule 8's file-system half.
|
|
* Returns the list of newly-created files.
|
|
*/
|
|
export function writeRepositoriesIfMissing(repositoriesDir: string, files: ReadonlyMap<string, string>): string[] {
|
|
const created: string[] = [];
|
|
fs.mkdirSync(repositoriesDir, { recursive: true });
|
|
for (const fileName of files.keys()) {
|
|
const className = fileName.replace(/\.entity\.ts$/, '');
|
|
const repoFile = path.join(repositoriesDir, `${className}.repository.ts`);
|
|
if (fs.existsSync(repoFile)) continue;
|
|
const content =
|
|
`import type { ${className} } from '../entities/${className}.entity';\n` +
|
|
`import { EntityRepository } from '@mikro-orm/sql';\n\n` +
|
|
`export class ${className}Repository extends EntityRepository<${className}> {}\n`;
|
|
fs.writeFileSync(repoFile, content);
|
|
created.push(repoFile);
|
|
}
|
|
return created;
|
|
}
|
|
|
|
/** Regenerates `src/db/entities/index.ts` from the directory, sorted, exporting `ALL_ENTITIES`. */
|
|
export function regenerateEntitiesIndex(entitiesDir: string): string {
|
|
const files = fs
|
|
.readdirSync(entitiesDir)
|
|
.filter((f) => f.endsWith('.entity.ts'))
|
|
.sort();
|
|
const classNames = files.map((f) => f.replace(/\.entity\.ts$/, ''));
|
|
const imports = classNames.map((name) => `import { ${name}Schema } from './${name}.entity';`).join('\n');
|
|
const exportList = classNames.map((name) => ` ${name}Schema,`).join('\n');
|
|
return (
|
|
`import type { EntitySchema } from '@mikro-orm/core';\n${imports}\n\n` +
|
|
`export const ALL_ENTITIES: readonly EntitySchema[] = [\n${exportList}\n];\n`
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function main(): Promise<void> {
|
|
const args = process.argv.slice(2);
|
|
const write = args.includes('--write');
|
|
const outIndex = args.indexOf('--out');
|
|
const outDir = outIndex >= 0 ? args[outIndex + 1] : undefined;
|
|
|
|
const { files } = await generateEntities();
|
|
|
|
if (write) {
|
|
dumpEntities(ENTITIES_DIR, files);
|
|
const created = writeRepositoriesIfMissing(REPOSITORIES_DIR, files);
|
|
fs.writeFileSync(path.join(ENTITIES_DIR, 'index.ts'), regenerateEntitiesIndex(ENTITIES_DIR));
|
|
console.log(`[generate-entities] wrote ${files.size} entities, ${created.length} new repositories, index.ts`);
|
|
return;
|
|
}
|
|
|
|
const target = outDir ?? path.join(os.tmpdir(), `trek-generated-entities-${crypto.randomUUID()}`);
|
|
dumpEntities(target, files);
|
|
console.log(`[generate-entities] dumped ${files.size} files to ${target}`);
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((err: unknown) => {
|
|
console.error('[generate-entities] failed:', err);
|
|
process.exit(1);
|
|
});
|
|
}
|