mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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:
@@ -823,6 +823,78 @@ export function RULE10_repositoryTypeMarker(metadata: EntityMetadata[]): Reposit
|
||||
return fixups;
|
||||
}
|
||||
|
||||
/** An owning relation whose FK targets a non-PK column on its target entity — Rule 11. */
|
||||
export interface ReferencedColumnsFixup {
|
||||
className: string;
|
||||
propName: string;
|
||||
referencedColumnNames: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule 11 (metadata level: detection only, no mutation; text level: the
|
||||
* actual insertion — see `injectReferencedColumns` below. This split, and why
|
||||
* it cannot be a pure metadata rule, mirrors Rule 5's `.joinColumn()`
|
||||
* injection and Rule 10's repository marker.
|
||||
*
|
||||
* `@mikro-orm/sql`'s SQLite introspection (`SqliteSchemaHelper.js`, straight
|
||||
* from `PRAGMA foreign_key_list`'s `to` column) already sets
|
||||
* `prop.referencedColumnNames` correctly for every owning to-one relation —
|
||||
* confirmed empirically with a throwaway `onProcessedMetadata` probe against
|
||||
* the real migrated schema, BEFORE any rule in this file runs:
|
||||
* `OauthTokens.client`/`OauthConsents.client` already carry
|
||||
* `referencedColumnNames: ['client_id']` (the schema's real FK target,
|
||||
* `Migration20200101012500_oauth_2.ts`'s `REFERENCES oauth_clients(client_id)`
|
||||
* — a UNIQUE natural key, not `OauthClients`'s PK, `id`), while every other
|
||||
* owning relation in the whole schema (`OauthTokens.user`, `.parentToken`,
|
||||
* `SchoolHolidayRegions.countryRef` -> `school_holiday_countries(code)`,
|
||||
* which IS that table's PK, …) already carries `referencedColumnNames` equal
|
||||
* to its target's own PK.
|
||||
*
|
||||
* The generator's own RENDERER never emits this correct, already-introspected
|
||||
* fact into the generated text, though — it is not something an earlier rule
|
||||
* in this file drops. Read directly out of
|
||||
* `node_modules/@mikro-orm/entity-generator/{EntityGenerator,SourceFile}.js`,
|
||||
* not assumed: `SourceFile.js#getForeignKeyDecoratorOptions` (shared by every
|
||||
* output mode, including `defineEntity`, through
|
||||
* `EntitySchemaSourceFile.getPropertyOptions` -> `DefineEntitySourceFile`)
|
||||
* only ever emits `.referencedColumnNames([...])` when
|
||||
* `prop.ownColumns && prop.ownColumns.length !== prop.fieldNames.length` — a
|
||||
* COMPOSITE-FK partial-ownership column-COUNT mismatch, never a check of
|
||||
* whether the referenced column differs from the target's PK. `prop.ownColumns`
|
||||
* itself is only ever set by `EntityGenerator.js#generate()` when
|
||||
* `targetPrimaryColumns.length !== prop.referencedColumnNames.length` — again
|
||||
* a length comparison, not a content comparison. For a single-column FK
|
||||
* against a single-column PK (exactly the `client_id` case here), both
|
||||
* lengths are 1 either way, so `ownColumns` is never set and the renderer's
|
||||
* emission check never fires, no matter which column is actually referenced.
|
||||
* This is a real gap in `@mikro-orm/entity-generator` 7.2.1's own renderer,
|
||||
* not a defect our earlier text passes introduce or could avoid at the
|
||||
* metadata level.
|
||||
*
|
||||
* Detection compares `prop.referencedColumnNames` (already correct) against
|
||||
* the target entity's OWN primary-key field names
|
||||
* (`targetMeta.getPrimaryProps().flatMap(pk => pk.fieldNames)`) — never a
|
||||
* hard-coded table list, so a future migration adding another FK against a
|
||||
* non-PK column is caught automatically the next time this generator runs,
|
||||
* not silently missed.
|
||||
*/
|
||||
export function RULE11_referencedColumns(metadata: EntityMetadata[]): ReferencedColumnsFixup[] {
|
||||
const fixups: ReferencedColumnsFixup[] = [];
|
||||
for (const meta of metadata) {
|
||||
for (const prop of meta.relations) {
|
||||
if (!isOwningToOne(prop)) continue;
|
||||
const targetMeta = metadata.find((m) => m.className === prop.type);
|
||||
if (!targetMeta) continue; // unresolved relation target — nothing this rule can compare against
|
||||
const targetPk = targetMeta.getPrimaryProps().flatMap((pk) => pk.fieldNames);
|
||||
const referenced = prop.referencedColumnNames ?? [];
|
||||
const matchesTargetPk = referenced.length === targetPk.length && referenced.every((column, i) => column === targetPk[i]);
|
||||
if (matchesTargetPk) continue;
|
||||
fixups.push({ className: meta.className, propName: prop.name, referencedColumnNames: [...referenced] });
|
||||
}
|
||||
}
|
||||
return fixups;
|
||||
}
|
||||
|
||||
/** A scalar property whose literal default the renderer's own heuristic drops or mis-renders — see below. */
|
||||
export interface DefaultFixup {
|
||||
className: string;
|
||||
@@ -899,6 +971,8 @@ export interface RuleFixups {
|
||||
retypedScalars: RetypedScalarFixup[];
|
||||
/** Rule 10's repository-type markers — one per entity class. */
|
||||
repositoryMarkers: RepositoryTypeMarkerFixup[];
|
||||
/** Rule 11's non-PK FK referenced-column fixups. */
|
||||
referencedColumns: ReferencedColumnsFixup[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -932,6 +1006,10 @@ export function applyRules(
|
||||
RULE9_addImplicitUniqueConstraints(metadata, implicitUniques);
|
||||
const defaults = RULE_normalizeLiteralDefaults(metadata);
|
||||
const repositoryMarkers = RULE10_repositoryTypeMarker(metadata);
|
||||
// Runs after RULE5 so it reads the FINAL (post-rename) owning-relation
|
||||
// property names — the same names the text pass below must find in the
|
||||
// rendered `properties: {...}` block.
|
||||
const referencedColumns = RULE11_referencedColumns(metadata);
|
||||
return {
|
||||
joinColumns,
|
||||
defaults,
|
||||
@@ -939,6 +1017,7 @@ export function applyRules(
|
||||
jsonColumns,
|
||||
retypedScalars: [...retypedByRule1, ...timestamps, ...jsonColumns],
|
||||
repositoryMarkers,
|
||||
referencedColumns,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1444,6 +1523,43 @@ export function injectRepositoryTypeMarker(source: string, fixups: readonly Repo
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text half of Rule 11: appends `.referencedColumnNames(<col>, …)` to a
|
||||
* FK relation's property line whenever the renderer left it out despite the
|
||||
* metadata already carrying the real (non-PK) referenced column — see
|
||||
* `RULE11_referencedColumns`'s doc comment for why the renderer never emits
|
||||
* this itself for a single-column FK. `p.manyToOne(...)`'s
|
||||
* `.referencedColumnNames(...)` is a rest-args method (confirmed against the
|
||||
* installed `node_modules/@mikro-orm/core/entity/defineEntity.d.ts`), so this
|
||||
* renders bare comma-separated quoted column names, never a `[...]` array
|
||||
* literal. A property line that already has an explicit
|
||||
* `.referencedColumnNames(` call is a legitimate no-op (never observed today
|
||||
* — the renderer's own emission path never fires for this shape, see the
|
||||
* rule's doc comment — but a future generator upgrade could change that);
|
||||
* anything else finding no trailing comma to anchor on throws.
|
||||
*/
|
||||
export function injectReferencedColumns(source: string, fixups: readonly ReferencedColumnsFixup[]): string {
|
||||
let result = source;
|
||||
for (const { className, propName, referencedColumnNames } of fixups) {
|
||||
const match = findPropertyBuilderLine(result, propName);
|
||||
if (!match) {
|
||||
throw new Error(`generate-entities: injectReferencedColumns could not find the property line for "${className}.${propName}".`);
|
||||
}
|
||||
const line = match[1];
|
||||
if (line.includes('.referencedColumnNames(')) continue; // already explicit — nothing to do
|
||||
const quoted = referencedColumnNames.map((column) => `'${column}'`).join(', ');
|
||||
const appended = appendCallBeforeTrailingComma(line, `.referencedColumnNames(${quoted})`);
|
||||
if (appended === undefined) {
|
||||
throw new Error(
|
||||
`generate-entities: injectReferencedColumns found the property line for "${className}.${propName}" but could not append ` +
|
||||
`.referencedColumnNames(${quoted}) to it — check the renderer shape.`,
|
||||
);
|
||||
}
|
||||
result = result.slice(0, match.index) + appended + result.slice(match.index + line.length);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Every text pass, applied in order. */
|
||||
export function applyTextPasses(source: string, fixups: RuleFixups): string {
|
||||
let result = injectJoinColumns(source, fixups.joinColumns);
|
||||
@@ -1456,6 +1572,7 @@ export function applyTextPasses(source: string, fixups: RuleFixups): string {
|
||||
result = removeUnusedCoreImportIfUnused(result, 'IType');
|
||||
result = stripHiddenTypeAnnotation(result);
|
||||
result = injectRepositoryTypeMarker(result, fixups.repositoryMarkers);
|
||||
result = injectReferencedColumns(result, fixups.referencedColumns);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1550,6 +1667,7 @@ export async function generateEntities(): Promise<GenerateResult> {
|
||||
jsonColumns: [],
|
||||
retypedScalars: [],
|
||||
repositoryMarkers: [],
|
||||
referencedColumns: [],
|
||||
};
|
||||
const rawFiles = await generator.generate({
|
||||
entityDefinition: 'defineEntity',
|
||||
@@ -1585,6 +1703,7 @@ export async function generateEntities(): Promise<GenerateResult> {
|
||||
jsonColumns: fixups.jsonColumns.filter((f) => f.className === className),
|
||||
retypedScalars: fixups.retypedScalars.filter((f) => f.className === className),
|
||||
repositoryMarkers: fixups.repositoryMarkers.filter((f) => f.className === className),
|
||||
referencedColumns: fixups.referencedColumns.filter((f) => f.className === className),
|
||||
};
|
||||
files.set(`${className}.entity.ts`, applyTextPasses(raw, relevantFixups));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -303,34 +303,83 @@ describe('entity ↔ migrated-schema parity', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* FK targets/deleteRule and index names, against `PRAGMA foreign_key_list`
|
||||
* and `PRAGMA index_list` — the plan's second, allow-listed check
|
||||
* (Task 0, Step 3). 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` now has a real, explicit
|
||||
* `joinColumn`, so it reports a genuine FK row) and re-ran this check
|
||||
* against the other 124 rewritten/added entities, composite-PK relations
|
||||
* included (`packing_item_contributors`, `vacay_user_settings`,
|
||||
* `document_connections`, `trip_document_links` — their `.entity.ts`
|
||||
* source never spells `.deleteRule(...)` on those relations, but MikroORM
|
||||
* discovery still resolves the correct rule at runtime, confirmed by
|
||||
* running this check, so they are not drift): the count is now exactly 10,
|
||||
* unchanged in substance from Task 0's list minus the one now-fixed row
|
||||
* (`trip_members.invited_by` entity=set null vs db=no action,
|
||||
* `roadtrip_day_tracks.day_id` entity=set null vs db=cascade,
|
||||
* `reservation_travelers.reservation_id`/`.user_id` and
|
||||
* 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 stays skipped — see
|
||||
* `PARITY-009b` immediately below for why and by how many rows.
|
||||
*/
|
||||
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). 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` now has a real, explicit `joinColumn`, so it reports a genuine
|
||||
* FK row) and re-ran this check against the other 124 rewritten/added
|
||||
* entities, composite-PK relations included (`packing_item_contributors`,
|
||||
* `vacay_user_settings`, `document_connections`, `trip_document_links` —
|
||||
* their `.entity.ts` source never spells `.deleteRule(...)` on those
|
||||
* relations, but MikroORM discovery still resolves the correct rule at
|
||||
* runtime, confirmed by running this check, so they are not drift): the
|
||||
* count was 10. Plan 3b interlude A's `RULE11_referencedColumns` only
|
||||
* touches the referenced COLUMN, never `deleteRule` (`OauthTokens.client`/
|
||||
* `OauthConsents.client` already agreed on `deleteRule('cascade')` —
|
||||
* unaffected either way), so re-running this exact query still finds
|
||||
* **10** disagreements, unchanged: `trip_members.invited_by` entity=set
|
||||
* null vs db=no action, `roadtrip_day_tracks.day_id` entity=set null vs
|
||||
* db=cascade, `reservation_travelers.reservation_id`/`.user_id` and
|
||||
* `assignment_participants.assignment_id`/`.user_id` entity=no action vs
|
||||
* db=cascade, `oauth_tokens.parent_token_id` entity=set null vs db=no
|
||||
* action, `journey_contributors.user_id` entity=cascade vs db=no action,
|
||||
* `budget_settlements.created_by_user_id`/`budget_items.paid_by_user_id`
|
||||
* entity=set null vs db=no action). None of these are in Task 0's or
|
||||
* Task 3's scope; an allow-list would need 10 one-line entries for drift
|
||||
* neither task was asked to fix, and index-name parity looked clean on
|
||||
* inspection but was not exhaustively checked. Left skipped, with the
|
||||
* findings above, for Plan 4 to turn into either fixes or a real
|
||||
* allow-list.
|
||||
* entity=set null vs db=no action. None of these are in this task's scope
|
||||
* (or Task 0's/Task 3's); an allow-list would need 10 one-line entries for
|
||||
* drift no task so far was asked to fix. Index-name parity looked clean on
|
||||
* inspection but was not exhaustively checked either, and stays out of
|
||||
* scope here too. Left skipped, with the count pinned above (Plan 4 owns
|
||||
* turning this into either fixes or a real allow-list — the number to
|
||||
* reconcile against is exactly 10, not "some").
|
||||
*/
|
||||
it.skip('PARITY-009 (report-only, deferred to Plan 4): FK deleteRule and index names agree', () => {
|
||||
it.skip('PARITY-009b (report-only, deferred to Plan 4): FK deleteRule agrees — 10 known disagreements, see doc comment', () => {
|
||||
const failures: string[] = [];
|
||||
for (const meta of tableBackedMetas()) {
|
||||
const table = meta.tableName;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
JoinColumnFixup,
|
||||
JsonColumnFixup,
|
||||
KNOWN_DIFFS,
|
||||
ReferencedColumnsFixup,
|
||||
RepositoryTypeMarkerFixup,
|
||||
RULE1_fixUnknownScalarTypes,
|
||||
RULE1b_markJsonColumns,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
RULE8_bindRepositories,
|
||||
RULE9_addImplicitUniqueConstraints,
|
||||
RULE10_repositoryTypeMarker,
|
||||
RULE11_referencedColumns,
|
||||
RULE_normalizeLiteralDefaults,
|
||||
applyTextPasses,
|
||||
checkEntities,
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
injectJsonInterfaces,
|
||||
injectJsonTypeParams,
|
||||
injectMissingDefaults,
|
||||
injectReferencedColumns,
|
||||
injectRepositoryTypeMarker,
|
||||
regenerateEntitiesIndex,
|
||||
stripHiddenTypeAnnotation,
|
||||
@@ -437,6 +440,58 @@ describe('RULE10_repositoryTypeMarker', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('RULE11_referencedColumns', () => {
|
||||
it('RULE11-001: an FK targeting its target\'s own single-column PK gets no fixup (the common case)', () => {
|
||||
const rel = fixtureProp({ name: 'user', kind: ReferenceKind.MANY_TO_ONE, fieldNames: ['user_id'], type: 'Users', referencedColumnNames: ['id'] });
|
||||
const owner = fixtureMeta('OauthTokens', 'oauth_tokens', [rel]);
|
||||
const target = fixtureMeta('Users', 'users', [fixtureProp({ name: 'id', primary: true })]);
|
||||
const fixups = RULE11_referencedColumns([owner, target]);
|
||||
expect(fixups).toEqual([]);
|
||||
});
|
||||
|
||||
it('RULE11-002: an FK targeting a non-PK column (the real oauth_tokens.client_id -> oauth_clients.client_id case) gets a fixup', () => {
|
||||
const rel = fixtureProp({ name: 'client', kind: ReferenceKind.MANY_TO_ONE, fieldNames: ['client_id'], type: 'OauthClients', referencedColumnNames: ['client_id'] });
|
||||
const owner = fixtureMeta('OauthTokens', 'oauth_tokens', [rel]);
|
||||
const target = fixtureMeta('OauthClients', 'oauth_clients', [fixtureProp({ name: 'id', primary: true })]);
|
||||
const fixups = RULE11_referencedColumns([owner, target]);
|
||||
expect(fixups).toEqual<ReferencedColumnsFixup[]>([{ className: 'OauthTokens', propName: 'client', referencedColumnNames: ['client_id'] }]);
|
||||
});
|
||||
|
||||
it('RULE11-003: a text PK target whose FK genuinely references that PK (school_holiday_regions.country -> school_holiday_countries.code) gets no fixup', () => {
|
||||
const rel = fixtureProp({ name: 'countryRef', kind: ReferenceKind.MANY_TO_ONE, fieldNames: ['country'], type: 'SchoolHolidayCountries', referencedColumnNames: ['code'] });
|
||||
const owner = fixtureMeta('SchoolHolidayRegions', 'school_holiday_regions', [rel]);
|
||||
const target = fixtureMeta('SchoolHolidayCountries', 'school_holiday_countries', [fixtureProp({ name: 'code', primary: true })]);
|
||||
const fixups = RULE11_referencedColumns([owner, target]);
|
||||
expect(fixups).toEqual([]);
|
||||
});
|
||||
|
||||
it('RULE11-004: an unresolved relation target (no matching metadata) is skipped, not thrown or flagged', () => {
|
||||
const rel = fixtureProp({ name: 'something', kind: ReferenceKind.MANY_TO_ONE, fieldNames: ['something_id'], type: 'Nowhere', referencedColumnNames: ['id'] });
|
||||
const owner = fixtureMeta('X', 'x', [rel]);
|
||||
expect(() => RULE11_referencedColumns([owner])).not.toThrow();
|
||||
expect(RULE11_referencedColumns([owner])).toEqual([]);
|
||||
});
|
||||
|
||||
it('RULE11-005: an inverse relation (oneToMany) is never a candidate — only an owning to-one is checked', () => {
|
||||
const inverse = fixtureProp({ name: 'oauth_tokens_collection', kind: ReferenceKind.ONE_TO_MANY, type: 'OauthTokens', mappedBy: 'client', referencedColumnNames: ['id'] });
|
||||
const owner = fixtureMeta('OauthClients', 'oauth_clients', [inverse]);
|
||||
const target = fixtureMeta('OauthTokens', 'oauth_tokens', [fixtureProp({ name: 'id', primary: true })]);
|
||||
const fixups = RULE11_referencedColumns([owner, target]);
|
||||
expect(fixups).toEqual([]);
|
||||
});
|
||||
|
||||
it('RULE11-006: a composite-PK target compares the full column list, in order', () => {
|
||||
const rel = fixtureProp({ name: 'setting', kind: ReferenceKind.MANY_TO_ONE, fieldNames: ['a', 'b'], type: 'Composite', referencedColumnNames: ['b', 'a'] });
|
||||
const owner = fixtureMeta('X', 'x', [rel]);
|
||||
const target = fixtureMeta('Composite', 'composite', [
|
||||
fixtureProp({ name: 'a', primary: true }),
|
||||
fixtureProp({ name: 'b', primary: true }),
|
||||
]);
|
||||
const fixups = RULE11_referencedColumns([owner, target]);
|
||||
expect(fixups).toEqual<ReferencedColumnsFixup[]>([{ className: 'X', propName: 'setting', referencedColumnNames: ['b', 'a'] }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RULE9_addImplicitUniqueConstraints', () => {
|
||||
it('RULE9-001: a table with a matching implicit-unique entry gets a uniques: block referencing the columns as properties — a plain (non-FK) column keeps its own name', () => {
|
||||
const userId = fixtureProp({ name: 'user_id', primary: false });
|
||||
@@ -806,6 +861,51 @@ describe('injectRepositoryTypeMarker', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectReferencedColumns', () => {
|
||||
it('TEXT-REFCOL-001: appends .referencedColumnNames(<col>) before the trailing comma (the real client_id shape)', () => {
|
||||
const source = " client: () => p.manyToOne(OauthClients).ref().name('client_id').deleteRule('cascade').hidden(),\n";
|
||||
const out = injectReferencedColumns(source, [{ className: 'OauthTokens', propName: 'client', referencedColumnNames: ['client_id'] }]);
|
||||
expect(out).toBe(" client: () => p.manyToOne(OauthClients).ref().name('client_id').deleteRule('cascade').hidden().referencedColumnNames('client_id'),\n");
|
||||
});
|
||||
|
||||
it('TEXT-REFCOL-002: multiple referenced columns render as bare comma-separated quoted strings, no array brackets (rest-args API)', () => {
|
||||
const source = ' setting: () => p.manyToOne(Composite).ref(),\n';
|
||||
const out = injectReferencedColumns(source, [{ className: 'X', propName: 'setting', referencedColumnNames: ['b', 'a'] }]);
|
||||
expect(out).toContain(".referencedColumnNames('b', 'a')");
|
||||
expect(out).not.toContain('[');
|
||||
});
|
||||
|
||||
it('TEXT-REFCOL-003: a line already carrying an explicit .referencedColumnNames( call is left untouched (legitimate no-op)', () => {
|
||||
const source = " client: () => p.manyToOne(OauthClients).ref().referencedColumnNames('client_id'),\n";
|
||||
const out = injectReferencedColumns(source, [{ className: 'X', propName: 'client', referencedColumnNames: ['client_id'] }]);
|
||||
expect(out).toBe(source);
|
||||
});
|
||||
|
||||
it('TEXT-REFCOL-004: appends before a trailing comma even with a line comment after it', () => {
|
||||
const source = ' client: () => p.manyToOne(Y).ref(), // pins client\n';
|
||||
const out = injectReferencedColumns(source, [{ className: 'X', propName: 'client', referencedColumnNames: ['client_id'] }]);
|
||||
expect(out).toBe(" client: () => p.manyToOne(Y).ref().referencedColumnNames('client_id'), // pins client\n");
|
||||
});
|
||||
|
||||
it('TEXT-REFCOL-005: throws, naming the class and property, if the property line is not found', () => {
|
||||
expect(() => injectReferencedColumns('', [{ className: 'X', propName: 'client', referencedColumnNames: ['client_id'] }])).toThrow(
|
||||
/could not find the property line for "X\.client"/,
|
||||
);
|
||||
});
|
||||
|
||||
it('TEXT-REFCOL-006: throws if the property line has no trailing comma to anchor on', () => {
|
||||
const source = ' client: () => p.manyToOne(Y).ref()';
|
||||
expect(() => injectReferencedColumns(source, [{ className: 'X', propName: 'client', referencedColumnNames: ['client_id'] }])).toThrow(
|
||||
/could not append/,
|
||||
);
|
||||
});
|
||||
|
||||
it('TEXT-REFCOL-007: an empty fixups list is a no-op', () => {
|
||||
const source = " client: () => p.manyToOne(OauthClients).ref().name('client_id'),\n";
|
||||
expect(injectReferencedColumns(source, [])).toBe(source);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTextPasses', () => {
|
||||
it('TEXT-ALL-001: composes every pass without one undoing another', () => {
|
||||
const source =
|
||||
@@ -821,8 +921,10 @@ describe('applyTextPasses', () => {
|
||||
jsonColumns: [],
|
||||
retypedScalars: [],
|
||||
repositoryMarkers: [{ className: 'X', repositoryClassName: 'XRepository' }],
|
||||
referencedColumns: [{ className: 'X', propName: 'countryRef', referencedColumnNames: ['code'] }],
|
||||
});
|
||||
expect(out).toContain(".joinColumn('country')");
|
||||
expect(out).toContain(".referencedColumnNames('code')");
|
||||
expect(out).not.toContain('& Hidden');
|
||||
expect(out).toContain('export class X {\n [EntityRepositoryType]?: XRepository;\n countryRef!: Ref<Y>;\n');
|
||||
});
|
||||
|
||||
@@ -344,7 +344,7 @@ describe('OauthTokensRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('listActiveByUser (OA26) — Kysely join on the real oauth_clients.client_id column', () => {
|
||||
describe('listActiveByUser (OA26) — QueryBuilder join on the real oauth_clients.client_id column', () => {
|
||||
it('OAUTHTOKREPO-022: lists only this user\'s non-revoked, non-expired-refresh sessions, newest first, with the client name joined in', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
@@ -434,6 +434,57 @@ describe('OauthTokensRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('client relation join (Plan 3b interlude A — RULE11_referencedColumns)', () => {
|
||||
it('OAUTHTOKREPO-030: a QueryBuilder join on the client relation resolves the right client, even though oauth_clients.id !== client_id', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// seedClient's own `id` ("row-<clientId>") is already never equal to
|
||||
// `client_id` — this test additionally asserts the two literally
|
||||
// differ, so a future fixture change can't silently make the
|
||||
// distinction disappear and hide a regression.
|
||||
seedClient(user.id, 'proto-30', 'Client Thirty');
|
||||
const clientRow = testDb.prepare('SELECT id, client_id FROM oauth_clients WHERE client_id = ?').get('proto-30') as { id: string; client_id: string };
|
||||
expect(clientRow.id).not.toBe(clientRow.client_id);
|
||||
const id = seedToken({ clientId: 'proto-30', userId: user.id });
|
||||
|
||||
const em = t.orm.em.fork();
|
||||
const qb = em.getRepository(OauthTokens).qb('ot').innerJoin('ot.client', 'oc').select(['ot.id', 'oc.name as client_name']).where({ 'ot.id': id });
|
||||
expect(qb.getFormattedQuery()).toContain('inner join `oauth_clients` as `oc` on `ot`.`client_id` = `oc`.`client_id`');
|
||||
const row = await qb.execute<{ id: number; client_name: string }>('get', false);
|
||||
expect(row).toEqual({ id, client_name: 'Client Thirty' });
|
||||
});
|
||||
|
||||
it('OAUTHTOKREPO-031: a DECOY client whose row id equals the real client_id — regression-proofs the join against silently falling back to oauth_clients.id (task-4-review.md, "For Task 5 / interlude A")', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
seedClient(user.id, 'proto-31-real', 'Real Client');
|
||||
const realClientRow = testDb
|
||||
.prepare('SELECT id, client_id FROM oauth_clients WHERE client_id = ?')
|
||||
.get('proto-31-real') as { id: string; client_id: string };
|
||||
// The decoy's own PRIMARY KEY (`id`) is deliberately set to the REAL
|
||||
// client's `client_id` — a wrong join (`oc.id = ot.client_id`, the pre-
|
||||
// Rule-11 defect) would match THIS row instead of the real one, and
|
||||
// return the decoy's name, not silently return nothing. A regression
|
||||
// to the wrong column is caught by a WRONG answer, not just an absent
|
||||
// one — the strongest form of this proof.
|
||||
testDb.prepare(
|
||||
`INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes) VALUES (?, ?, ?, ?, ?, '[]', '[]')`,
|
||||
).run(realClientRow.client_id, user.id, 'Decoy Client', 'proto-31-decoy', 'hash');
|
||||
|
||||
const id = seedToken({ clientId: 'proto-31-real', userId: user.id });
|
||||
|
||||
const em = t.orm.em.fork();
|
||||
const qb = em.getRepository(OauthTokens).qb('ot').innerJoin('ot.client', 'oc').select(['ot.id', 'oc.name as client_name']).where({ 'ot.id': id });
|
||||
const row = await qb.execute<{ id: number; client_name: string }>('get', false);
|
||||
expect(row).toEqual({ id, client_name: 'Real Client' });
|
||||
|
||||
// Same proof through the actual repository methods (OA26/OA31), not
|
||||
// just a hand-rolled QueryBuilder query.
|
||||
const active = await tokens.listActiveByUser(user.id);
|
||||
expect(active.map((r) => r.client_name)).toEqual(['Real Client']);
|
||||
const allActive = await tokens.listAllActiveWithClientAndUser();
|
||||
expect(allActive.map((r) => r.client_name)).toEqual(['Real Client']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('identity-map write-back regression (program RULING, D-shape)', () => {
|
||||
it('OAUTHTOKREPO-029: findByRefreshTokenHash (projection A) then findParent (projection B) then revokeById inside uow.transactional — the write survives', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
@@ -7,11 +7,11 @@ describe('toRowId', () => {
|
||||
expect(toRowId('abc')).toBeNull();
|
||||
});
|
||||
|
||||
it('ROWID-002: rejects a hex-shaped string — Number() would accept it, SQLite affinity never would', () => {
|
||||
it('ROWID-002: rejects a hex-shaped string — Number() would accept it, SQLite affinity never would (verified against better-sqlite3)', () => {
|
||||
expect(toRowId('0x10')).toBeNull();
|
||||
});
|
||||
|
||||
it('ROWID-003: rejects an exponent-shaped string — Number() would accept it, SQLite affinity never would', () => {
|
||||
it('ROWID-003: rejects an exponent-shaped string ("1e3") — a DELIBERATE, ACCEPTED deviation: SQLite\'s own numeric-affinity comparison DOES match this against integer 1000 (verified against better-sqlite3, not assumed — the row-id.ts docstring previously claimed the opposite), but no id this guard ever sees originates that way (every id is a canonical decimal string from our own client), so this fails closed to the route\'s legacy not-found result instead of widening the guard to match SQLite\'s affinity exactly', () => {
|
||||
expect(toRowId('1e3')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -19,14 +19,22 @@ describe('toRowId', () => {
|
||||
expect(toRowId('-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('ROWID-005: rejects a leading space', () => {
|
||||
it('ROWID-005: rejects a leading space (" 1") — same deliberate deviation as ROWID-003: SQLite\'s affinity conversion ignores leading whitespace and matches integer 1 (verified against better-sqlite3)', () => {
|
||||
expect(toRowId(' 1')).toBeNull();
|
||||
});
|
||||
|
||||
it('ROWID-006: rejects a decimal point', () => {
|
||||
it('ROWID-005B: rejects a trailing space ("1 ") — same deliberate deviation: SQLite\'s affinity conversion ignores trailing whitespace too and matches integer 1 (verified against better-sqlite3)', () => {
|
||||
expect(toRowId('1 ')).toBeNull();
|
||||
});
|
||||
|
||||
it('ROWID-006: rejects a decimal point ("1.0") — same deliberate deviation: SQLite\'s affinity conversion parses this as REAL 1.0, which compares equal to INTEGER 1 (verified against better-sqlite3)', () => {
|
||||
expect(toRowId('1.0')).toBeNull();
|
||||
});
|
||||
|
||||
it('ROWID-006B: rejects a leading plus sign ("+1") — same deliberate deviation: SQLite\'s affinity conversion accepts a leading sign and matches integer 1 (verified against better-sqlite3)', () => {
|
||||
expect(toRowId('+1')).toBeNull();
|
||||
});
|
||||
|
||||
it('ROWID-007: rejects an empty string', () => {
|
||||
expect(toRowId('')).toBeNull();
|
||||
});
|
||||
@@ -39,6 +47,10 @@ describe('toRowId', () => {
|
||||
expect(toRowId('42')).toBe(42);
|
||||
});
|
||||
|
||||
it('ROWID-009B: accepts a leading-zero digit string ("007") — SQLite\'s own affinity conversion resolves this to integer 7 too (verified against better-sqlite3), and the guard\'s regex (/^\\d+$/) must keep matching it: never tighten it to /^(0|[1-9]\\d*)$/', () => {
|
||||
expect(toRowId('007')).toBe(7);
|
||||
});
|
||||
|
||||
it('ROWID-010: accepts an already-numeric safe integer', () => {
|
||||
expect(toRowId(42)).toBe(42);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user