import { ALL_ENTITIES } from '../../../src/db/entities'; import { createSnapshotTestDb } from '../../helpers/db-mock'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { ReferenceKind, type EntityMetadata, type EntityProperty } from '@mikro-orm/core'; import type Database from 'better-sqlite3'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; /** * 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()` 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 { 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 { const names = new Set(); 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(); 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(
)` names every index SQLite actually built — * a plain `CREATE INDEX ON ...` or `CREATE UNIQUE INDEX * ON ...`, one row each — MINUS the unnamed `sqlite_autoindex_
_` * 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(); 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); }); });