mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
628 lines
30 KiB
TypeScript
628 lines
30 KiB
TypeScript
import { ReferenceKind, type EntityMetadata, type EntityProperty } from '@mikro-orm/core';
|
|
import type Database from 'better-sqlite3';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { ALL_ENTITIES } from '../../../src/db/entities';
|
|
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
|
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
|
|
|
/**
|
|
* The blocking entry gate for Plan 2 (the entity rewrite, see
|
|
* docs/superpowers/plans/2026-09-22-orm-phase2-entity-rewrite.md, Task 0):
|
|
* every entity's MikroORM metadata must describe exactly the schema the
|
|
* migrations built, column for column. `orm.schema.getUpdateSchemaSQL()` was
|
|
* tried first and rejected — on SQLite it produces 883 statements of pure
|
|
* naming-convention noise (index names, `__temp_alter` table rebuilds), which
|
|
* the MikroORM docs (7.2, schema comparator) say not to use as a test. This
|
|
* compares `PRAGMA table_info(<table>)` against `getMetadata().getAll()`
|
|
* column by column instead, which is precise: on today's entities it reports
|
|
* exactly 24 tables with issues, of which only 4 are real drifts (fixed
|
|
* alongside this test) and the rest is type-name spelling MikroORM's SQLite
|
|
* platform leaves abstract (`double`, `json` — see `normaliseColumnType`
|
|
* below) plus 5 tables Plan 2 Task 3 still has to add entities for.
|
|
*/
|
|
|
|
const testDb = createSnapshotTestDb();
|
|
let t: TestOrm;
|
|
|
|
beforeAll(async () => {
|
|
t = await createTestOrm(testDb);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await t.close();
|
|
testDb.close();
|
|
});
|
|
|
|
/**
|
|
* Tables with no entity yet. Plan 2 Task 0 named the 5 document-sync tables
|
|
* here; Task 3 added their entities (`DocumentProviders`,
|
|
* `DocumentProviderFields`, `DocumentConnections`, `TripDocumentLinks`,
|
|
* `DocumentSyncItems`), so the set is empty. PARITY-007's "every db table
|
|
* has an entity" assertion reports anything listed here as expected missing
|
|
* instead of failing on it; PARITY-008 fails the moment a listed table
|
|
* actually gains an entity, so a future author has to empty this set again
|
|
* rather than silently leaving a stale entry behind.
|
|
*/
|
|
const ENTITIES_STILL_MISSING: string[] = [];
|
|
|
|
interface DbColumn {
|
|
cid: number;
|
|
name: string;
|
|
type: string;
|
|
notnull: number;
|
|
dflt_value: string | null;
|
|
pk: number;
|
|
}
|
|
|
|
/** Tables SQLite itself creates (`sqlite_sequence` for AUTOINCREMENT bookkeeping) — never a domain table, never an entity. */
|
|
function dbTables(db: Database.Database): Set<string> {
|
|
const rows = db
|
|
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`)
|
|
.all() as { name: string }[];
|
|
return new Set(rows.map((r) => r.name));
|
|
}
|
|
|
|
function tableInfo(db: Database.Database, table: string): DbColumn[] {
|
|
return db.prepare(`PRAGMA table_info("${table}")`).all() as DbColumn[];
|
|
}
|
|
|
|
/**
|
|
* Spells a column type the way both sides can agree on. MikroORM's SQLite
|
|
* platform normalises most abstract type names to their real SQLite storage
|
|
* class (`boolean` -> `integer`, `string` -> `text`) but leaves a few as the
|
|
* ORM's own abstract name because it never overrides the base `Platform`
|
|
* declaration for them (`getDoubleDeclarationSQL`, `getJsonDeclarationSQL` —
|
|
* verified against `node_modules/@mikro-orm/sql/dialects/sqlite/SqlitePlatform.js`
|
|
* and `node_modules/@mikro-orm/core/platforms/Platform.js`), so those need an
|
|
* explicit map. `varchar` only ever appears on the db side (the hand-written
|
|
* migration SQL for `migrations.name`); the entity's `p.string()` already
|
|
* reports `text` on SQLite.
|
|
*/
|
|
function normaliseColumnType(type: string): string {
|
|
const stripped = type.trim().toLowerCase().replace(/\(\d+(,\s*\d+)?\)/, '');
|
|
if (stripped === 'double') return 'real';
|
|
if (stripped === 'varchar' || stripped === 'json') return 'text';
|
|
return stripped;
|
|
}
|
|
|
|
/**
|
|
* `datetime` and `text` are accepted as equal in either direction: a
|
|
* `DbTimestampType` column reports `datetime` (SQLite's
|
|
* `getDateTimeTypeDeclarationSQL`), and every migration that created a
|
|
* timestamp column also declared it `DATETIME`, so in practice both sides
|
|
* already say `datetime` — but a future entity or migration is free to spell
|
|
* a timestamp column `TEXT` (SQLite gives it the same storage class either
|
|
* way), and that must not be a drift.
|
|
*/
|
|
function columnTypesEqual(entityType: string, dbType: string): boolean {
|
|
const a = normaliseColumnType(entityType);
|
|
const b = normaliseColumnType(dbType);
|
|
if (a === b) return true;
|
|
const datetimeTextPair = new Set([a, b]);
|
|
return datetimeTextPair.has('datetime') && datetimeTextPair.has('text');
|
|
}
|
|
|
|
/** A property this test holds against the migrated schema: it owns a real, persisted column. */
|
|
function isPersistedColumnProperty(prop: EntityProperty): boolean {
|
|
if (prop.persist === false) return false;
|
|
if (prop.kind === ReferenceKind.ONE_TO_MANY || prop.kind === ReferenceKind.MANY_TO_MANY) return false;
|
|
if (prop.kind === ReferenceKind.EMBEDDED) return false;
|
|
if (prop.kind === ReferenceKind.ONE_TO_ONE && !prop.owner) return false;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* MikroORM flags a declared `pivotEntity` (`PackingItemContributors`, which
|
|
* carries `status`/`created_at` payload columns) with `pivotTable: true` just
|
|
* like the synthetic pivots it invents for a bare `manyToMany`. Only the
|
|
* synthetic ones have no class of their own — a declared pivot entity is a
|
|
* real table-backed entity and is held to the schema like any other.
|
|
*/
|
|
const DECLARED_ENTITY_CLASSES = new Set(ALL_ENTITIES.map((schema) => schema.meta.className));
|
|
|
|
function isSyntheticPivot(meta: EntityMetadata): boolean {
|
|
return Boolean(meta.pivotTable) && !DECLARED_ENTITY_CLASSES.has(meta.className);
|
|
}
|
|
|
|
/** Entity metadata this test holds against a table: skips MikroORM's own synthetic/non-table entries. */
|
|
function isTableBackedMeta(meta: EntityMetadata): boolean {
|
|
return !isSyntheticPivot(meta) && !meta.embeddable && !meta.virtual && !meta.abstract;
|
|
}
|
|
|
|
function tableBackedMetas(): EntityMetadata[] {
|
|
return [...t.orm.getMetadata().getAll().values()].filter(isTableBackedMeta);
|
|
}
|
|
|
|
/** Table names of the SYNTHETIC pivots MikroORM registers for a bare `manyToMany` + `pivotTable` — covered by the owning relation, never their own entity. */
|
|
function pivotTableNames(): Set<string> {
|
|
const names = new Set<string>();
|
|
for (const meta of t.orm.getMetadata().getAll().values()) {
|
|
if (isSyntheticPivot(meta)) names.add(meta.tableName);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
describe('entity ↔ migrated-schema parity', () => {
|
|
it('PARITY-001: every entity maps to a table that exists', () => {
|
|
const tables = dbTables(testDb);
|
|
const failures: string[] = [];
|
|
for (const meta of tableBackedMetas()) {
|
|
if (!tables.has(meta.tableName)) {
|
|
failures.push(`${meta.className}: entity table "${meta.tableName}" does not exist in the migrated schema`);
|
|
}
|
|
}
|
|
expect(failures).toEqual([]);
|
|
});
|
|
|
|
it('PARITY-002: every persisted property has a matching column', () => {
|
|
const failures: string[] = [];
|
|
for (const meta of tableBackedMetas()) {
|
|
const table = meta.tableName;
|
|
if (!dbTables(testDb).has(table)) continue; // reported by PARITY-001
|
|
const columns = new Map(tableInfo(testDb, table).map((c) => [c.name, c]));
|
|
for (const prop of meta.props) {
|
|
if (!isPersistedColumnProperty(prop)) continue;
|
|
for (const fieldName of prop.fieldNames) {
|
|
if (!columns.has(fieldName)) {
|
|
failures.push(`${table}.${fieldName}: entity property "${meta.className}.${prop.name}" has no db column`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
expect(failures).toEqual([]);
|
|
});
|
|
|
|
it('PARITY-003: column types agree after normalisation', () => {
|
|
const failures: string[] = [];
|
|
for (const meta of tableBackedMetas()) {
|
|
const table = meta.tableName;
|
|
const columns = new Map(tableInfo(testDb, table).map((c) => [c.name, c]));
|
|
for (const prop of meta.props) {
|
|
if (!isPersistedColumnProperty(prop)) continue;
|
|
for (const [i, fieldName] of prop.fieldNames.entries()) {
|
|
const column = columns.get(fieldName);
|
|
if (!column) continue; // reported by PARITY-002
|
|
const entityType = prop.columnTypes[i];
|
|
if (!entityType) {
|
|
failures.push(`${table}.${fieldName}: entity property "${meta.className}.${prop.name}" declares no column type`);
|
|
continue;
|
|
}
|
|
if (!columnTypesEqual(entityType, column.type)) {
|
|
failures.push(`${table}.${fieldName}: entity ${normaliseColumnType(entityType)} vs db ${normaliseColumnType(column.type)}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
expect(failures).toEqual([]);
|
|
});
|
|
|
|
it('PARITY-004: nullability agrees on every non-primary-key column', () => {
|
|
const failures: string[] = [];
|
|
for (const meta of tableBackedMetas()) {
|
|
const table = meta.tableName;
|
|
const columns = new Map(tableInfo(testDb, table).map((c) => [c.name, c]));
|
|
for (const prop of meta.props) {
|
|
if (!isPersistedColumnProperty(prop)) continue;
|
|
for (const fieldName of prop.fieldNames) {
|
|
const column = columns.get(fieldName);
|
|
if (!column) continue; // reported by PARITY-002
|
|
if (column.pk) continue; // SQLite's INTEGER PRIMARY KEY reports notnull=0 regardless; text PKs are legitimately nullable (no `NOT NULL` in the migration) — confirmed in inputs.md.
|
|
const entityNullable = Boolean(prop.nullable);
|
|
const dbNullable = column.notnull === 0;
|
|
if (entityNullable !== dbNullable) {
|
|
failures.push(`${table}.${fieldName}: nullable entity=${entityNullable} vs db=${dbNullable}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
expect(failures).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* Column defaults, now a real gate: Task 2/3's generator rewrite removed
|
|
* every `onCreate(() => new Date())` hook in favour of `DbTimestampType` +
|
|
* `defaultRaw('CURRENT_TIMESTAMP')` (Plan 2's D2), which is what unblocked
|
|
* this test — Task 0 left it `it.skip`'d and report-only with 93 of 994
|
|
* columns "mismatching," all of them that same onCreate-vs-CURRENT_TIMESTAMP
|
|
* gap or a quoting spelling difference carrying no schema information.
|
|
* `normaliseDefault` below folds those spellings together (quote style, a
|
|
* wrapping paren SQLite adds around a non-literal `DEFAULT (expr)` that
|
|
* `PRAGMA table_info` sometimes strips and the generator's own
|
|
* `prop.defaultRaw` sometimes doesn't, `datetime('now')`/`CURRENT_TIMESTAMP`,
|
|
* and `p.boolean()`'s literal `true`/`false` class-field default against
|
|
* SQLite's `0`/`1` — the only boolean column in the schema today,
|
|
* `addons.enabled`). Only real schema drift survives that.
|
|
*/
|
|
it('PARITY-005: column defaults agree after normalisation', () => {
|
|
const failures: string[] = [];
|
|
for (const meta of tableBackedMetas()) {
|
|
const table = meta.tableName;
|
|
const columns = new Map(tableInfo(testDb, table).map((c) => [c.name, c]));
|
|
for (const prop of meta.props) {
|
|
if (!isPersistedColumnProperty(prop)) continue;
|
|
for (const fieldName of prop.fieldNames) {
|
|
const column = columns.get(fieldName);
|
|
if (!column) continue;
|
|
const entityDefault = normaliseDefault(prop.defaultRaw ?? (prop.default != null ? String(prop.default) : null));
|
|
const dbDefault = normaliseDefault(column.dflt_value);
|
|
if (entityDefault !== dbDefault) {
|
|
failures.push(`${table}.${fieldName}: default entity=${entityDefault ?? 'null'} vs db=${dbDefault ?? 'null'}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
expect(failures).toEqual([]);
|
|
});
|
|
|
|
it('PARITY-006: every db column is covered by a persisted property', () => {
|
|
const failures: string[] = [];
|
|
for (const meta of tableBackedMetas()) {
|
|
const table = meta.tableName;
|
|
if (!dbTables(testDb).has(table)) continue; // reported by PARITY-001
|
|
const covered = new Set<string>();
|
|
for (const prop of meta.props) {
|
|
if (!isPersistedColumnProperty(prop)) continue;
|
|
for (const fieldName of prop.fieldNames) covered.add(fieldName);
|
|
}
|
|
for (const column of tableInfo(testDb, table)) {
|
|
if (!covered.has(column.name)) {
|
|
failures.push(`${table}.${column.name}: db column has no entity property (${meta.className})`);
|
|
}
|
|
}
|
|
}
|
|
expect(failures).toEqual([]);
|
|
});
|
|
|
|
it('PARITY-007: every db table has an entity, except mikro_orm_migrations, declared pivots and ENTITIES_STILL_MISSING', () => {
|
|
const tables = dbTables(testDb);
|
|
const entityTables = new Set(tableBackedMetas().map((m) => m.tableName));
|
|
const pivots = pivotTableNames();
|
|
const stillMissing = new Set(ENTITIES_STILL_MISSING);
|
|
const failures: string[] = [];
|
|
for (const table of tables) {
|
|
if (table === 'mikro_orm_migrations') continue;
|
|
if (entityTables.has(table)) continue;
|
|
if (pivots.has(table)) continue;
|
|
if (stillMissing.has(table)) continue; // reported separately below
|
|
failures.push(`${table}: db table has no entity and is not in ENTITIES_STILL_MISSING`);
|
|
}
|
|
expect(failures).toEqual([]);
|
|
});
|
|
|
|
it('PARITY-008: ENTITIES_STILL_MISSING stays accurate — none of them may already have an entity', () => {
|
|
const entityTables = new Set(tableBackedMetas().map((m) => m.tableName));
|
|
const failures: string[] = [];
|
|
const tables = dbTables(testDb);
|
|
for (const table of ENTITIES_STILL_MISSING) {
|
|
if (!tables.has(table)) failures.push(`${table}: listed in ENTITIES_STILL_MISSING but is not a db table`);
|
|
if (entityTables.has(table)) {
|
|
failures.push(`${table}: has an entity now — remove it from ENTITIES_STILL_MISSING`);
|
|
}
|
|
}
|
|
expect(failures).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* FK targets, against `PRAGMA foreign_key_list` — the plan's second,
|
|
* allow-listed check (Task 0, Step 3), the REFERENCED-COLUMN half. Task 0
|
|
* left the whole check (referenced column + deleteRule + index names)
|
|
* `it.skip`'d and report-only. This half is un-skipped as of Plan 3b
|
|
* interlude A: `RULE11_referencedColumns` (`scripts/generate-entities.ts`)
|
|
* closed the one gap that made it fail — `OauthTokens.client`/
|
|
* `OauthConsents.client` targeted `oauth_clients.id` (the generator's
|
|
* default assumption for an owning to-one relation) instead of the schema's
|
|
* real FK target, the UNIQUE natural key `oauth_clients.client_id`
|
|
* (`Migration20200101012500_oauth_2.ts`). Every other owning relation in
|
|
* the schema already agreed with `PRAGMA foreign_key_list`'s `to` column,
|
|
* `school_holiday_regions.country -> school_holiday_countries.code`
|
|
* included (`code` IS that table's primary key, so the generator's default
|
|
* assumption was already correct there — unchanged by Rule 11, confirmed
|
|
* by this same test). The deleteRule half is un-skipped by Plan 4 Task 7 —
|
|
* see `PARITY-009b` immediately below.
|
|
*
|
|
* `school_holiday_regions.country` re-verified one more time at Task 7's
|
|
* own HEAD, directly against the live migration DDL (not the entity, not
|
|
* an earlier report): `Migration20200101033600_create_school_holiday_countries.ts`
|
|
* declares `country TEXT NOT NULL REFERENCES school_holiday_countries(code)`
|
|
* — a real, DB-enforced FK, confirmed here by this very test passing for
|
|
* that column too (`SchoolHolidayRegions.countryRef`'s `.joinColumn('country')`,
|
|
* Plan 2 Task 3). 3f's inventory was right; there is no schema gap here to
|
|
* document for the user.
|
|
*/
|
|
it('PARITY-009: FK referenced columns agree with PRAGMA foreign_key_list', () => {
|
|
const failures: string[] = [];
|
|
for (const meta of tableBackedMetas()) {
|
|
const table = meta.tableName;
|
|
const fkRows = testDb.prepare(`PRAGMA foreign_key_list("${table}")`).all() as {
|
|
from: string;
|
|
table: string;
|
|
to: string;
|
|
}[];
|
|
const fkByColumn = new Map(fkRows.map((row) => [row.from, row]));
|
|
for (const prop of meta.props) {
|
|
const isOwningToOne =
|
|
prop.kind === ReferenceKind.MANY_TO_ONE || (prop.kind === ReferenceKind.ONE_TO_ONE && prop.owner);
|
|
if (!isOwningToOne) continue;
|
|
const fieldName = prop.fieldNames[0];
|
|
const fk = fkByColumn.get(fieldName);
|
|
if (!fk) {
|
|
failures.push(`${table}.${fieldName}: entity relation has no matching db foreign key`);
|
|
continue;
|
|
}
|
|
const entityReferencedColumn = prop.referencedColumnNames?.[0];
|
|
if (entityReferencedColumn !== fk.to) {
|
|
failures.push(`${table}.${fieldName}: referencedColumn entity=${String(entityReferencedColumn)} vs db=${fk.to}`);
|
|
}
|
|
}
|
|
}
|
|
expect(failures).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* The deleteRule half of the same check (Task 0, Step 3), un-skipped by
|
|
* Plan 4 Task 7. Task 0 found deleteRule disagreeing on 11 of 180 owning
|
|
* relations; Task 3's rewrite fixed one of those eleven as a side effect
|
|
* (`school_holiday_regions.country`) and re-measured the other 124
|
|
* rewritten/added entities at 10. Task 7 re-measured again at its own HEAD
|
|
* (program rule: re-measure, never assume a prior count) and found the
|
|
* same 10, unchanged: `trip_members.invited_by`, `oauth_tokens.
|
|
* parent_token_id`, `budget_settlements.created_by_user_id`, `budget_items.
|
|
* paid_by_user_id` (a nullable relation whose column has no `ON DELETE` at
|
|
* all in its migration — SQLite ⇒ `no action` — while the entity's bare
|
|
* `.nullable()` defaults to MikroORM's own `'set null'`), `roadtrip_day_
|
|
* tracks.day_id` (nullable AND the table's sole PK — migration says
|
|
* `ON DELETE CASCADE`, entity defaulted to `'set null'` the same way),
|
|
* `journey_contributors.user_id` (a composite-PK member whose migration
|
|
* has no `ON DELETE`, while the entity's FK-as-PK shape defaults to
|
|
* `'cascade'`), and `reservation_travelers.reservation_id`/`.user_id` +
|
|
* `assignment_participants.assignment_id`/`.user_id` (migrations say
|
|
* `ON DELETE CASCADE`; the entity had no default at all for that shape,
|
|
* landing on `'no action'`).
|
|
*
|
|
* All 10 are the ENTITY side never having matched its migration's physical
|
|
* FK to begin with (never a migration defect — program rule: migrations
|
|
* are append-only, and every one of the 10 migrations above is verified
|
|
* correct against its own table's real production behavior). Fixed via a
|
|
* new generator rule, `RULE13_pinDeleteRuleDrift` (`scripts/generate-
|
|
* entities.ts`), which pins an explicit `.deleteRule(...)` wherever
|
|
* leaving the relation implicit would produce the wrong effective rule —
|
|
* see that rule's own doc comment for the full mechanism, including one
|
|
* more wrinkle it found empirically: `DawarichConnections.user`,
|
|
* `PlaceRegions.place` and `VacayUserSettings.user` share the exact same
|
|
* "nullable AND sole FK-as-PK" shape as `roadtrip_day_tracks.day_id` but
|
|
* were NOT among the 10 — their runtime default already happens to land on
|
|
* `'cascade'` (matching physical) purely because of `ALL_ENTITIES`
|
|
* discovery order, not because the shape is safe. `RULE13` pins all four
|
|
* explicitly rather than trust that order to hold.
|
|
*/
|
|
it('PARITY-009b: FK deleteRule agrees with PRAGMA foreign_key_list', () => {
|
|
const failures: string[] = [];
|
|
for (const meta of tableBackedMetas()) {
|
|
const table = meta.tableName;
|
|
const fkRows = testDb.prepare(`PRAGMA foreign_key_list("${table}")`).all() as {
|
|
from: string;
|
|
table: string;
|
|
on_delete: string;
|
|
}[];
|
|
const fkByColumn = new Map(fkRows.map((row) => [row.from, row]));
|
|
for (const prop of meta.props) {
|
|
const isOwningToOne =
|
|
prop.kind === ReferenceKind.MANY_TO_ONE || (prop.kind === ReferenceKind.ONE_TO_ONE && prop.owner);
|
|
if (!isOwningToOne) continue;
|
|
const fieldName = prop.fieldNames[0];
|
|
const fk = fkByColumn.get(fieldName);
|
|
if (!fk) {
|
|
failures.push(`${table}.${fieldName}: entity relation has no matching db foreign key`);
|
|
continue;
|
|
}
|
|
const entityRule = (prop.deleteRule ?? 'no action').toLowerCase();
|
|
const dbRule = fk.on_delete.toLowerCase();
|
|
if (entityRule !== dbRule) {
|
|
failures.push(`${table}.${fieldName}: deleteRule entity=${entityRule} vs db=${dbRule}`);
|
|
}
|
|
}
|
|
}
|
|
expect(failures).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* The index-name half of the same PRAGMA-allow-listed check (Task 0, Step
|
|
* 3) — per 3a's own ledger note, "PARITY-009's index half was never
|
|
* implemented (only deleteRule)." Task 7 writes it fresh.
|
|
*
|
|
* `PRAGMA index_list(<table>)` names every index SQLite actually built —
|
|
* a plain `CREATE INDEX <name> ON ...` or `CREATE UNIQUE INDEX <name>
|
|
* ON ...`, one row each — MINUS the unnamed `sqlite_autoindex_<table>_<n>`
|
|
* entries a bare `PRIMARY KEY`/inline `UNIQUE(...)` constraint gets for
|
|
* free (excluded the same way `collectImplicitUniqueIndexes` in
|
|
* `scripts/generate-entities.ts` excludes them — those are RULE9's
|
|
* `uniques:` territory, a DIFFERENT, already-covered check, not a named
|
|
* index this one can compare against). The entity side collects every
|
|
* NAMED index a `defineEntity` call can declare: a property-level
|
|
* `.index('name')`/`.unique('name')` (`prop.index`/`prop.unique`, a string
|
|
* when named — the codebase never uses the unnamed `boolean` form, see the
|
|
* `grep` a future author can re-run: `grep -rn '\.index()\|\.unique()' src/db/entities/*.entity.ts`
|
|
* finds nothing), and every entity-level `indexes:`/`uniques:` block's own
|
|
* `.name` (composite indexes, e.g. `Notifications.idx_notifications_target_scope`).
|
|
* The two sets must be equal — a name in one and not the other means
|
|
* either a migration added/renamed/dropped an index without the entity
|
|
* following, or the reverse. Measured clean on today's schema (the
|
|
* generator's own introspection already names every entity index after
|
|
* its real DB index, RULE9's unnamed `uniques:` entries excluded by
|
|
* construction from both sides) — this is a drift GUARD for a future
|
|
* migration, not a fix for a found gap.
|
|
*/
|
|
it('PARITY-009c: index names agree with PRAGMA index_list', () => {
|
|
const failures: string[] = [];
|
|
for (const meta of tableBackedMetas()) {
|
|
const table = meta.tableName;
|
|
const dbIndexRows = testDb.prepare(`PRAGMA index_list("${table}")`).all() as { name: string; unique: number }[];
|
|
const dbNames = new Set(dbIndexRows.filter((row) => !row.name.startsWith('sqlite_autoindex_')).map((row) => row.name));
|
|
const entityNames = new Set<string>();
|
|
for (const prop of meta.props) {
|
|
if (typeof prop.index === 'string') entityNames.add(prop.index);
|
|
if (typeof prop.unique === 'string') entityNames.add(prop.unique);
|
|
}
|
|
for (const idx of meta.indexes) if (idx.name) entityNames.add(idx.name);
|
|
for (const uniq of meta.uniques) if (uniq.name) entityNames.add(uniq.name);
|
|
for (const name of dbNames) {
|
|
if (!entityNames.has(name)) failures.push(`${table}: db index "${name}" has no matching entity index declaration`);
|
|
}
|
|
for (const name of entityNames) {
|
|
if (!dbNames.has(name)) failures.push(`${table}: entity declares index "${name}" with no matching db index`);
|
|
}
|
|
}
|
|
expect(failures).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* D1's own rule (the naming strategy derives the same column from a
|
|
* camelCase or a snake_case property, so a column-level check can never
|
|
* see the difference): every persisted scalar property's name must equal
|
|
* its single column name. Relations are exempt by kind (an owning
|
|
* relation legitimately renames itself away from its column, e.g.
|
|
* `SchoolHolidayRegions.countryRef` + `.joinColumn('country')` — Task 2's
|
|
* rule 5); their FK twin scalars are `kind === SCALAR` and stay covered
|
|
* here. The same test asserts every single-column integer primary key is
|
|
* `autoincrement` in the discovered metadata — Task 2's Rule 3 doesn't
|
|
* spell `.autoincrement()` in the builder chain for every case (relying on
|
|
* `MetadataDiscovery#initAutoincrement`'s own `??= true` default for an
|
|
* integer PK with no explicit value), so nothing until now pinned that the
|
|
* default actually fires. Composite and text primary keys are untouched by
|
|
* design (Task 0/1) and excluded here by construction (more than one
|
|
* primary prop, or a non-integer type).
|
|
*/
|
|
it('PARITY-010: persisted scalar property names equal their single column name; integer id PKs are autoincrement', () => {
|
|
const failures: string[] = [];
|
|
for (const meta of tableBackedMetas()) {
|
|
for (const prop of meta.props) {
|
|
const isRelation = prop.kind !== undefined && prop.kind !== ReferenceKind.SCALAR;
|
|
if (isRelation) continue;
|
|
if (prop.persist === false && prop.fieldNames.length === 0) continue; // an embeddable/virtual scalar with no column at all
|
|
if (prop.fieldNames.length !== 1 || prop.name !== prop.fieldNames[0]) {
|
|
failures.push(
|
|
`${meta.className}.${prop.name}: property name does not equal its single column name (fieldNames=${JSON.stringify(prop.fieldNames)})`,
|
|
);
|
|
}
|
|
}
|
|
// A genuinely single-column primary key only — a composite PK (e.g.
|
|
// RoadtripDayBoundaries' PRIMARY KEY (trip_id, day_number)) can have an
|
|
// all-integer shape without ever being SQLite's single-column
|
|
// `INTEGER PRIMARY KEY` rowid alias, so it must never autoincrement;
|
|
// `getPrimaryProps()` must report exactly one prop, unfiltered, before
|
|
// this check applies at all.
|
|
const primaryProps = meta.getPrimaryProps();
|
|
if (primaryProps.length === 1) {
|
|
const pk = primaryProps[0];
|
|
const isIntegerScalarPk =
|
|
(pk.kind === undefined || pk.kind === ReferenceKind.SCALAR) && normaliseColumnType(pk.columnTypes[0] ?? '') === 'integer';
|
|
if (isIntegerScalarPk && pk.autoincrement !== true) {
|
|
failures.push(`${meta.className}.${pk.name}: integer primary key is not autoincrement`);
|
|
}
|
|
}
|
|
}
|
|
expect(failures).toEqual([]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Strips SQL string-literal quoting and a single wrapping paren pair (SQLite
|
|
* requires `DEFAULT (expr)` for a non-literal expression in the migration's
|
|
* own DDL; `PRAGMA table_info` and the generator's introspected
|
|
* `prop.defaultRaw` don't always agree on keeping that outer pair), folds
|
|
* SQLite's `CURRENT_TIMESTAMP`/`datetime('now')` spellings together, and
|
|
* folds a `p.boolean()` property's literal `true`/`false` default onto
|
|
* SQLite's `1`/`0` (SQLite has no boolean storage class — see
|
|
* `BOOLEAN_COLUMNS` in `scripts/generate-entities.ts`). Used by PARITY-005.
|
|
*
|
|
* M1 (task-4-review-gates.md): only the SQL keywords/functions above are
|
|
* folded case-insensitively — a quoted string literal's PAYLOAD is data, not
|
|
* a keyword, and is compared verbatim, case included. A blanket
|
|
* `s.toLowerCase()` on the whole string (the previous shape) silently
|
|
* equated `'#10b981'` and `'#10B981'`, proved by the gate reviewer's mutation
|
|
* (d2): flipping `Tags.color`'s class-field default's case left every test
|
|
* green. The entity side and the db side aren't even symmetric about
|
|
* quoting to begin with (`prop.default` arrives as a bare JS string, e.g.
|
|
* `#10b981`; `column.dflt_value` arrives SQL-quoted, `'#10b981'`) — quotes
|
|
* are stripped from whichever side has them, then BOTH sides are compared
|
|
* verbatim, never lowercased, unless they match a known keyword.
|
|
*/
|
|
function normaliseDefault(value: string | null): string | null {
|
|
if (value == null) return null;
|
|
let s = value.trim();
|
|
if (s.startsWith('(') && s.endsWith(')')) s = s.slice(1, -1).trim();
|
|
if (s.startsWith("'") && s.endsWith("'")) s = s.slice(1, -1); // SQL quoting stripped; payload kept verbatim below
|
|
const lower = s.toLowerCase();
|
|
if (lower === "datetime('now')" || lower === 'current_timestamp') return '__now__';
|
|
if (lower === 'true') return '1';
|
|
if (lower === 'false') return '0';
|
|
return s;
|
|
}
|
|
|
|
describe('normaliseColumnType', () => {
|
|
it('TYPE-001: maps the abstract names MikroORM leaves untranslated on SQLite', () => {
|
|
expect(normaliseColumnType('double')).toBe('real');
|
|
expect(normaliseColumnType('json')).toBe('text');
|
|
expect(normaliseColumnType('varchar')).toBe('text');
|
|
});
|
|
|
|
it('TYPE-002: strips a length/precision suffix and lowercases', () => {
|
|
expect(normaliseColumnType('VARCHAR(255)')).toBe('text');
|
|
expect(normaliseColumnType('DECIMAL(10,2)')).toBe('decimal');
|
|
expect(normaliseColumnType('INTEGER')).toBe('integer');
|
|
});
|
|
|
|
it('TYPE-003: passes through types that already agree', () => {
|
|
expect(normaliseColumnType('text')).toBe('text');
|
|
expect(normaliseColumnType('real')).toBe('real');
|
|
expect(normaliseColumnType('datetime')).toBe('datetime');
|
|
expect(normaliseColumnType('bigint')).toBe('bigint');
|
|
});
|
|
});
|
|
|
|
describe('normaliseDefault', () => {
|
|
it('DEFAULT-NORM-001: a case-only difference in a quoted string literal payload is a real mismatch (M1)', () => {
|
|
expect(normaliseDefault("'#10b981'")).not.toBe(normaliseDefault("'#10B981'"));
|
|
});
|
|
|
|
it('DEFAULT-NORM-002: an identical quoted string literal payload matches, case preserved', () => {
|
|
expect(normaliseDefault("'#10b981'")).toBe(normaliseDefault("'#10b981'"));
|
|
expect(normaliseDefault("'#10b981'")).toBe('#10b981');
|
|
});
|
|
|
|
it('DEFAULT-NORM-003: an unquoted default (as PARITY-005 passes the entity side, String(prop.default)) compares verbatim too', () => {
|
|
expect(normaliseDefault('#10B981')).toBe('#10B981');
|
|
expect(normaliseDefault('#10B981')).not.toBe(normaliseDefault("'#10b981'"));
|
|
});
|
|
|
|
it('DEFAULT-NORM-004: SQL keyword/function spellings still fold case-insensitively', () => {
|
|
expect(normaliseDefault('CURRENT_TIMESTAMP')).toBe('__now__');
|
|
expect(normaliseDefault('current_timestamp')).toBe('__now__');
|
|
expect(normaliseDefault("datetime('now')")).toBe('__now__');
|
|
expect(normaliseDefault("DATETIME('NOW')")).toBe('__now__');
|
|
expect(normaliseDefault('TRUE')).toBe('1');
|
|
expect(normaliseDefault('FALSE')).toBe('0');
|
|
});
|
|
|
|
it('DEFAULT-NORM-005: a wrapping paren pair around a non-literal expression is stripped once', () => {
|
|
expect(normaliseDefault("(strftime('%s','now'))")).toBe("strftime('%s','now')");
|
|
});
|
|
|
|
it('DEFAULT-NORM-006: null passes through as null', () => {
|
|
expect(normaliseDefault(null)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('columnTypesEqual', () => {
|
|
it('TYPE-004: accepts datetime on one side and text on the other, either direction', () => {
|
|
expect(columnTypesEqual('datetime', 'text')).toBe(true);
|
|
expect(columnTypesEqual('text', 'datetime')).toBe(true);
|
|
expect(columnTypesEqual('DATETIME', 'TEXT')).toBe(true);
|
|
});
|
|
|
|
it('TYPE-005: still rejects a genuine mismatch', () => {
|
|
expect(columnTypesEqual('integer', 'text')).toBe(false);
|
|
expect(columnTypesEqual('real', 'integer')).toBe(false);
|
|
});
|
|
});
|