fix(db): dialect helpers dispatch on the platform; lint gate for the repository layer

This commit is contained in:
jubnl
2026-09-21 18:12:07 +02:00
parent d7c7afea1b
commit f4fe27a3f8
8 changed files with 197 additions and 48 deletions
+129
View File
@@ -106,6 +106,135 @@ export default tseslint.config(
],
},
},
{
// D4: the driver is an implementation detail of src/db. Nothing else in
// src/ names `better-sqlite3` — a domain module that imports it is coupled
// to the engine the ORM exists to abstract, and (for the value import)
// reaches a connection the DI graph never handed it.
//
// `no-restricted-imports` is repeated rather than added to the src/services
// block above because ESLint flat config REPLACES a rule's options with the
// last matching config object's — a block that set only the driver path
// would silently drop the services wall for every file it matches.
files: ['src/**/*.ts'],
ignores: [
// The connection and the ORM's bound driver: this is where the engine lives.
'src/db/database.ts',
'src/db/orm-driver.ts',
'src/db/durability.ts',
// The plugin sandbox opens its own per-plugin database files, deliberately
// outside the app's ORM and connection.
'src/nest/plugins/host/plugin-data.service.ts',
// Backup/restore operates on database FILES, not on rows.
'src/nest/backup/backup.impl.ts',
// Demo-mode seeding, and the legacy schema/migration/seed scripts the test
// suite still builds its throwaway databases from (guarded by
// tests/unit/db/schema-parity.test.ts).
'src/demo/**',
'src/db/schema.ts',
'src/db/migrations.ts',
'src/db/seeds.ts',
// TODO(plan 4): pre-existing importers, to be migrated onto the ORM /
// DatabaseService rather than by loosening this rule. All type-only except
// reseat-booked-nights.ts, which opens its own handle.
'src/db/reseat-booked-nights.ts',
'src/db/document-provider-seed.ts',
'src/nest/database/database.service.ts',
'src/nest/plugins/contributions/plugin-route-normalize.ts',
'src/nest/plugins/host/plugin-host-state.ts',
'src/nest/plugins/install/discovery.ts',
'src/nest/plugins/settings-defaults.ts',
'src/nest/plugins/signature-status.ts',
],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'better-sqlite3',
message:
'The SQLite driver is an implementation detail of src/db. Go through the ORM (src/db/entities + src/db/repositories) or the injected DatabaseService; the handle itself is owned by src/db/database.ts.',
},
],
patterns: [
{
group: ['**/services/*', '**/services/**/*'],
message:
'src/services/ is deleted. New backend code goes to src/nest/<domain>/ (service + controller + module, registered in app.module.ts). See src/nest/README.md.',
},
],
},
],
},
},
{
// D4/D10: a repository is dialect-neutral. It talks to the ORM through
// @mikro-orm/core and @mikro-orm/sql — never to a concrete driver package,
// never to better-sqlite3 (the driver is wired once, in src/db/orm-driver.ts)
// — and it never spells raw SQL inline: every database function goes through
// src/db/dialect/sql-functions.ts, which dispatches on the live platform.
//
// Both rules restate the blocks above for the same flat-config reason: the
// last matching config object's options REPLACE, not extend, the earlier
// ones, so dropping either restatement would open a hole under
// src/db/repositories/ only.
files: ['src/db/repositories/**/*.ts'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@mikro-orm/sqlite',
message:
'Repositories are dialect-neutral: import from @mikro-orm/sql / @mikro-orm/core. The driver lives in src/db/orm-driver.ts.',
},
{
name: '@mikro-orm/postgresql',
message:
'Repositories are dialect-neutral: import from @mikro-orm/sql / @mikro-orm/core. The driver lives in src/db/orm-driver.ts.',
},
{
name: 'better-sqlite3',
message:
'Repositories are dialect-neutral: import from @mikro-orm/sql / @mikro-orm/core. The driver lives in src/db/orm-driver.ts.',
},
],
patterns: [
{
group: ['**/services/*', '**/services/**/*'],
message:
'src/services/ is deleted. New backend code goes to src/nest/<domain>/ (service + controller + module, registered in app.module.ts). See src/nest/README.md.',
},
],
},
],
'no-restricted-syntax': [
'error',
{
selector: "MemberExpression[object.name='process'][property.name='env']",
message:
'Read configuration via src/app-config (readEnv()/derive/tokens), not process.env. Exemptions: eslint.config.mjs + src/app-config/README.md.',
},
{
selector: "MemberExpression[object.name='process'][property.value='env']",
message:
'Read configuration via src/app-config (readEnv()/derive/tokens), not process.env. Exemptions: eslint.config.mjs + src/app-config/README.md.',
},
{
selector: "CallExpression[callee.name='raw']",
message:
'Dialect SQL goes through src/db/dialect/sql-functions.ts, which dispatches on the live MikroORM platform. A repository must not spell raw SQL.',
},
{
selector: "TaggedTemplateExpression[tag.name='sql']",
message:
'Dialect SQL goes through src/db/dialect/sql-functions.ts, which dispatches on the live MikroORM platform. A repository must not spell raw SQL.',
},
],
},
},
{
// The plugin RPC decorator kit is written to stay extractable into its own
// package, so its dependencies are pinned to Nest plus the two host modules it
+28 -13
View File
@@ -1,13 +1,19 @@
import { raw, type RawQueryFragment } from '@mikro-orm/core';
import { raw, type Platform, type RawQueryFragment } from '@mikro-orm/core';
import { SqlitePlatform } from '@mikro-orm/sql';
/**
* The only place a repository may spell a database function.
*
* Every helper takes an already-qualified column reference (`'t.created_at'`)
* and returns a fragment for the *current* dialect. The ORM exists so a
* Postgres driver can follow; when it does, this file gets a second
* implementation and no repository changes. Values are never interpolated —
* the day count in `dateAdd` is checked to be an integer before it is spelled.
* Every helper takes the MikroORM `Platform` the caller's EntityManager is
* running on (`em.getPlatform()`) plus an already-qualified column reference
* (`'t.created_at'`), and dispatches on that platform at runtime. TREK ships
* one binary and the database is chosen by configuration, so the dialect is a
* runtime property of the live connection, not a build-time swap: when the
* Postgres driver arrives this file grows a second branch and no repository
* changes. An unknown platform fails closed rather than guessing a spelling.
*
* Values are never interpolated — the day count in `dateAdd` is checked to be
* an integer before it is spelled.
*/
function column(ref: string): string {
@@ -17,19 +23,28 @@ function column(ref: string): string {
return ref;
}
function unsupported(platform: Platform): never {
throw new Error(`sql-functions: no implementation for platform ${platform.constructor.name}`);
}
/** The calendar date (`YYYY-MM-DD`) of a timestamp column. */
export function dateOf(ref: string): RawQueryFragment {
return raw(`date(${column(ref)})`);
export function dateOf(platform: Platform, ref: string): RawQueryFragment {
if (platform instanceof SqlitePlatform) return raw(`date(${column(ref)})`);
return unsupported(platform);
}
/** The timestamp column shifted by whole days, as a calendar date. */
export function dateAdd(ref: string, days: number): RawQueryFragment {
export function dateAdd(platform: Platform, ref: string, days: number): RawQueryFragment {
if (!Number.isInteger(days)) throw new Error(`sql-functions: dateAdd needs an integer day count, got ${days}`);
const sign = days < 0 ? '-' : '+';
return raw(`date(${column(ref)}, '${sign}${Math.abs(days)} days')`);
if (platform instanceof SqlitePlatform) {
const sign = days < 0 ? '-' : '+';
return raw(`date(${column(ref)}, '${sign}${Math.abs(days)} days')`);
}
return unsupported(platform);
}
/** The database clock, in the same text the column defaults produce. */
export function currentTimestamp(): RawQueryFragment {
return raw('CURRENT_TIMESTAMP');
export function currentTimestamp(platform: Platform): RawQueryFragment {
if (platform instanceof SqlitePlatform) return raw('CURRENT_TIMESTAMP');
return unsupported(platform);
}
+2 -2
View File
@@ -56,8 +56,8 @@ export async function runSchemaBootstrap(orm: AnyOrm): Promise<void> {
export function attachOrm(orm: AnyOrm): void {
registerReinitializeHook(async () => {
const connection = orm.em.getConnection();
// The old handle is already closed by this point; better-sqlite3's close()
// is idempotent, so letting Kysely destroy it again is harmless.
// The handle is owned and closed by `database.ts`; the bound driver's
// `destroy()` is a no-op (see `orm-driver.ts`), so this close never reaches it.
await connection.close(true);
await connection.connect();
await runSchemaBootstrap(orm);
+11 -17
View File
@@ -1,4 +1,4 @@
import { Type, type EntityProperty, type Platform, type TransformContext } from '@mikro-orm/core';
import { Type, type EntityProperty, type Platform } from '@mikro-orm/core';
/** The text every DATETIME column holds and every API response emits. */
export const DB_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
@@ -25,7 +25,9 @@ type TimestampValue = string | null | undefined;
* text on the way in, and text is written as text on the way out.
*/
export class DbTimestampType extends Type<TimestampValue, TimestampValue> {
override convertToDatabaseValue(value: TimestampValue, platform: Platform, context?: TransformContext): TimestampValue {
// Both converters drop the base class's `platform`/`context` parameters: the
// wire format is the same text on every dialect, so neither is consulted.
override convertToDatabaseValue(value: TimestampValue): TimestampValue {
if (value == null) {
return value;
}
@@ -33,14 +35,10 @@ export class DbTimestampType extends Type<TimestampValue, TimestampValue> {
if ((value as unknown) instanceof Date) {
return dbNow(value as unknown as Date);
}
if (typeof value === 'string') {
return value;
}
// Default: convert to string
return String(value);
return value;
}
override convertToJSValue(value: TimestampValue, platform: Platform, context?: TransformContext): TimestampValue {
override convertToJSValue(value: TimestampValue): TimestampValue {
if (value == null) {
return value;
}
@@ -48,16 +46,12 @@ export class DbTimestampType extends Type<TimestampValue, TimestampValue> {
if ((value as unknown) instanceof Date) {
return dbNow(value as unknown as Date);
}
// Handle millisecond integers (legacy format)
if (typeof value === 'number' || (value as unknown) instanceof Number) {
const numValue = typeof value === 'number' ? value : (value as unknown as number);
return dbNow(new Date(numValue));
// Handle millisecond integers (legacy format) — a Date written by the stock
// DateTimeType reads back as a number.
if (typeof value === 'number') {
return dbNow(new Date(value as unknown as number));
}
if (typeof value === 'string') {
return value;
}
// Default: convert to string
return String(value);
return value;
}
override getColumnType(prop: EntityProperty, platform: Platform): string {
+2 -2
View File
@@ -152,8 +152,8 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
LlmParseModule,
ManagedExtModule,
MikroOrmModule.forRoot(mikroOrmConfig),
// Not part of DatabaseModule: 41 e2e suites compose that module without the
// ORM, and an EntityManager-dependent provider there fails their containers.
// Not part of DatabaseModule: the e2e suites that compose `DatabaseModule`
// without the ORM would fail on an EntityManager-dependent provider there.
OrmModule,
],
providers: [
+3 -2
View File
@@ -5,8 +5,9 @@ import { UnitOfWork } from './unit-of-work';
* The ORM-side container module: the home of providers that depend on MikroORM
* itself rather than on the raw better-sqlite3 handle `DatabaseModule` owns.
*
* Deliberately separate from `DatabaseModule`, which 41 e2e suites compose
* standalone (`Test.createTestingModule({ imports: [DatabaseModule, ...] })`)
* Deliberately separate from `DatabaseModule`, which the e2e suites that
* compose `DatabaseModule` without the ORM build standalone
* (`Test.createTestingModule({ imports: [DatabaseModule, ...] })`)
* with no ORM in the container: a provider there that injects the core
* `EntityManager` fails every one of them at `compile()`, because Nest
* instantiates providers eagerly. Nothing is imported here — the
@@ -1,11 +1,13 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createTestDb, resetTestDb } from '../../../helpers/test-db';
import { Platform } from '@mikro-orm/core';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { Users } from '../../../../src/db/entities/Users.entity';
import { currentTimestamp, dateAdd, dateOf } from '../../../../src/db/dialect/sql-functions';
const testDb = createTestDb();
const testDb = createSnapshotTestDb();
let t: TestOrm;
beforeAll(async () => { t = await createTestOrm(testDb); });
@@ -17,7 +19,7 @@ describe('sql-functions (sqlite)', () => {
const { user } = createUser(testDb);
testDb.prepare("UPDATE users SET created_at = '2026-09-21 13:05:09' WHERE id = ?").run(user.id);
const row = await t.em.createQueryBuilder(Users, 'u')
.select([dateOf('u.created_at').as('d')])
.select([dateOf(t.em.getPlatform(), 'u.created_at').as('d')])
.where({ id: user.id })
.execute('get', false);
expect(row).toEqual({ d: '2026-09-21' });
@@ -27,7 +29,7 @@ describe('sql-functions (sqlite)', () => {
const { user } = createUser(testDb);
testDb.prepare("UPDATE users SET created_at = '2026-09-21 13:05:09' WHERE id = ?").run(user.id);
const row = await t.em.createQueryBuilder(Users, 'u')
.select([dateAdd('u.created_at', 10).as('d')])
.select([dateAdd(t.em.getPlatform(), 'u.created_at', 10).as('d')])
.where({ id: user.id })
.execute('get', false);
expect(row).toEqual({ d: '2026-10-01' });
@@ -36,30 +38,38 @@ describe('sql-functions (sqlite)', () => {
it('SQLF-003: currentTimestamp is the DB clock in the wire format', async () => {
createUser(testDb);
const row = await t.em.createQueryBuilder(Users, 'u')
.select([currentTimestamp().as('now')])
.select([currentTimestamp(t.em.getPlatform()).as('now')])
.limit(1)
.execute('get', false);
expect(String((row as { now: string }).now)).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
});
it('SQLF-004: dateOf rejects anything that is not a plain column reference', () => {
expect(() => dateOf('u.created_at); DROP TABLE users; --')).toThrow(/not a column reference/);
expect(() => dateOf('u.created_at.extra')).toThrow(/not a column reference/);
expect(() => dateOf('')).toThrow(/not a column reference/);
expect(() => dateOf(t.em.getPlatform(), 'u.created_at); DROP TABLE users; --')).toThrow(/not a column reference/);
expect(() => dateOf(t.em.getPlatform(), 'u.created_at.extra')).toThrow(/not a column reference/);
expect(() => dateOf(t.em.getPlatform(), '')).toThrow(/not a column reference/);
});
it('SQLF-005: dateAdd rejects a non-integer day count', () => {
expect(() => dateAdd('u.created_at', 1.5)).toThrow(/integer day count/);
expect(() => dateAdd('u.created_at', Number.NaN)).toThrow(/integer day count/);
expect(() => dateAdd(t.em.getPlatform(), 'u.created_at', 1.5)).toThrow(/integer day count/);
expect(() => dateAdd(t.em.getPlatform(), 'u.created_at', Number.NaN)).toThrow(/integer day count/);
});
it('SQLF-006: dateAdd shifts backwards for a negative day count', async () => {
const { user } = createUser(testDb);
testDb.prepare("UPDATE users SET created_at = '2026-09-21 13:05:09' WHERE id = ?").run(user.id);
const row = await t.em.createQueryBuilder(Users, 'u')
.select([dateAdd('u.created_at', -10).as('d')])
.select([dateAdd(t.em.getPlatform(), 'u.created_at', -10).as('d')])
.where({ id: user.id })
.execute('get', false);
expect(row).toEqual({ d: '2026-09-11' });
});
it('SQLF-007: an unknown platform fails closed instead of guessing a spelling', () => {
class FakePlatform extends Platform {}
const foreign = new FakePlatform();
expect(() => dateOf(foreign, 'u.created_at')).toThrow(/no implementation for platform FakePlatform/);
expect(() => dateAdd(foreign, 'u.created_at', 1)).toThrow(/no implementation for platform FakePlatform/);
expect(() => currentTimestamp(foreign)).toThrow(/no implementation for platform FakePlatform/);
});
});
+1 -1
View File
@@ -170,7 +170,7 @@ export default defineConfig({
'src/db/**/*.ts': { statements: 73, branches: 38, functions: 59, lines: 80 },
'src/db/dialect/**/*.ts': { statements: 99, branches: 99, functions: 99, lines: 99 },
'src/db/repositories/**/*.ts': { statements: 99, branches: 99, functions: 99, lines: 99 },
'src/db/types/**/*.ts': { statements: 89, branches: 83, functions: 99, lines: 89 },
'src/db/types/**/*.ts': { statements: 93, branches: 99, functions: 99, lines: 93 },
'src/mcp/**/*.ts': { statements: 58, branches: 43, functions: 63, lines: 60 },
'src/middleware/**/*.ts': { statements: 91, branches: 89, functions: 87, lines: 94 },
// The folded-in nest-mcp decorator/registry layer keeps the 80% floor