fix(db): generator emits the referenced column for non-PK foreign keys; OAuth client joins through the relation; FK parity un-skipped

This commit is contained in:
jubnl
2026-09-22 21:53:34 +02:00
parent 80a0fdc4d9
commit 8ba55bda30
10 changed files with 479 additions and 145 deletions
-23
View File
@@ -1,6 +1,5 @@
import { raw, type Platform, type RawQueryFragment } from '@mikro-orm/core';
import { SqlitePlatform } from '@mikro-orm/sql';
import { sql, type Expression } from 'kysely';
/**
* The only place a repository may spell a database function.
@@ -101,25 +100,3 @@ export function lower(platform: Platform, ref: string): RawQueryFragment & symbo
return unsupported(platform);
}
/**
* `CURRENT_TIMESTAMP`, for a query built through `em.getKysely()` — the
* escape hatch for a recursive CTE (`OauthTokensRepository.collectChainIds`)
* or a join the QueryBuilder's relation metadata cannot express correctly
* (`OauthTokensRepository.listActiveByUser`/`listAllActiveWithClientAndUser`
* — `oauth_tokens.client_id` references `oauth_clients.client_id`, not its
* primary key, and the generated entity's `client` relation has no
* `referencedColumnNames` override, so a QueryBuilder `.innerJoin('ot.client',
* …)` silently joins on the wrong column; see those methods' docstrings).
* Kysely has its own expression system, entirely separate from MikroORM's
* `raw()` — `currentTimestamp` above returns a value this file's OTHER
* consumers (the QueryBuilder, `nativeUpdate`) accept, but a Kysely query
* cannot use a `RawQueryFragment` as an operand. This is the one place a
* repository may reach for the Kysely spelling of the same literal, kept
* here rather than in a repository file so `no-restricted-syntax`'s ban on
* spelling raw SQL directly in `src/db/repositories/**` still holds — this
* file is exempt from that rule by design (it IS the one approved place).
*/
export function currentTimestampKysely(platform: Platform): Expression<string> {
if (platform instanceof SqlitePlatform) return sql<string>`CURRENT_TIMESTAMP`;
return unsupported(platform);
}
@@ -21,7 +21,7 @@ export const OauthConsentsSchema = defineEntity({
uniques: [{ properties: ['client', 'user'] }],
properties: {
id: p.integer().primary(),
client: () => p.manyToOne(OauthClients).ref().name('client_id').deleteRule('cascade').hidden(),
client: () => p.manyToOne(OauthClients).ref().name('client_id').deleteRule('cascade').hidden().referencedColumnNames('client_id'),
client_id: p.text().persist(false),
user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(),
user_id: p.integer().persist(false),
+1 -1
View File
@@ -33,7 +33,7 @@ export const OauthTokensSchema = defineEntity({
],
properties: {
id: p.integer().primary(),
client: () => p.manyToOne(OauthClients).ref().name('client_id').deleteRule('cascade').hidden(),
client: () => p.manyToOne(OauthClients).ref().name('client_id').deleteRule('cascade').hidden().referencedColumnNames('client_id'),
client_id: p.text().persist(false),
user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_oauth_tokens_user'),
user_id: p.integer().persist(false).index('idx_oauth_tokens_user'),
@@ -1,14 +1,17 @@
import { OauthTokens } from '../entities/OauthTokens.entity';
import { type AssertRowKeys } from './_shared/rows';
import { columnRef, currentTimestamp, currentTimestampKysely } from '../dialect/sql-functions';
import { columnRef, currentTimestamp } from '../dialect/sql-functions';
import { EntityRepository } from '@mikro-orm/sql';
/**
* The Kysely-side table shapes `em.getKysely()` needs for the two escape
* hatches in this repository (the recursive CTE and the `oauth_clients`
* join — see `collectChainIds`/`listActiveByUser`'s docstrings for why the
* QueryBuilder can't express either). Column-shaped, not entity-shaped: this
* is the raw driver's view of the tables, independent of MikroORM metadata.
* The Kysely-side table shape `em.getKysely()` needs for `collectChainIds`'s
* recursive CTE (D4's T6 tier — the QueryBuilder has no recursive-CTE
* support at all, so this one escape hatch stays regardless of the `client`
* relation's referenced column). `oauth_clients`/`users` are no longer part
* of this interface: `listActiveByUser`/`listAllActiveWithClientAndUser`
* (OA26/OA31) moved to the QueryBuilder once the generator learned to emit
* `.referencedColumnNames('client_id')` for `OauthTokens.client` (Plan 3b
* interlude A) — see those methods' docstrings.
*/
interface OauthTokensKyselyDB {
oauth_tokens: {
@@ -22,14 +25,6 @@ interface OauthTokensKyselyDB {
created_at: string | null;
parent_token_id: number | null;
};
oauth_clients: {
client_id: string;
name: string;
};
users: {
id: number;
username: string;
};
}
/** `OauthService.issueTokens`/`issueClientCredentialsToken`'s write (OA13/OA14) — same column set, one method. */
@@ -146,27 +141,25 @@ const _oauthTokenScalarProbe: AssertRowKeys<OauthTokenScalarColumns, OauthTokens
* `OauthService` does (`hashToken`/`generateAccessToken`/…), this
* repository only ever sees an already-computed hash string.
*
* **The `client` relation targets the wrong column.** The generator built
* `client: p.manyToOne(OauthClients).ref().name('client_id')` with no
* `referencedColumnNames` override, so MikroORM's QueryBuilder joins it as
* `ot.client_id = oc.id` (`OauthClients`'s primary key) — verified
* empirically (`qb.getFormattedQuery()`). The actual schema
* (`Migration20200101012500_oauth_2.ts`) has `oauth_tokens.client_id
* REFERENCES oauth_clients(client_id)`, the UNIQUE natural key, not `oc.id`
* — the two are independently generated UUIDs
* (`OauthService.createOAuthClient`'s `id`/`clientId` locals). A plain
* equality filter/write on the `client` property (`{ client: clientId }` in
* a `find`/`nativeUpdate`) is unaffected — it binds the given scalar
* straight into the `client_id` column, no join involved, verified
* empirically — but an ORM-level JOIN through this relation
* (`.innerJoin('ot.client', 'oc')`) silently returns the WRONG rows. This
* repository never joins that relation: `listActiveByUser`/
* `listAllActiveWithClientAndUser` (OA26/OA31, the two legacy statements
* that actually join `oauth_clients`) go through `em.getKysely()` instead,
* joining on the real `oc.client_id` column directly, entity metadata
* bypassed entirely. This is a pre-existing entity-generation defect
* (outside this task's scope to fix — `src/db/entities/**` is off limits
* here); flagged for Task 7 / the entity generator's own follow-up.
* **The `client` relation now correctly targets `oauth_clients.client_id`.**
* The schema (`Migration20200101012500_oauth_2.ts`) has `oauth_tokens.
* client_id REFERENCES oauth_clients(client_id)`, the UNIQUE natural key —
* NOT `oauth_clients.id`, its primary key; the two are independently
* generated UUIDs (`OauthService.createOAuthClient`'s `id`/`clientId`
* locals). Task 4 found the generated entity's `client` relation had no
* `referencedColumnNames` override and so silently joined on `oc.id`
* instead — a pre-existing `@mikro-orm/entity-generator` 7.2.1 defect (a
* single-column FK's renderer never emits `.referencedColumnNames(...)`
* unless the target's own PK has a DIFFERENT column count, never a
* different column identity) — and worked around it with `em.getKysely()`
* for the two legacy statements that actually join `oauth_clients`
* (OA26/OA31). Plan 3b interlude A (`RULE11_referencedColumns` in
* `scripts/generate-entities.ts`) closed that gap at the generator level:
* `OauthTokens.entity.ts`'s `client` relation now carries an explicit
* `.referencedColumnNames('client_id')`, so `.innerJoin('ot.client', 'oc')`
* joins on the real column — verified empirically (`qb.getFormattedQuery()`,
* OAUTHTOKREPO-030) — and `listActiveByUser`/`listAllActiveWithClientAndUser`
* below use the QueryBuilder like every other join in this file.
*/
export class OauthTokensRepository extends EntityRepository<OauthTokens> {
// ---------------------------------------------------------------------
@@ -260,8 +253,9 @@ export class OauthTokensRepository extends EntityRepository<OauthTokens> {
* (unlike `ot.client`) is a NORMAL relation — its column
* (`user_id`, default naming, no override) genuinely references
* `users.id`, verified empirically — so `.innerJoin('ot.user', 'u')` joins
* correctly and this stays on the QueryBuilder (D4's T5 tier), unlike
* OA26/OA31 below. `columnRef` for both FK scalars in the select list:
* correctly and this stays on the QueryBuilder (D4's T5 tier) — as do
* OA26/OA31 below, since Plan 3b interlude A. `columnRef` for both FK
* scalars in the select list:
* `ot.user_id` because the same relation is also joined (the
* `McpTokensRepository.listAllWithUsername` finding — selecting a
* relation property while also joining it collapses into the join rather
@@ -502,30 +496,35 @@ export class OauthTokensRepository extends EntityRepository<OauthTokens> {
* ORDER BY ot.created_at DESC
* ```
*
* `em.getKysely()`, NOT the QueryBuilder (this class's docstring): the
* join is `ot.client_id = oc.client_id`, the real FK target per the
* migration, which `.innerJoin('ot.client', 'oc')` cannot express
* correctly (it would join `ot.client_id = oc.id` instead — silently
* wrong, not an error). `currentTimestampKysely` for the same
* `CURRENT_TIMESTAMP` literal `currentTimestamp` spells for the
* QueryBuilder/`nativeUpdate` — Kysely has its own expression system and
* cannot consume a MikroORM `RawQueryFragment` directly (see
* `sql-functions.ts`'s docstring on that helper). Verified empirically
* against a real seeded row before committing to this shape (see the
* task report).
* The QueryBuilder (Plan 3b interlude A — this class's docstring):
* `.innerJoin('ot.client', 'oc')` now joins on the real `oc.client_id`
* column, the generated entity's `client` relation carrying an explicit
* `.referencedColumnNames('client_id')`. `columnRef(platform,
* 'ot.client_id')` in the select list for the same reason OA16's
* `findByAccessTokenHashWithUser` needs it: selecting a relation property
* while ALSO joining that same relation collapses into the join rather
* than emitting the FK scalar column (the `McpTokensRepository
* .listAllWithUsername` finding). `{ $gt: currentTimestamp(platform) }`
* for the `refresh_token_expires_at > CURRENT_TIMESTAMP` predicate — a
* `RawQueryFragment` is a normal filter value here, no Kysely-specific
* spelling needed once the query itself is a QueryBuilder query.
*/
async listActiveByUser(userId: number): Promise<OauthSessionRow[]> {
const platform = this.getEntityManager().getPlatform();
const rows = await this.getEntityManager()
.getKysely<OauthTokensKyselyDB>()
.selectFrom('oauth_tokens as ot')
.innerJoin('oauth_clients as oc', 'oc.client_id', 'ot.client_id')
.select(['ot.id', 'ot.client_id', 'oc.name as client_name', 'ot.scopes', 'ot.access_token_expires_at', 'ot.refresh_token_expires_at', 'ot.created_at'])
.where('ot.user_id', '=', userId)
.where('ot.revoked_at', 'is', null)
.where('ot.refresh_token_expires_at', '>', currentTimestampKysely(platform))
.orderBy('ot.created_at', 'desc')
.execute();
const rows = await this.qb('ot')
.innerJoin('ot.client', 'oc')
.select([
'ot.id',
columnRef(platform, 'ot.client_id'),
'oc.name as client_name',
'ot.scopes',
'ot.access_token_expires_at',
'ot.refresh_token_expires_at',
'ot.created_at',
])
.where({ 'ot.user_id': userId, 'ot.revoked_at': null, 'ot.refresh_token_expires_at': { $gt: currentTimestamp(platform) } })
.orderBy({ 'ot.created_at': 'desc' })
.execute<OauthSessionRow[]>('all', false);
return rows.map((row) => ({
id: row.id,
client_id: row.client_id,
@@ -562,25 +561,37 @@ export class OauthTokensRepository extends EntityRepository<OauthTokens> {
* ORDER BY ot.created_at DESC
* ```
*
* `em.getKysely()`, same reason as `listActiveByUser` (OA26) — the
* `oauth_clients` join. `users` joins correctly either way (its relation
* is normally mapped), joined here through Kysely too for one consistent
* query shape rather than mixing a QueryBuilder join into a Kysely query.
* The QueryBuilder, same reason as `listActiveByUser` (OA26) — the
* `client` relation now carries the real referenced column (see this
* class's docstring). `users` joins correctly either way (`ot.user` is a
* normal relation), joined here through the QueryBuilder too for one
* consistent query shape rather than a Kysely query. `columnRef` for both
* `client_id`/`user_id`: both relations are also joined in this same
* query, so a plain `'ot.client_id'`/`'ot.user_id'` select would collapse
* into their respective joins (same finding as `listActiveByUser`/OA16).
* Malformed `scopes` JSON degrading to `null` per-row (not a 500) stays
* in the SERVICE, unchanged — this repository returns the raw column
* text.
*/
async listAllActiveWithClientAndUser(): Promise<OauthSessionWithUserRow[]> {
const rows = await this.getEntityManager()
.getKysely<OauthTokensKyselyDB>()
.selectFrom('oauth_tokens as ot')
.innerJoin('oauth_clients as oc', 'oc.client_id', 'ot.client_id')
.innerJoin('users as u', 'u.id', 'ot.user_id')
.select(['ot.id', 'ot.client_id', 'oc.name as client_name', 'ot.user_id', 'u.username', 'ot.scopes', 'ot.access_token_expires_at', 'ot.refresh_token_expires_at', 'ot.created_at'])
.where('ot.revoked_at', 'is', null)
.where('ot.refresh_token_expires_at', '>', currentTimestampKysely(this.getEntityManager().getPlatform()))
.orderBy('ot.created_at', 'desc')
.execute();
const platform = this.getEntityManager().getPlatform();
const rows = await this.qb('ot')
.innerJoin('ot.client', 'oc')
.innerJoin('ot.user', 'u')
.select([
'ot.id',
columnRef(platform, 'ot.client_id'),
'oc.name as client_name',
columnRef(platform, 'ot.user_id'),
'u.username',
'ot.scopes',
'ot.access_token_expires_at',
'ot.refresh_token_expires_at',
'ot.created_at',
])
.where({ 'ot.revoked_at': null, 'ot.refresh_token_expires_at': { $gt: currentTimestamp(platform) } })
.orderBy({ 'ot.created_at': 'desc' })
.execute<OauthSessionWithUserRow[]>('all', false);
return rows.map((row) => ({
id: row.id,
client_id: row.client_id,
+33 -20
View File
@@ -3,28 +3,41 @@
* numeric primary key before it reaches a typed repository filter.
*
* The legacy raw-SQL layer bound a route param straight into `WHERE id = ?`
* and let SQLite's column-affinity rules do the work: a well-formed numeric
* string ('5', ' 5', '5.0') converts and matches, anything else ('abc', '',
* '0x10', '1e3') stays TEXT, can never equal an INTEGER column, and the
* lookup simply misses — answered with the route's ordinary not-found. A
* typed MikroORM filter has no such affinity: `Number('abc')` is `NaN`, and
* the ORM renders that as the bare, unquoted token `NaN` in the generated
* SQL, which SQLite parses as a column reference and throws
* `InvalidFieldNameException: no such column: NaN` — a 500 where the legacy
* route 404'd (Plan 3b Task 2 review, F1).
* and let SQLite's column-affinity rules do the work. Those rules are more
* permissive than they look: SQLite applies NUMERIC affinity to a TEXT
* operand compared against an INTEGER column using the same conversion CAST
* uses, which accepts leading/trailing whitespace, a leading sign, a decimal
* point and an exponent — so `WHERE id = ?` genuinely matches integer row 1
* for `' 1'`, `'1 '`, `'1.0'` and `'+1'`, and matches row 1000 for `'1e3'`
* (verified empirically against `better-sqlite3`, not assumed — this
* docstring previously claimed the opposite, that `'1e3'` "can never equal
* an INTEGER column"; that claim was false). A typed MikroORM filter has no
* such affinity: `Number('abc')` is `NaN`, and the ORM renders that as the
* bare, unquoted token `NaN` in the generated SQL, which SQLite parses as a
* column reference and throws `InvalidFieldNameException: no such column:
* NaN` — a 500 where the legacy route 404'd (Plan 3b Task 2 review, F1).
*
* `toRowId` is the guard every converted service calls instead of a bare
* `Number(id)`: it accepts only what the legacy binding actually matched —
* digits only, no sign, no decimal point, no exponent, no hex — and returns
* `null` for everything else, which the caller answers with the route's own
* legacy not-found result BEFORE any repository call (program rule 15,
* `docs/superpowers/plans/2026-09-21-orm-migration-program.md`).
*
* A `/^\d+$/` string match is deliberately stricter than `Number.isSafeInteger`
* alone: `Number('0x10')` is `16` and `Number('1e3')` is `1000`, both safe
* integers a bare `Number()` conversion would have accepted even though
* SQLite's affinity conversion never would have matched either shape against
* an INTEGER column. Requiring the digits-only shape first closes that gap.
* `Number(id)`. It does NOT reproduce every string SQLite's affinity
* conversion would have matched — that would mean accepting `' 1'`, `'1.0'`,
* `'1e3'`, `'+1'`, and there is no realistic route/tool input that spells an
* id that way: every id in this codebase originates as
* `String(<integer column value>)` on our own client (a URL path segment or
* an MCP tool argument), which is always a canonical decimal string, digits
* only, no sign, no leading zero beyond what the id itself is, no decimal
* point, no exponent, no surrounding whitespace. `toRowId` accepts exactly
* that canonical shape (`/^\d+$/`, so `'007'` — an id that happens to have
* leading zeros, which SQLite's own affinity conversion also resolves to 7 —
* still matches) and returns `null` for every non-canonical spelling,
* INCLUDING the ones SQLite's affinity would have matched. This is a
* DELIBERATE, ACCEPTED narrowing (program rule 15,
* `docs/superpowers/plans/2026-09-21-orm-migration-program.md`): a
* non-canonical id can only ever reach this guard from something other than
* our own client (a hand-crafted request), and failing closed to the
* route's legacy not-found result for it is the documented parity
* deviation, not a bug — never widen the regex to `/^(0|[1-9]\d*)$/` or any
* other shape that would reject `'007'`, and never widen it to accept the
* divergent shapes above either.
*/
export function toRowId(value: unknown): number | null {
if (typeof value === 'number') {