diff --git a/server/src/db/repositories/Addons.repository.ts b/server/src/db/repositories/Addons.repository.ts index c5d88794d..30ae6f593 100644 --- a/server/src/db/repositories/Addons.repository.ts +++ b/server/src/db/repositories/Addons.repository.ts @@ -1,6 +1,6 @@ import type { Addons, AddonConfig } from '../entities/Addons.entity'; import { toRow, type AssertRowKeys } from './_shared/rows'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; /** An `addons` row as the API emits it. */ export interface AddonRow { @@ -16,12 +16,13 @@ export interface AddonRow { const _addonRowKeys: AssertRowKeys = true; -export class AddonsRepository extends EntityRepository { +export class AddonsRepository extends TrekRepository { /** * `SELECT enabled FROM addons WHERE id = ?` * - * `disableIdentityMap: true` (Plan 3b Task 1 fix round, supersedes Plan - * 3a's I1 "`refresh: true` on every PK-only `findOne`" — see + * `disableIdentityMap: true`, applied by the base class's default (Plan 3b + * interlude B — `_shared/trek-repository.ts`; supersedes Plan 3a's I1 + * "`refresh: true` on every PK-only `findOne`" — see * `Users.repository.ts`'s class-level docstring and * `.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md` B1): a * throwaway forked context always sees a write on the same id inside the @@ -30,7 +31,7 @@ export class AddonsRepository extends EntityRepository { * request's identity map. */ async isEnabled(id: string): Promise { - const row = await this.findOne({ id }, { fields: ['enabled'], disableIdentityMap: true }); + const row = await this.findOne({ id }, { fields: ['enabled'] }); return !!row?.enabled; } @@ -43,7 +44,7 @@ export class AddonsRepository extends EntityRepository { * for no parity benefit. */ async listEnabled(): Promise { - const rows = await this.find({ enabled: true }, { orderBy: { sort_order: 'asc' }, disableIdentityMap: true }); + const rows = await this.find({ enabled: true }, { orderBy: { sort_order: 'asc' } }); return rows.map((row) => toRow(row) as AddonRow); } } diff --git a/server/src/db/repositories/AppSettings.repository.ts b/server/src/db/repositories/AppSettings.repository.ts index 7bf34eb1c..a5f8d68ae 100644 --- a/server/src/db/repositories/AppSettings.repository.ts +++ b/server/src/db/repositories/AppSettings.repository.ts @@ -1,6 +1,6 @@ import type { AppSettings } from '../entities/AppSettings.entity'; import { toRow, type AssertRowKeys } from './_shared/rows'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; /** An `app_settings` row as the API emits it. */ export interface AppSettingsRow { @@ -10,12 +10,13 @@ export interface AppSettingsRow { const _appSettingsRowKeys: AssertRowKeys = true; -export class AppSettingsRepository extends EntityRepository { +export class AppSettingsRepository extends TrekRepository { /** * `SELECT value FROM app_settings WHERE key = ?` * - * `disableIdentityMap: true` (Plan 3b Task 1 fix round, supersedes Plan - * 3a's I1 "`refresh: true` on every PK-only `findOne`" — see + * `disableIdentityMap: true`, applied by the base class's default (Plan 3b + * interlude B — `_shared/trek-repository.ts`; supersedes Plan 3a's I1 + * "`refresh: true` on every PK-only `findOne`" — see * `Users.repository.ts`'s class-level docstring for the full mechanism and * `.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md` B1): the read * is answered from a throwaway forked context that is cleared afterwards, @@ -24,7 +25,7 @@ export class AppSettingsRepository extends EntityRepository { * identity map to become a stale pending change at a later `flush()`. */ async getValue(key: string): Promise { - const row = await this.findOne({ key }, { fields: ['value'], disableIdentityMap: true }); + const row = await this.findOne({ key }, { fields: ['value'] }); return row?.value ?? null; } @@ -35,7 +36,7 @@ export class AppSettingsRepository extends EntityRepository { * value" from "no row" (both read as absent). */ async getValues(keys: string[]): Promise> { - const rows = await this.find({ key: { $in: keys } }, { disableIdentityMap: true }); + const rows = await this.find({ key: { $in: keys } }); const values = new Map(); for (const row of rows) { if (row.key != null && row.value != null) values.set(row.key, row.value); @@ -80,7 +81,7 @@ export class AppSettingsRepository extends EntityRepository { * a prefix that itself contains `%`/`_`. */ async findByKeyPrefix(prefix: string): Promise { - const rows = await this.find({ key: { $like: `${prefix}%` } }, { disableIdentityMap: true }); + const rows = await this.find({ key: { $like: `${prefix}%` } }); return rows.map((row) => toRow(row) as AppSettingsRow); } diff --git a/server/src/db/repositories/AssignmentParticipants.repository.ts b/server/src/db/repositories/AssignmentParticipants.repository.ts index a441cbc06..335ec5b71 100644 --- a/server/src/db/repositories/AssignmentParticipants.repository.ts +++ b/server/src/db/repositories/AssignmentParticipants.repository.ts @@ -1,4 +1,4 @@ import type { AssignmentParticipants } from '../entities/AssignmentParticipants.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class AssignmentParticipantsRepository extends EntityRepository {} +export class AssignmentParticipantsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/AuditLog.repository.ts b/server/src/db/repositories/AuditLog.repository.ts index a4f1ca4ca..5a31b4bb8 100644 --- a/server/src/db/repositories/AuditLog.repository.ts +++ b/server/src/db/repositories/AuditLog.repository.ts @@ -1,6 +1,6 @@ import { AuditLog } from '../entities/AuditLog.entity'; import { type AssertRowKeys } from './_shared/rows'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; /** * An `audit_log` row as the API emits it (Plan 3i's `admin.service.ts` @@ -27,7 +27,7 @@ export type NewAuditLogRow = Omit; // join to `Users` for `username`/`email`) and `count(): Promise`. // Not implemented here — see plan3a-sql-inventory.md §1. -export class AuditLogRepository extends EntityRepository { +export class AuditLogRepository extends TrekRepository { /** * The column set of the legacy INSERT: * `INSERT INTO audit_log (user_id, action, resource, details, ip) @@ -57,7 +57,7 @@ export class AuditLogRepository extends EntityRepository { * inserts `createDay`/`createNote` rather than `create`. */ async insertEntry(entry: NewAuditLogRow): Promise { - await this.getEntityManager().insert(AuditLog, { + await this.insert({ user: entry.user_id, action: entry.action, resource: entry.resource, diff --git a/server/src/db/repositories/BucketList.repository.ts b/server/src/db/repositories/BucketList.repository.ts index bd5f6cceb..35a8156db 100644 --- a/server/src/db/repositories/BucketList.repository.ts +++ b/server/src/db/repositories/BucketList.repository.ts @@ -1,4 +1,4 @@ import type { BucketList } from '../entities/BucketList.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class BucketListRepository extends EntityRepository {} +export class BucketListRepository extends TrekRepository {} diff --git a/server/src/db/repositories/BudgetCategoryOrder.repository.ts b/server/src/db/repositories/BudgetCategoryOrder.repository.ts index 0c21a9c19..801fc397f 100644 --- a/server/src/db/repositories/BudgetCategoryOrder.repository.ts +++ b/server/src/db/repositories/BudgetCategoryOrder.repository.ts @@ -1,4 +1,4 @@ import type { BudgetCategoryOrder } from '../entities/BudgetCategoryOrder.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class BudgetCategoryOrderRepository extends EntityRepository {} +export class BudgetCategoryOrderRepository extends TrekRepository {} diff --git a/server/src/db/repositories/BudgetItemMembers.repository.ts b/server/src/db/repositories/BudgetItemMembers.repository.ts index 33d6a96e8..54c4d9f70 100644 --- a/server/src/db/repositories/BudgetItemMembers.repository.ts +++ b/server/src/db/repositories/BudgetItemMembers.repository.ts @@ -1,4 +1,4 @@ import type { BudgetItemMembers } from '../entities/BudgetItemMembers.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class BudgetItemMembersRepository extends EntityRepository {} +export class BudgetItemMembersRepository extends TrekRepository {} diff --git a/server/src/db/repositories/BudgetItemPayers.repository.ts b/server/src/db/repositories/BudgetItemPayers.repository.ts index b834e75d1..54e6e6dea 100644 --- a/server/src/db/repositories/BudgetItemPayers.repository.ts +++ b/server/src/db/repositories/BudgetItemPayers.repository.ts @@ -1,4 +1,4 @@ import type { BudgetItemPayers } from '../entities/BudgetItemPayers.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class BudgetItemPayersRepository extends EntityRepository {} +export class BudgetItemPayersRepository extends TrekRepository {} diff --git a/server/src/db/repositories/BudgetItems.repository.ts b/server/src/db/repositories/BudgetItems.repository.ts index 7dda3e117..7d73a3da6 100644 --- a/server/src/db/repositories/BudgetItems.repository.ts +++ b/server/src/db/repositories/BudgetItems.repository.ts @@ -1,4 +1,4 @@ import type { BudgetItems } from '../entities/BudgetItems.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class BudgetItemsRepository extends EntityRepository {} +export class BudgetItemsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/BudgetSettlements.repository.ts b/server/src/db/repositories/BudgetSettlements.repository.ts index f117c9a33..eb4bc2a66 100644 --- a/server/src/db/repositories/BudgetSettlements.repository.ts +++ b/server/src/db/repositories/BudgetSettlements.repository.ts @@ -1,4 +1,4 @@ import type { BudgetSettlements } from '../entities/BudgetSettlements.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class BudgetSettlementsRepository extends EntityRepository {} +export class BudgetSettlementsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/Categories.repository.ts b/server/src/db/repositories/Categories.repository.ts index 34ee1a50b..2f8506a26 100644 --- a/server/src/db/repositories/Categories.repository.ts +++ b/server/src/db/repositories/Categories.repository.ts @@ -1,6 +1,6 @@ import type { Categories } from '../entities/Categories.entity'; import { toRow, type AssertRowKeys } from './_shared/rows'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; /** A `categories` row as the API emits it. */ export interface CategoryRow { @@ -14,10 +14,10 @@ export interface CategoryRow { const _categoryRowKeys: AssertRowKeys = true; -export class CategoriesRepository extends EntityRepository { +export class CategoriesRepository extends TrekRepository { /** `SELECT * FROM categories ORDER BY name ASC` */ async list(): Promise { - const categories = await this.find({}, { orderBy: { name: 'asc' }, disableIdentityMap: true }); + const categories = await this.find({}, { orderBy: { name: 'asc' } }); return categories.map((category) => toRow(category) as CategoryRow); } @@ -30,9 +30,10 @@ export class CategoriesRepository extends EntityRepository { * global") and was deleted rather than kept as dead code (Task 0 * re-review, R6; see `_shared/owned-lookup.ts`'s docstring). * - * `disableIdentityMap: true` (Plan 3b Task 1 fix round, supersedes Plan - * 3a's I1 "`refresh: true` on every PK-only `findOne`" — see - * `Users.repository.ts`'s class-level docstring and + * `disableIdentityMap: true` by the base class's default (`_shared/ + * trek-repository.ts`, Plan 3b interlude B — supersedes the per-call + * option this repository used to pass itself, which superseded Plan 3a's + * I1 "`refresh: true` on every PK-only `findOne`"; see * `.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md` B1): this is * a "rows out" read, converted via `toRow` and discarded — the entity is * loaded in a throwaway forked context and never lands in the request's @@ -41,7 +42,7 @@ export class CategoriesRepository extends EntityRepository { * dirty. */ async findById(id: number): Promise { - const category = await this.findOne({ id }, { disableIdentityMap: true }); + const category = await this.findOne({ id }); return category ? (toRow(category) as CategoryRow) : null; } @@ -51,74 +52,69 @@ export class CategoriesRepository extends EntityRepository { * The `#6366f1`/`📍` defaulting stays in `CategoriesService` (D4's defaults * rule) — this writes exactly what it is given. * - * Named `createCategory`, not `create`: `EntityRepository#create` is a - * synchronous, non-persisting factory with an incompatible signature — - * shadowing it would fail to compile, the same collision Task 1 hit - * naming a method `insert`. - * - * Followed by `em.refresh` before `toRow` (D4's create rule): the insert's - * `RETURNING` clause already carries the generated `id` and the - * `defaultRaw` `created_at`, but calling `refresh` documents the - * one-extra-SELECT parity with the legacy statement pair (INSERT, then a - * re-select by id) rather than leaning on driver-specific RETURNING - * behaviour. + * `this.insert` (Plan 3b interlude B, finishing F2's sweep — Task 1 + * fix round's re-review flagged this method as a leftover whole-request- + * UnitOfWork `flush()`), never `create()` + `persist().flush()`: + * `flush()` commits the *whole* unit of work of the request's + * `EntityManager`, not just this row. `insert` fires a single native + * INSERT with no side effects on the context/identity map, followed by a + * read-back (`findById`, `disableIdentityMap: true` by the base class's + * default): the returned row carries the generated `id` and the + * `defaultRaw` `created_at` the same way a legacy INSERT-then-reselect + * pair did. */ async createCategory(input: { name: string; color: string; icon: string; user_id: number }): Promise { - const category = this.create({ + const id = await this.insert({ name: input.name, color: input.color, icon: input.icon, user: input.user_id, }); - await this.getEntityManager().persist(category).flush(); - await this.getEntityManager().refresh(category); - return toRow(category) as CategoryRow; + const inserted = await this.findById(id); + if (!inserted) { + throw new Error('createCategory: read-back after insert found no row'); + } + return inserted; } /** * `UPDATE categories SET name = COALESCE(?, name), color = COALESCE(?, - * color), icon = COALESCE(?, icon) WHERE id = ?`, re-selected. `assign` - * only touches the keys present on `changes` — the entity-API equivalent - * of `COALESCE` against a bind parameter — so the caller must omit a field - * entirely (not pass it as `undefined`) to leave it untouched, which is - * how `CategoriesService` reproduces the legacy `|| null` - * coalesce-away-empty-string behaviour. Returns `null` when no row matches - * `id`, mirroring the legacy re-select's `undefined`. + * color), icon = COALESCE(?, icon) WHERE id = ?`, re-selected. + * `nativeUpdate` only writes the keys present on `changes` — the + * entity-API equivalent of `COALESCE` against a bind parameter — so the + * caller must omit a field entirely (not pass it as `undefined`) to leave + * it untouched, which is how `CategoriesService` reproduces the legacy + * `|| null` coalesce-away-empty-string behaviour. Returns `null` when no + * row matches `id`, mirroring the legacy re-select's `undefined`. * - * The lookup `findOne({ id }, { refresh: true })` is PK-only, so without - * `refresh: true` MikroORM would answer it from the identity map instead - * of the DB (Task 3 review, Important 1): a category loaded earlier in - * this request (list/findById/an earlier patch) would stay pinned to that - * stale snapshot, so (a) an untouched column could read stale even though - * the DB has since changed underneath it, (b) `assign` could diff a patch - * against a stale value that happens to match the new one and emit no - * UPDATE at all — reporting success while silently dropping the write — - * and (c) a `remove()` earlier in the same request (a `nativeDelete`, - * which does not clear the identity map) would let `patch` fabricate a - * row for an id that no longer exists instead of returning `null`. - * `refresh: true` forces a real re-query every time, closing all three. - * - * No re-`findOne`/`refresh` after `flush`: `assign` already mutated the - * managed (and now guaranteed-fresh) entity in place, and no column here - * is DB-computed on UPDATE (unlike `created_at` on insert), so the - * in-memory entity already holds exactly what was written. - * - * **Excluded from the Plan 3b Task 1 fix round's `disableIdentityMap: true` - * ruling, deliberately** — see `TagsRepository.patch`'s docstring for the - * full reasoning: this is not a "rows out" read, the entity must stay - * MANAGED for `assign`+`flush`, and `disableIdentityMap: true` would - * silently stop the write from persisting (a detached entity is outside - * `flush()`'s unit of work). `refresh: true` stays for the identity-map - * freshness reason above; there is no B1-shaped staleness risk because - * this is the only read of this row left un-`disableIdentityMap`-ed in the - * repository. + * **Converted from `findOne({ refresh: true })` + `assign` + `flush()` + * to `nativeUpdate` + a typed partial (Plan 3b interlude B, per + * `task-1-rereview.md`'s ruling on this method: "convert them, don't + * complete the sweep on the read").** `nativeUpdate` writes the given + * columns directly, with no ORM change-set diff against an in-memory + * snapshot — that removes the whole class of staleness the old + * `refresh: true` lookup existed to guard against (Task 3 review, + * Important 1: an untouched column reading stale, a patch silently + * dropped because it happened to match a stale in-memory value, and a + * patch after `remove()` fabricating a row), not just the one B1 shape + * the rest of the program's `disableIdentityMap: true` ruling closes. When + * `changes` is non-empty, `nativeUpdate`'s own affected-row count IS the + * existence check (0 ⇒ `null`, no separate read); when `changes` is empty + * (nothing to write), a plain existence read (`disableIdentityMap: true` + * by the base class's default, like every other read in this repository + * now) stands in for it. Either way the return value is a fresh + * `findById` — a genuine rows-out read, not the entity `assign` just + * mutated in place. */ async patch(id: number, changes: { name?: string; color?: string; icon?: string }): Promise { - const category = await this.findOne({ id }, { refresh: true }); - if (!category) return null; - this.assign(category, changes); - await this.getEntityManager().flush(); - return toRow(category) as CategoryRow; + if (Object.keys(changes).length > 0) { + const affected = await this.nativeUpdate({ id }, changes); + if (affected === 0) return null; + } else { + const existing = await this.findOne({ id }, { fields: ['id'] }); + if (!existing) return null; + } + return this.findById(id); } /** `DELETE FROM categories WHERE id = ?`. Returns the affected row count. */ diff --git a/server/src/db/repositories/CollabLinks.repository.ts b/server/src/db/repositories/CollabLinks.repository.ts index 9f3948010..6f140a4c8 100644 --- a/server/src/db/repositories/CollabLinks.repository.ts +++ b/server/src/db/repositories/CollabLinks.repository.ts @@ -1,4 +1,4 @@ import type { CollabLinks } from '../entities/CollabLinks.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class CollabLinksRepository extends EntityRepository {} +export class CollabLinksRepository extends TrekRepository {} diff --git a/server/src/db/repositories/CollabMessageReactions.repository.ts b/server/src/db/repositories/CollabMessageReactions.repository.ts index 02d449b08..21706674f 100644 --- a/server/src/db/repositories/CollabMessageReactions.repository.ts +++ b/server/src/db/repositories/CollabMessageReactions.repository.ts @@ -1,4 +1,4 @@ import type { CollabMessageReactions } from '../entities/CollabMessageReactions.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class CollabMessageReactionsRepository extends EntityRepository {} +export class CollabMessageReactionsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/CollabMessages.repository.ts b/server/src/db/repositories/CollabMessages.repository.ts index 9f666b1d1..e7f206905 100644 --- a/server/src/db/repositories/CollabMessages.repository.ts +++ b/server/src/db/repositories/CollabMessages.repository.ts @@ -1,4 +1,4 @@ import type { CollabMessages } from '../entities/CollabMessages.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class CollabMessagesRepository extends EntityRepository {} +export class CollabMessagesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/CollabNotes.repository.ts b/server/src/db/repositories/CollabNotes.repository.ts index 55b042aba..cd6efa861 100644 --- a/server/src/db/repositories/CollabNotes.repository.ts +++ b/server/src/db/repositories/CollabNotes.repository.ts @@ -1,4 +1,4 @@ import type { CollabNotes } from '../entities/CollabNotes.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class CollabNotesRepository extends EntityRepository {} +export class CollabNotesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/CollabPollVotes.repository.ts b/server/src/db/repositories/CollabPollVotes.repository.ts index 7cbd6b49d..4f08d44f6 100644 --- a/server/src/db/repositories/CollabPollVotes.repository.ts +++ b/server/src/db/repositories/CollabPollVotes.repository.ts @@ -1,4 +1,4 @@ import type { CollabPollVotes } from '../entities/CollabPollVotes.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class CollabPollVotesRepository extends EntityRepository {} +export class CollabPollVotesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/CollabPolls.repository.ts b/server/src/db/repositories/CollabPolls.repository.ts index 570d906e2..764becfd6 100644 --- a/server/src/db/repositories/CollabPolls.repository.ts +++ b/server/src/db/repositories/CollabPolls.repository.ts @@ -1,4 +1,4 @@ import type { CollabPolls } from '../entities/CollabPolls.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class CollabPollsRepository extends EntityRepository {} +export class CollabPollsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/CollectionLabels.repository.ts b/server/src/db/repositories/CollectionLabels.repository.ts index 2c0bcbddb..cdb3d7be1 100644 --- a/server/src/db/repositories/CollectionLabels.repository.ts +++ b/server/src/db/repositories/CollectionLabels.repository.ts @@ -1,4 +1,4 @@ import type { CollectionLabels } from '../entities/CollectionLabels.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class CollectionLabelsRepository extends EntityRepository {} +export class CollectionLabelsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/CollectionMembers.repository.ts b/server/src/db/repositories/CollectionMembers.repository.ts index a5051939e..f0790f846 100644 --- a/server/src/db/repositories/CollectionMembers.repository.ts +++ b/server/src/db/repositories/CollectionMembers.repository.ts @@ -1,4 +1,4 @@ import type { CollectionMembers } from '../entities/CollectionMembers.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class CollectionMembersRepository extends EntityRepository {} +export class CollectionMembersRepository extends TrekRepository {} diff --git a/server/src/db/repositories/CollectionPlaceRatings.repository.ts b/server/src/db/repositories/CollectionPlaceRatings.repository.ts index 49a415ec0..8a19eb320 100644 --- a/server/src/db/repositories/CollectionPlaceRatings.repository.ts +++ b/server/src/db/repositories/CollectionPlaceRatings.repository.ts @@ -1,4 +1,4 @@ import type { CollectionPlaceRatings } from '../entities/CollectionPlaceRatings.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class CollectionPlaceRatingsRepository extends EntityRepository {} +export class CollectionPlaceRatingsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/CollectionPlaces.repository.ts b/server/src/db/repositories/CollectionPlaces.repository.ts index 0d0d9f355..f5ebb9a33 100644 --- a/server/src/db/repositories/CollectionPlaces.repository.ts +++ b/server/src/db/repositories/CollectionPlaces.repository.ts @@ -1,4 +1,4 @@ import type { CollectionPlaces } from '../entities/CollectionPlaces.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class CollectionPlacesRepository extends EntityRepository {} +export class CollectionPlacesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/Collections.repository.ts b/server/src/db/repositories/Collections.repository.ts index 2946d880d..8c28d3e20 100644 --- a/server/src/db/repositories/Collections.repository.ts +++ b/server/src/db/repositories/Collections.repository.ts @@ -1,4 +1,4 @@ import type { Collections } from '../entities/Collections.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class CollectionsRepository extends EntityRepository {} +export class CollectionsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/DawarichConnections.repository.ts b/server/src/db/repositories/DawarichConnections.repository.ts index c597c8bfa..9e6bcea1b 100644 --- a/server/src/db/repositories/DawarichConnections.repository.ts +++ b/server/src/db/repositories/DawarichConnections.repository.ts @@ -1,4 +1,4 @@ import type { DawarichConnections } from '../entities/DawarichConnections.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class DawarichConnectionsRepository extends EntityRepository {} +export class DawarichConnectionsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/DawarichVisitSuggestions.repository.ts b/server/src/db/repositories/DawarichVisitSuggestions.repository.ts index f2bac51d7..ba6f33ac4 100644 --- a/server/src/db/repositories/DawarichVisitSuggestions.repository.ts +++ b/server/src/db/repositories/DawarichVisitSuggestions.repository.ts @@ -1,4 +1,4 @@ import type { DawarichVisitSuggestions } from '../entities/DawarichVisitSuggestions.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class DawarichVisitSuggestionsRepository extends EntityRepository {} +export class DawarichVisitSuggestionsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/DayAccommodations.repository.ts b/server/src/db/repositories/DayAccommodations.repository.ts index 592616e15..9594441b6 100644 --- a/server/src/db/repositories/DayAccommodations.repository.ts +++ b/server/src/db/repositories/DayAccommodations.repository.ts @@ -1,4 +1,4 @@ import type { DayAccommodations } from '../entities/DayAccommodations.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class DayAccommodationsRepository extends EntityRepository {} +export class DayAccommodationsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/DayAssignments.repository.ts b/server/src/db/repositories/DayAssignments.repository.ts index 297674a8b..331380225 100644 --- a/server/src/db/repositories/DayAssignments.repository.ts +++ b/server/src/db/repositories/DayAssignments.repository.ts @@ -1,4 +1,4 @@ import type { DayAssignments } from '../entities/DayAssignments.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class DayAssignmentsRepository extends EntityRepository {} +export class DayAssignmentsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/DayNotes.repository.ts b/server/src/db/repositories/DayNotes.repository.ts index d52d64c0f..a78457dba 100644 --- a/server/src/db/repositories/DayNotes.repository.ts +++ b/server/src/db/repositories/DayNotes.repository.ts @@ -1,6 +1,6 @@ import type { DayNotes } from '../entities/DayNotes.entity'; import { toRow, type AssertRowKeys } from './_shared/rows'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; export interface DayNoteRow { id: number; @@ -16,7 +16,7 @@ export interface DayNoteRow { const _dayNoteRowKeys: AssertRowKeys = true; -export class DayNotesRepository extends EntityRepository { +export class DayNotesRepository extends TrekRepository { /** * The column set of the legacy INSERT: * `INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order, color)`. @@ -29,9 +29,14 @@ export class DayNotesRepository extends EntityRepository { * `created_at` is not in the column set: it is left to the column's * `DEFAULT CURRENT_TIMESTAMP`. It alone would not need the re-read either — * a `defaultRaw` column the insert does not name is in its `returning` - * clause, so it is already the stored text by the time `flush()` resolves. - * The re-read is there for the columns that are not: one this insert never - * names would otherwise be `undefined` on the entity and missing from the row. + * clause. The re-read is there for the columns that are not: one this + * insert never names would otherwise be `undefined` on the entity and + * missing from the row. + * + * `this.insert` (Plan 3b interlude B, finishing F2's sweep), never + * `create()` + `persist().flush()`: `flush()` commits the *whole* unit of + * work of the request's `EntityManager`, not just this row. The read-back + * uses `disableIdentityMap: true` by the base class's default. */ async createNote(input: { day_id: number; @@ -42,7 +47,7 @@ export class DayNotesRepository extends EntityRepository { sort_order: number | null; color: string | null; }): Promise { - const note = this.create({ + const id = await this.insert({ day: input.day_id, trip: input.trip_id, text: input.text, @@ -51,8 +56,10 @@ export class DayNotesRepository extends EntityRepository { sort_order: input.sort_order, color: input.color, }); - await this.getEntityManager().persist(note).flush(); - await this.getEntityManager().refresh(note); - return toRow(note) as DayNoteRow; + const inserted = await this.findOne({ id }); + if (!inserted) { + throw new Error('createNote: read-back after insert found no row'); + } + return toRow(inserted) as DayNoteRow; } } diff --git a/server/src/db/repositories/Days.repository.ts b/server/src/db/repositories/Days.repository.ts index 8009bf472..d96185d52 100644 --- a/server/src/db/repositories/Days.repository.ts +++ b/server/src/db/repositories/Days.repository.ts @@ -1,6 +1,6 @@ import type { Days } from '../entities/Days.entity'; import { toRow, type AssertRowKeys } from './_shared/rows'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; /** A `days` row as the API emits it. */ export interface DayRow { @@ -15,7 +15,7 @@ export interface DayRow { const _dayRowKeys: AssertRowKeys = true; -export class DaysRepository extends EntityRepository { +export class DaysRepository extends TrekRepository { /** `SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC` */ async listByTrip(trip_id: number): Promise { const days = await this.find({ trip: trip_id }, { orderBy: { day_number: 'asc' } }); @@ -54,12 +54,17 @@ export class DaysRepository extends EntityRepository { * written verbatim, because a default or a coercion belongs to the service * that owns the rule, not to the statement that stores it. * - * The insert is followed by the legacy re-select, so the caller gets the - * stored row rather than what it asked to store. That re-select is not - * optional: the insert's `returning` clause carries only the generated and + * `this.insert` (Plan 3b interlude B, finishing F2's sweep — Task 1 fix + * round's re-review flagged this method as a leftover whole-request- + * UnitOfWork `flush()`), never `create()` + `persist().flush()`: `flush()` + * commits the *whole* unit of work of the request's `EntityManager`, not + * just this row. The insert is followed by a read-back + * (`disableIdentityMap: true` by the base class's default), so the caller + * gets the stored row rather than what it asked to store — not optional: + * the insert's `returning` clause carries only the generated and * `defaultRaw` columns, so a column this insert never names (`title`, - * `default_transport_mode`) would still be `undefined` on the entity and - * would simply be missing from the row. + * `default_transport_mode`) would otherwise simply be missing from the + * row. */ async createDay(input: { trip_id: number; @@ -67,14 +72,16 @@ export class DaysRepository extends EntityRepository { date: string | null; notes: string | null; }): Promise { - const day = this.create({ + const id = await this.insert({ trip: input.trip_id, day_number: input.day_number, date: input.date, notes: input.notes, }); - await this.getEntityManager().persist(day).flush(); - await this.getEntityManager().refresh(day); - return toRow(day) as DayRow; + const inserted = await this.findOne({ id }); + if (!inserted) { + throw new Error('createDay: read-back after insert found no row'); + } + return toRow(inserted) as DayRow; } } diff --git a/server/src/db/repositories/DocumentConnections.repository.ts b/server/src/db/repositories/DocumentConnections.repository.ts index 2d8729b10..593ee6111 100644 --- a/server/src/db/repositories/DocumentConnections.repository.ts +++ b/server/src/db/repositories/DocumentConnections.repository.ts @@ -1,4 +1,4 @@ import type { DocumentConnections } from '../entities/DocumentConnections.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class DocumentConnectionsRepository extends EntityRepository {} +export class DocumentConnectionsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/DocumentProviderFields.repository.ts b/server/src/db/repositories/DocumentProviderFields.repository.ts index dcb654a47..18903ab52 100644 --- a/server/src/db/repositories/DocumentProviderFields.repository.ts +++ b/server/src/db/repositories/DocumentProviderFields.repository.ts @@ -1,4 +1,4 @@ import type { DocumentProviderFields } from '../entities/DocumentProviderFields.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class DocumentProviderFieldsRepository extends EntityRepository {} +export class DocumentProviderFieldsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/DocumentProviders.repository.ts b/server/src/db/repositories/DocumentProviders.repository.ts index 10c071ef3..931d7c788 100644 --- a/server/src/db/repositories/DocumentProviders.repository.ts +++ b/server/src/db/repositories/DocumentProviders.repository.ts @@ -1,4 +1,4 @@ import type { DocumentProviders } from '../entities/DocumentProviders.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class DocumentProvidersRepository extends EntityRepository {} +export class DocumentProvidersRepository extends TrekRepository {} diff --git a/server/src/db/repositories/DocumentSyncItems.repository.ts b/server/src/db/repositories/DocumentSyncItems.repository.ts index bc0bf3263..b2af97bed 100644 --- a/server/src/db/repositories/DocumentSyncItems.repository.ts +++ b/server/src/db/repositories/DocumentSyncItems.repository.ts @@ -1,4 +1,4 @@ import type { DocumentSyncItems } from '../entities/DocumentSyncItems.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class DocumentSyncItemsRepository extends EntityRepository {} +export class DocumentSyncItemsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/FileLinks.repository.ts b/server/src/db/repositories/FileLinks.repository.ts index f2a0449c0..d1a94780c 100644 --- a/server/src/db/repositories/FileLinks.repository.ts +++ b/server/src/db/repositories/FileLinks.repository.ts @@ -1,4 +1,4 @@ import type { FileLinks } from '../entities/FileLinks.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class FileLinksRepository extends EntityRepository {} +export class FileLinksRepository extends TrekRepository {} diff --git a/server/src/db/repositories/GooglePlacePhotoMeta.repository.ts b/server/src/db/repositories/GooglePlacePhotoMeta.repository.ts index 991f1167b..180513a29 100644 --- a/server/src/db/repositories/GooglePlacePhotoMeta.repository.ts +++ b/server/src/db/repositories/GooglePlacePhotoMeta.repository.ts @@ -1,4 +1,4 @@ import type { GooglePlacePhotoMeta } from '../entities/GooglePlacePhotoMeta.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class GooglePlacePhotoMetaRepository extends EntityRepository {} +export class GooglePlacePhotoMetaRepository extends TrekRepository {} diff --git a/server/src/db/repositories/HiddenCountries.repository.ts b/server/src/db/repositories/HiddenCountries.repository.ts index 3904e2f0e..cd4d91a6e 100644 --- a/server/src/db/repositories/HiddenCountries.repository.ts +++ b/server/src/db/repositories/HiddenCountries.repository.ts @@ -1,4 +1,4 @@ import type { HiddenCountries } from '../entities/HiddenCountries.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class HiddenCountriesRepository extends EntityRepository {} +export class HiddenCountriesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/HiddenRegions.repository.ts b/server/src/db/repositories/HiddenRegions.repository.ts index 99289a507..e8db21f7b 100644 --- a/server/src/db/repositories/HiddenRegions.repository.ts +++ b/server/src/db/repositories/HiddenRegions.repository.ts @@ -1,4 +1,4 @@ import type { HiddenRegions } from '../entities/HiddenRegions.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class HiddenRegionsRepository extends EntityRepository {} +export class HiddenRegionsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/IdempotencyKeys.repository.ts b/server/src/db/repositories/IdempotencyKeys.repository.ts index dca7a242c..cfd7f437f 100644 --- a/server/src/db/repositories/IdempotencyKeys.repository.ts +++ b/server/src/db/repositories/IdempotencyKeys.repository.ts @@ -1,4 +1,4 @@ import type { IdempotencyKeys } from '../entities/IdempotencyKeys.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class IdempotencyKeysRepository extends EntityRepository {} +export class IdempotencyKeysRepository extends TrekRepository {} diff --git a/server/src/db/repositories/InviteTokens.repository.ts b/server/src/db/repositories/InviteTokens.repository.ts index 69ca9f47c..bb104fa3d 100644 --- a/server/src/db/repositories/InviteTokens.repository.ts +++ b/server/src/db/repositories/InviteTokens.repository.ts @@ -1,7 +1,7 @@ import type { InviteTokens } from '../entities/InviteTokens.entity'; import { toRow, type AssertRowKeys } from './_shared/rows'; import { columnIncrementedBy, columnRef } from '../dialect/sql-functions'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; /** An `invite_tokens` row as the API emits it. */ export interface InviteTokenRow { @@ -44,7 +44,7 @@ export interface NewInviteTokenRow { * ruling. Task 0 builds the three methods every later task needs in common; * Task 3 extends this with the admin list/create/delete surface (RI1/RI5-RI7). */ -export class InviteTokensRepository extends EntityRepository { +export class InviteTokensRepository extends TrekRepository { /** * `SELECT * FROM invite_tokens WHERE token = ?` * @@ -66,7 +66,7 @@ export class InviteTokensRepository extends EntityRepository { * isolated, unmanaged read can never be part of that flush. */ async findByToken(token: string): Promise { - const invite = await this.findOne({ token }, { disableIdentityMap: true }); + const invite = await this.findOne({ token }); return invite ? (toRow(invite) as InviteTokenRow) : null; } @@ -95,7 +95,7 @@ export class InviteTokensRepository extends EntityRepository { createdByRef: row.created_by, trip: row.trip_id ?? null, }); - const invite = await this.findOne({ id }, { disableIdentityMap: true }); + const invite = await this.findOne({ id }); return toRow(invite!) as InviteTokenRow; } @@ -198,7 +198,7 @@ export class InviteTokensRepository extends EntityRepository { * unrelated repositories side by side, not a compiler conflict). */ async findIdById(id: number): Promise { - const row = await this.findOne({ id }, { fields: ['id'], disableIdentityMap: true }); + const row = await this.findOne({ id }, { fields: ['id'] }); return row ? row.id : null; } diff --git a/server/src/db/repositories/JourneyBooks.repository.ts b/server/src/db/repositories/JourneyBooks.repository.ts index 504a76a82..37a9575cd 100644 --- a/server/src/db/repositories/JourneyBooks.repository.ts +++ b/server/src/db/repositories/JourneyBooks.repository.ts @@ -1,4 +1,4 @@ import type { JourneyBooks } from '../entities/JourneyBooks.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class JourneyBooksRepository extends EntityRepository {} +export class JourneyBooksRepository extends TrekRepository {} diff --git a/server/src/db/repositories/JourneyContributors.repository.ts b/server/src/db/repositories/JourneyContributors.repository.ts index 096f9fda2..8c055f1a2 100644 --- a/server/src/db/repositories/JourneyContributors.repository.ts +++ b/server/src/db/repositories/JourneyContributors.repository.ts @@ -1,4 +1,4 @@ import type { JourneyContributors } from '../entities/JourneyContributors.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class JourneyContributorsRepository extends EntityRepository {} +export class JourneyContributorsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/JourneyEntries.repository.ts b/server/src/db/repositories/JourneyEntries.repository.ts index 96fcd06c0..3530484ae 100644 --- a/server/src/db/repositories/JourneyEntries.repository.ts +++ b/server/src/db/repositories/JourneyEntries.repository.ts @@ -1,4 +1,4 @@ import type { JourneyEntries } from '../entities/JourneyEntries.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class JourneyEntriesRepository extends EntityRepository {} +export class JourneyEntriesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/JourneyEntryPhotos.repository.ts b/server/src/db/repositories/JourneyEntryPhotos.repository.ts index 81b8bda92..3c0867b59 100644 --- a/server/src/db/repositories/JourneyEntryPhotos.repository.ts +++ b/server/src/db/repositories/JourneyEntryPhotos.repository.ts @@ -1,4 +1,4 @@ import type { JourneyEntryPhotos } from '../entities/JourneyEntryPhotos.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class JourneyEntryPhotosRepository extends EntityRepository {} +export class JourneyEntryPhotosRepository extends TrekRepository {} diff --git a/server/src/db/repositories/JourneyPhotos.repository.ts b/server/src/db/repositories/JourneyPhotos.repository.ts index 191de482d..f03e6f949 100644 --- a/server/src/db/repositories/JourneyPhotos.repository.ts +++ b/server/src/db/repositories/JourneyPhotos.repository.ts @@ -1,4 +1,4 @@ import type { JourneyPhotos } from '../entities/JourneyPhotos.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class JourneyPhotosRepository extends EntityRepository {} +export class JourneyPhotosRepository extends TrekRepository {} diff --git a/server/src/db/repositories/JourneyShareTokens.repository.ts b/server/src/db/repositories/JourneyShareTokens.repository.ts index 2565419d2..14589c483 100644 --- a/server/src/db/repositories/JourneyShareTokens.repository.ts +++ b/server/src/db/repositories/JourneyShareTokens.repository.ts @@ -1,4 +1,4 @@ import type { JourneyShareTokens } from '../entities/JourneyShareTokens.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class JourneyShareTokensRepository extends EntityRepository {} +export class JourneyShareTokensRepository extends TrekRepository {} diff --git a/server/src/db/repositories/JourneyTrips.repository.ts b/server/src/db/repositories/JourneyTrips.repository.ts index f1de6b039..f217f3fb3 100644 --- a/server/src/db/repositories/JourneyTrips.repository.ts +++ b/server/src/db/repositories/JourneyTrips.repository.ts @@ -1,4 +1,4 @@ import type { JourneyTrips } from '../entities/JourneyTrips.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class JourneyTripsRepository extends EntityRepository {} +export class JourneyTripsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/Journeys.repository.ts b/server/src/db/repositories/Journeys.repository.ts index 764970d72..e1fd9c0ef 100644 --- a/server/src/db/repositories/Journeys.repository.ts +++ b/server/src/db/repositories/Journeys.repository.ts @@ -1,4 +1,4 @@ import type { Journeys } from '../entities/Journeys.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class JourneysRepository extends EntityRepository {} +export class JourneysRepository extends TrekRepository {} diff --git a/server/src/db/repositories/McpTokens.repository.ts b/server/src/db/repositories/McpTokens.repository.ts index 18664ac04..a23a0c227 100644 --- a/server/src/db/repositories/McpTokens.repository.ts +++ b/server/src/db/repositories/McpTokens.repository.ts @@ -1,7 +1,7 @@ import { McpTokens } from '../entities/McpTokens.entity'; import type { AssertRowKeys } from './_shared/rows'; import { columnRef, currentTimestamp } from '../dialect/sql-functions'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; /** * A full `mcp_tokens` row (`SELECT * FROM mcp_tokens WHERE ...`), as the API @@ -100,7 +100,7 @@ export interface McpTokenUserRow { * on every read here. `deleteAllForUser` exists for Task 5's `AuthService` * (AU17/AU45 — session revocation on password change), not consumed here. */ -export class McpTokensRepository extends EntityRepository { +export class McpTokensRepository extends TrekRepository { /** * `SELECT id, name, token_prefix, created_at, last_used_at, scope_mode, * api_scopes FROM mcp_tokens WHERE user_id = ? AND kind = ? @@ -111,7 +111,9 @@ export class McpTokensRepository extends EntityRepository { * into a grant — both stay in the service, unchanged. * * `disableIdentityMap: true` (controller ruling on Task 1's review, - * applied to every entity-hydrating read in this repository): a `find`/ + * applied to every entity-hydrating read in this repository, by the base + * class's default since Plan 3b interlude B — `_shared/trek-repository.ts`): + * a `find`/ * `findOne` with a `fields` projection merges a PARTIAL snapshot of this * row into the request's shared identity map. If the same row is later * loaded elsewhere in the request with a *different* projection, MikroORM @@ -131,7 +133,6 @@ export class McpTokensRepository extends EntityRepository { { fields: ['id', 'name', 'token_prefix', 'created_at', 'last_used_at', 'scope_mode', 'api_scopes'], orderBy: { created_at: 'desc' }, - disableIdentityMap: true, }, ); return rows.map((row) => ({ @@ -184,7 +185,7 @@ export class McpTokensRepository extends EntityRepository { * nothing else. */ async insertToken(row: NewMcpTokenRow): Promise<{ id: number }> { - const id = await this.getEntityManager().insert(McpTokens, { + const id = await this.insert({ user: row.user_id, name: row.name, token_hash: row.token_hash, @@ -242,7 +243,7 @@ export class McpTokensRepository extends EntityRepository { async findBasic(id: number): Promise { const row = await this.findOne( { id }, - { fields: ['id', 'user', 'name', 'token_prefix', 'created_at', 'last_used_at'], disableIdentityMap: true }, + { fields: ['id', 'user', 'name', 'token_prefix', 'created_at', 'last_used_at'] }, ); return row ? { @@ -268,7 +269,7 @@ export class McpTokensRepository extends EntityRepository { * is the hazard, not specifically a PK-only filter). */ async findOwnedByKind(id: number, userId: number, kind: string): Promise { - const row = await this.findOne({ id, user: userId, kind }, { fields: ['id'], disableIdentityMap: true }); + const row = await this.findOne({ id, user: userId, kind }, { fields: ['id'] }); return row ? { id: row.id } : null; } diff --git a/server/src/db/repositories/Migrations.repository.ts b/server/src/db/repositories/Migrations.repository.ts index c3655cb2f..b35051c92 100644 --- a/server/src/db/repositories/Migrations.repository.ts +++ b/server/src/db/repositories/Migrations.repository.ts @@ -1,4 +1,4 @@ import type { Migrations } from '../entities/Migrations.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class MigrationsRepository extends EntityRepository {} +export class MigrationsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/NotificationChannelPreferences.repository.ts b/server/src/db/repositories/NotificationChannelPreferences.repository.ts index d554a4cfb..fb184f19f 100644 --- a/server/src/db/repositories/NotificationChannelPreferences.repository.ts +++ b/server/src/db/repositories/NotificationChannelPreferences.repository.ts @@ -1,4 +1,4 @@ import type { NotificationChannelPreferences } from '../entities/NotificationChannelPreferences.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class NotificationChannelPreferencesRepository extends EntityRepository {} +export class NotificationChannelPreferencesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/Notifications.repository.ts b/server/src/db/repositories/Notifications.repository.ts index 4cdb296e5..c7b2ebb9c 100644 --- a/server/src/db/repositories/Notifications.repository.ts +++ b/server/src/db/repositories/Notifications.repository.ts @@ -1,4 +1,4 @@ import type { Notifications } from '../entities/Notifications.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class NotificationsRepository extends EntityRepository {} +export class NotificationsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/OauthClients.repository.ts b/server/src/db/repositories/OauthClients.repository.ts index 9772556e6..938908bae 100644 --- a/server/src/db/repositories/OauthClients.repository.ts +++ b/server/src/db/repositories/OauthClients.repository.ts @@ -1,6 +1,6 @@ import { OauthClients } from '../entities/OauthClients.entity'; import { toRow, type AssertRowKeys } from './_shared/rows'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; /** * A full `oauth_clients` row (`SELECT * FROM oauth_clients WHERE ...`), as @@ -99,14 +99,16 @@ export interface OauthClientAuthRow { * OAuth 2.1 client registrations (self-service + DCR + machine clients), * Plan 3b Task 4, inventory §3 OA1-OA10/OA15/OA21/OA29/OA30. Every row-out * read passes `disableIdentityMap: true` (program RULING, Task 1 review B1 - * — see `Users.repository.ts`'s class-level docstring): a partial-field + * — see `Users.repository.ts`'s class-level docstring; applied by the base + * class's default since Plan 3b interlude B, `_shared/trek-repository.ts`): + * a partial-field * `find`/`findOne` merged into the request's identity map can go stale and * get flushed back over an unrelated `nativeUpdate` in the same transaction; * a `disableIdentityMap: true` read is always isolated and discarded. * `client_secret_hash` is never hashed or compared here — that stays in * `OauthService` (`hashToken`/`timingSafeEqualHex`). */ -export class OauthClientsRepository extends EntityRepository { +export class OauthClientsRepository extends TrekRepository { // --------------------------------------------------------------------- // OA1 — self-service client list // --------------------------------------------------------------------- @@ -129,7 +131,6 @@ export class OauthClientsRepository extends EntityRepository { { fields: ['id', 'user', 'name', 'client_id', 'redirect_uris', 'allowed_scopes', 'created_at', 'is_public', 'created_via', 'allows_client_credentials'], orderBy: { created_at: 'desc' }, - disableIdentityMap: true, }, ); return rows.map((row) => ({ @@ -179,7 +180,7 @@ export class OauthClientsRepository extends EntityRepository { * does too, never relying on a DB-generated default. */ async insertClient(row: NewOauthClientRow): Promise { - await this.getEntityManager().insert(OauthClients, { + await this.insert({ id: row.id, user: row.user_id, name: row.name, @@ -204,7 +205,6 @@ export class OauthClientsRepository extends EntityRepository { { id }, { fields: ['id', 'user', 'name', 'client_id', 'redirect_uris', 'allowed_scopes', 'created_at', 'is_public', 'created_via', 'allows_client_credentials'], - disableIdentityMap: true, }, ); return row @@ -240,7 +240,7 @@ export class OauthClientsRepository extends EntityRepository { async findOwned(id: string, userId: number): Promise { const row = await this.findOne( { id, user: userId }, - { fields: ['id', 'client_id', 'is_public'], disableIdentityMap: true }, + { fields: ['id', 'client_id', 'is_public'] }, ); return row ? { id: row.id as string, client_id: row.client_id, is_public: row.is_public } : null; } @@ -276,7 +276,7 @@ export class OauthClientsRepository extends EntityRepository { async findSdkProjection(clientId: string): Promise { const row = await this.findOne( { client_id: clientId }, - { fields: ['client_id', 'name', 'redirect_uris', 'allowed_scopes', 'is_public', 'created_via'], disableIdentityMap: true }, + { fields: ['client_id', 'name', 'redirect_uris', 'allowed_scopes', 'is_public', 'created_via'] }, ); return row ? { client_id: row.client_id, name: row.name, redirect_uris: row.redirect_uris, allowed_scopes: row.allowed_scopes, is_public: row.is_public, created_via: row.created_via } @@ -298,7 +298,7 @@ export class OauthClientsRepository extends EntityRepository { async findAuthRow(clientId: string): Promise { const row = await this.findOne( { client_id: clientId }, - { fields: ['client_id', 'client_secret_hash', 'is_public'], disableIdentityMap: true }, + { fields: ['client_id', 'client_secret_hash', 'is_public'] }, ); return row ? { client_id: row.client_id, client_secret_hash: row.client_secret_hash, is_public: row.is_public } : null; } @@ -316,7 +316,7 @@ export class OauthClientsRepository extends EntityRepository { * projections). */ async findByClientIdFull(clientId: string): Promise { - const row = await this.findOne({ client_id: clientId }, { disableIdentityMap: true }); + const row = await this.findOne({ client_id: clientId }); return row ? (toRow(row) as OauthClientRow) : null; } } diff --git a/server/src/db/repositories/OauthConsents.repository.ts b/server/src/db/repositories/OauthConsents.repository.ts index b634c3919..ec8ed7064 100644 --- a/server/src/db/repositories/OauthConsents.repository.ts +++ b/server/src/db/repositories/OauthConsents.repository.ts @@ -1,6 +1,27 @@ import { OauthConsents } from '../entities/OauthConsents.entity'; import { currentTimestamp } from '../dialect/sql-functions'; -import { EntityRepository } from '@mikro-orm/sql'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * No method in this repository returns a full `oauth_consents` row (unlike + * `OauthClients`/`OauthTokens` in the same domain) — this interface exists + * only so `AssertRowKeys` below still pins that every scalar column of the + * entity is accounted for SOMEWHERE, the same `OauthTokenScalarColumns` + * shape `OauthTokens.repository.ts` uses (Plan 3b interlude B, task-4-review + * F4: this repository had no such pin at all — a column added to or removed + * from `oauth_consents` by a future migration+regeneration would not fail + * `tsc`). + */ +interface OauthConsentRow { + id: number; + client_id: string; + user_id: number; + scopes: string; + updated_at: string | null; +} + +const _oauthConsentRowKeys: AssertRowKeys = true; /** * Per-(client, user) OAuth consent grants, Plan 3b Task 4, inventory §3 @@ -13,7 +34,7 @@ import { EntityRepository } from '@mikro-orm/sql'; * never narrow stored consent) stays in the SERVICE — this repository * writes exactly the scopes JSON it is given. */ -export class OauthConsentsRepository extends EntityRepository { +export class OauthConsentsRepository extends TrekRepository { // --------------------------------------------------------------------- // OA11 — consent-sufficiency read // --------------------------------------------------------------------- @@ -33,7 +54,7 @@ export class OauthConsentsRepository extends EntityRepository { async findScopes(clientId: string, userId: number): Promise<{ scopes: string } | null> { const row = await this.findOne( { client: clientId, user: userId }, - { fields: ['scopes'], disableIdentityMap: true }, + { fields: ['scopes'] }, ); return row ? { scopes: row.scopes } : null; } @@ -75,8 +96,7 @@ export class OauthConsentsRepository extends EntityRepository { */ async upsertGrant(clientId: string, userId: number, scopes: string): Promise { const platform = this.getEntityManager().getPlatform(); - await this.getEntityManager().upsert( - OauthConsents, + await this.upsert( { client: clientId, user: userId, scopes, updated_at: currentTimestamp(platform) }, { onConflictFields: ['client', 'user'], onConflictAction: 'merge' }, ); diff --git a/server/src/db/repositories/OauthTokens.repository.ts b/server/src/db/repositories/OauthTokens.repository.ts index c8c970d68..f76a86a61 100644 --- a/server/src/db/repositories/OauthTokens.repository.ts +++ b/server/src/db/repositories/OauthTokens.repository.ts @@ -1,7 +1,7 @@ import { OauthTokens } from '../entities/OauthTokens.entity'; import { type AssertRowKeys } from './_shared/rows'; import { columnRef, currentTimestamp } from '../dialect/sql-functions'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; /** * The Kysely-side table shape `em.getKysely()` needs for `collectChainIds`'s @@ -137,7 +137,8 @@ const _oauthTokenScalarProbe: AssertRowKeys { +export class OauthTokensRepository extends TrekRepository { // --------------------------------------------------------------------- // OA13 / OA14 — token issuance // --------------------------------------------------------------------- @@ -185,7 +186,7 @@ export class OauthTokensRepository extends EntityRepository { * ::insertToken` avoid. */ async insertToken(row: NewOauthTokenRow): Promise { - await this.getEntityManager().insert(OauthTokens, { + await this.insert({ client: row.client_id, user: row.user_id, access_token_hash: row.access_token_hash, @@ -297,7 +298,7 @@ export class OauthTokensRepository extends EntityRepository { * the mirror directly leaves it `undefined`. */ async findParent(id: number): Promise { - const row = await this.findOne({ id }, { fields: ['id', 'parentToken'], disableIdentityMap: true }); + const row = await this.findOne({ id }, { fields: ['id', 'parentToken'] }); return row ? { id: row.id, parent_token_id: row.parentToken?.id ?? null } : null; } @@ -328,10 +329,13 @@ export class OauthTokensRepository extends EntityRepository { * on a 4-level chain plus a sibling branch that must NOT be collected * (`OAUTHTOKREPO` CTE test) — see the task report for the exact generated * SQL and both result sets side by side. + * + * `this.kysely()` (`_shared/trek-repository.ts`, Plan 3b interlude B), not + * `this.getEntityManager().getKysely()` directly — see + * `WebauthnChallengesRepository.claimChallenge`'s docstring for why. */ async collectChainIds(rootId: number): Promise { - const rows = await this.getEntityManager() - .getKysely() + const rows = await this.kysely() .withRecursive('chain', (db) => db.selectFrom('oauth_tokens') .select('id') @@ -383,7 +387,7 @@ export class OauthTokensRepository extends EntityRepository { * plain equality filter is unaffected by this file's class-level finding. */ async findSuccessorAlive(parentId: number): Promise<{ id: number } | null> { - const row = await this.findOne({ parentToken: parentId, revoked_at: null }, { fields: ['id'], disableIdentityMap: true }); + const row = await this.findOne({ parentToken: parentId, revoked_at: null }, { fields: ['id'] }); return row ? { id: row.id } : null; } @@ -407,7 +411,7 @@ export class OauthTokensRepository extends EntityRepository { async findByRefreshTokenHash(hash: string): Promise { const row = await this.findOne( { refresh_token_hash: hash }, - { fields: ['id', 'client', 'user', 'scopes', 'audience', 'refresh_token_expires_at', 'revoked_at', 'parentToken'], disableIdentityMap: true }, + { fields: ['id', 'client', 'user', 'scopes', 'audience', 'refresh_token_expires_at', 'revoked_at', 'parentToken'] }, ); return row ? { @@ -459,7 +463,7 @@ export class OauthTokensRepository extends EntityRepository { async findByAccessOrRefreshHashAndClient(hash: string, clientId: string): Promise { const row = await this.findOne( { $or: [{ access_token_hash: hash }, { refresh_token_hash: hash }], client: clientId }, - { fields: ['user'], disableIdentityMap: true }, + { fields: ['user'] }, ); return row ? { user_id: row.user.id } : null; } @@ -542,7 +546,7 @@ export class OauthTokensRepository extends EntityRepository { /** `SELECT id, client_id FROM oauth_tokens WHERE id = ? AND user_id = ?` — 404-never-403, ownership scoped in the WHERE. */ async findOwnedById(id: number, userId: number): Promise { - const row = await this.findOne({ id, user: userId }, { fields: ['id', 'client'], disableIdentityMap: true }); + const row = await this.findOne({ id, user: userId }, { fields: ['id', 'client'] }); return row ? { id: row.id, client_id: row.client.id } : null; } @@ -611,7 +615,7 @@ export class OauthTokensRepository extends EntityRepository { /** `SELECT id, user_id, client_id FROM oauth_tokens WHERE id = ?` — unscoped (admin), distinct from `findOwnedById` (OA27). */ async findById(id: number): Promise { - const row = await this.findOne({ id }, { fields: ['id', 'user', 'client'], disableIdentityMap: true }); + const row = await this.findOne({ id }, { fields: ['id', 'user', 'client'] }); return row ? { id: row.id, user_id: row.user.id, client_id: row.client.id } : null; } } diff --git a/server/src/db/repositories/PackingBags.repository.ts b/server/src/db/repositories/PackingBags.repository.ts index 6d319f938..b24217789 100644 --- a/server/src/db/repositories/PackingBags.repository.ts +++ b/server/src/db/repositories/PackingBags.repository.ts @@ -1,4 +1,4 @@ import type { PackingBags } from '../entities/PackingBags.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PackingBagsRepository extends EntityRepository {} +export class PackingBagsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PackingCategoryAssignees.repository.ts b/server/src/db/repositories/PackingCategoryAssignees.repository.ts index 5c5b61863..bc4bbeadc 100644 --- a/server/src/db/repositories/PackingCategoryAssignees.repository.ts +++ b/server/src/db/repositories/PackingCategoryAssignees.repository.ts @@ -1,4 +1,4 @@ import type { PackingCategoryAssignees } from '../entities/PackingCategoryAssignees.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PackingCategoryAssigneesRepository extends EntityRepository {} +export class PackingCategoryAssigneesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PackingItemContributors.repository.ts b/server/src/db/repositories/PackingItemContributors.repository.ts index b5cd81ef7..8f3b31533 100644 --- a/server/src/db/repositories/PackingItemContributors.repository.ts +++ b/server/src/db/repositories/PackingItemContributors.repository.ts @@ -1,4 +1,4 @@ import type { PackingItemContributors } from '../entities/PackingItemContributors.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PackingItemContributorsRepository extends EntityRepository {} +export class PackingItemContributorsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PackingItems.repository.ts b/server/src/db/repositories/PackingItems.repository.ts index c80366e75..a2bec36a8 100644 --- a/server/src/db/repositories/PackingItems.repository.ts +++ b/server/src/db/repositories/PackingItems.repository.ts @@ -1,4 +1,4 @@ import type { PackingItems } from '../entities/PackingItems.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PackingItemsRepository extends EntityRepository {} +export class PackingItemsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PackingTemplateCategories.repository.ts b/server/src/db/repositories/PackingTemplateCategories.repository.ts index 8718ef311..1351abab2 100644 --- a/server/src/db/repositories/PackingTemplateCategories.repository.ts +++ b/server/src/db/repositories/PackingTemplateCategories.repository.ts @@ -1,4 +1,4 @@ import type { PackingTemplateCategories } from '../entities/PackingTemplateCategories.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PackingTemplateCategoriesRepository extends EntityRepository {} +export class PackingTemplateCategoriesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PackingTemplateItems.repository.ts b/server/src/db/repositories/PackingTemplateItems.repository.ts index 782cd44c2..0428658ea 100644 --- a/server/src/db/repositories/PackingTemplateItems.repository.ts +++ b/server/src/db/repositories/PackingTemplateItems.repository.ts @@ -1,4 +1,4 @@ import type { PackingTemplateItems } from '../entities/PackingTemplateItems.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PackingTemplateItemsRepository extends EntityRepository {} +export class PackingTemplateItemsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PackingTemplates.repository.ts b/server/src/db/repositories/PackingTemplates.repository.ts index aa37f129a..6106fcda1 100644 --- a/server/src/db/repositories/PackingTemplates.repository.ts +++ b/server/src/db/repositories/PackingTemplates.repository.ts @@ -1,4 +1,4 @@ import type { PackingTemplates } from '../entities/PackingTemplates.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PackingTemplatesRepository extends EntityRepository {} +export class PackingTemplatesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PasswordResetTokens.repository.ts b/server/src/db/repositories/PasswordResetTokens.repository.ts index e6f047cf2..9a1ea2d5b 100644 --- a/server/src/db/repositories/PasswordResetTokens.repository.ts +++ b/server/src/db/repositories/PasswordResetTokens.repository.ts @@ -1,4 +1,4 @@ import type { PasswordResetTokens } from '../entities/PasswordResetTokens.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PasswordResetTokensRepository extends EntityRepository {} +export class PasswordResetTokensRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PhotoProviderFields.repository.ts b/server/src/db/repositories/PhotoProviderFields.repository.ts index e894cf771..557961a94 100644 --- a/server/src/db/repositories/PhotoProviderFields.repository.ts +++ b/server/src/db/repositories/PhotoProviderFields.repository.ts @@ -1,6 +1,6 @@ import type { PhotoProviderFields } from '../entities/PhotoProviderFields.entity'; import { toRow, type AssertRowKeys } from './_shared/rows'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; /** A `photo_provider_fields` row as the API emits it. */ export interface PhotoProviderFieldRow { @@ -20,7 +20,7 @@ export interface PhotoProviderFieldRow { const _photoProviderFieldRowKeys: AssertRowKeys = true; -export class PhotoProviderFieldsRepository extends EntityRepository { +export class PhotoProviderFieldsRepository extends TrekRepository { /** * `SELECT provider_id, field_key, label, input_type, placeholder, hint, * required, secret, settings_key, payload_key, sort_order FROM @@ -29,11 +29,12 @@ export class PhotoProviderFieldsRepository extends EntityRepository { - const rows = await this.find({}, { orderBy: { sort_order: 'asc', id: 'asc' }, disableIdentityMap: true }); + const rows = await this.find({}, { orderBy: { sort_order: 'asc', id: 'asc' } }); return rows.map((row) => toRow(row) as PhotoProviderFieldRow); } } diff --git a/server/src/db/repositories/PhotoProviders.repository.ts b/server/src/db/repositories/PhotoProviders.repository.ts index e2cbbae3c..b2da9a340 100644 --- a/server/src/db/repositories/PhotoProviders.repository.ts +++ b/server/src/db/repositories/PhotoProviders.repository.ts @@ -1,6 +1,6 @@ import type { PhotoProviders } from '../entities/PhotoProviders.entity'; import { toRow, type AssertRowKeys } from './_shared/rows'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; /** A `photo_providers` row as the API emits it. */ export interface PhotoProviderRow { @@ -14,7 +14,7 @@ export interface PhotoProviderRow { const _photoProviderRowKeys: AssertRowKeys = true; -export class PhotoProvidersRepository extends EntityRepository { +export class PhotoProvidersRepository extends TrekRepository { /** * `SELECT id, name, icon, enabled, sort_order FROM photo_providers WHERE * enabled = 1 ORDER BY sort_order, id` — `enabled` is `p.integer()` on this @@ -22,11 +22,12 @@ export class PhotoProvidersRepository extends EntityRepository { * back as the raw stored `0`/`1`; `AddonsService.list()` does the `!!` * coercion itself, matching the legacy `!!p.enabled`. * - * `disableIdentityMap: true` (Plan 3b Task 1 fix round) — a "rows out" + * `disableIdentityMap: true`, applied by the base class's default (Plan 3b + * interlude B — `_shared/trek-repository.ts`) — a "rows out" * read, converted via `toRow` and discarded. */ async listEnabled(): Promise { - const rows = await this.find({ enabled: 1 }, { orderBy: { sort_order: 'asc', id: 'asc' }, disableIdentityMap: true }); + const rows = await this.find({ enabled: 1 }, { orderBy: { sort_order: 'asc', id: 'asc' } }); return rows.map((row) => toRow(row) as PhotoProviderRow); } } diff --git a/server/src/db/repositories/Photos.repository.ts b/server/src/db/repositories/Photos.repository.ts index ebae2fa64..99502a2f5 100644 --- a/server/src/db/repositories/Photos.repository.ts +++ b/server/src/db/repositories/Photos.repository.ts @@ -1,4 +1,4 @@ import type { Photos } from '../entities/Photos.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PhotosRepository extends EntityRepository {} +export class PhotosRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PlaceDetailsCache.repository.ts b/server/src/db/repositories/PlaceDetailsCache.repository.ts index 79faa88e2..433331e9a 100644 --- a/server/src/db/repositories/PlaceDetailsCache.repository.ts +++ b/server/src/db/repositories/PlaceDetailsCache.repository.ts @@ -1,4 +1,4 @@ import type { PlaceDetailsCache } from '../entities/PlaceDetailsCache.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PlaceDetailsCacheRepository extends EntityRepository {} +export class PlaceDetailsCacheRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PlaceRatings.repository.ts b/server/src/db/repositories/PlaceRatings.repository.ts index a3bd830b9..c74660529 100644 --- a/server/src/db/repositories/PlaceRatings.repository.ts +++ b/server/src/db/repositories/PlaceRatings.repository.ts @@ -1,4 +1,4 @@ import type { PlaceRatings } from '../entities/PlaceRatings.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PlaceRatingsRepository extends EntityRepository {} +export class PlaceRatingsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PlaceRegions.repository.ts b/server/src/db/repositories/PlaceRegions.repository.ts index d52c85ded..3de0d5614 100644 --- a/server/src/db/repositories/PlaceRegions.repository.ts +++ b/server/src/db/repositories/PlaceRegions.repository.ts @@ -1,4 +1,4 @@ import type { PlaceRegions } from '../entities/PlaceRegions.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PlaceRegionsRepository extends EntityRepository {} +export class PlaceRegionsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PlaceShadowPicks.repository.ts b/server/src/db/repositories/PlaceShadowPicks.repository.ts index 69b886db0..c6752bd80 100644 --- a/server/src/db/repositories/PlaceShadowPicks.repository.ts +++ b/server/src/db/repositories/PlaceShadowPicks.repository.ts @@ -1,4 +1,4 @@ import type { PlaceShadowPicks } from '../entities/PlaceShadowPicks.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PlaceShadowPicksRepository extends EntityRepository {} +export class PlaceShadowPicksRepository extends TrekRepository {} diff --git a/server/src/db/repositories/Places.repository.ts b/server/src/db/repositories/Places.repository.ts index 8e18821be..b750fedac 100644 --- a/server/src/db/repositories/Places.repository.ts +++ b/server/src/db/repositories/Places.repository.ts @@ -1,4 +1,4 @@ import type { Places } from '../entities/Places.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PlacesRepository extends EntityRepository {} +export class PlacesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PluginActions.repository.ts b/server/src/db/repositories/PluginActions.repository.ts index 9daef6c70..4a4a40aa0 100644 --- a/server/src/db/repositories/PluginActions.repository.ts +++ b/server/src/db/repositories/PluginActions.repository.ts @@ -1,4 +1,4 @@ import type { PluginActions } from '../entities/PluginActions.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PluginActionsRepository extends EntityRepository {} +export class PluginActionsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PluginCapabilityAudit.repository.ts b/server/src/db/repositories/PluginCapabilityAudit.repository.ts index d4a4c29fe..9bea2a678 100644 --- a/server/src/db/repositories/PluginCapabilityAudit.repository.ts +++ b/server/src/db/repositories/PluginCapabilityAudit.repository.ts @@ -1,4 +1,4 @@ import type { PluginCapabilityAudit } from '../entities/PluginCapabilityAudit.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PluginCapabilityAuditRepository extends EntityRepository {} +export class PluginCapabilityAuditRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PluginEgressHosts.repository.ts b/server/src/db/repositories/PluginEgressHosts.repository.ts index 66b8463ff..a2be28f26 100644 --- a/server/src/db/repositories/PluginEgressHosts.repository.ts +++ b/server/src/db/repositories/PluginEgressHosts.repository.ts @@ -1,4 +1,4 @@ import type { PluginEgressHosts } from '../entities/PluginEgressHosts.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PluginEgressHostsRepository extends EntityRepository {} +export class PluginEgressHostsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PluginEntityMetadata.repository.ts b/server/src/db/repositories/PluginEntityMetadata.repository.ts index 79ef31386..b69635715 100644 --- a/server/src/db/repositories/PluginEntityMetadata.repository.ts +++ b/server/src/db/repositories/PluginEntityMetadata.repository.ts @@ -1,4 +1,4 @@ import type { PluginEntityMetadata } from '../entities/PluginEntityMetadata.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PluginEntityMetadataRepository extends EntityRepository {} +export class PluginEntityMetadataRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PluginErrorLog.repository.ts b/server/src/db/repositories/PluginErrorLog.repository.ts index 087d9a69a..49e129263 100644 --- a/server/src/db/repositories/PluginErrorLog.repository.ts +++ b/server/src/db/repositories/PluginErrorLog.repository.ts @@ -1,4 +1,4 @@ import type { PluginErrorLog } from '../entities/PluginErrorLog.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PluginErrorLogRepository extends EntityRepository {} +export class PluginErrorLogRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PluginMetaMigrations.repository.ts b/server/src/db/repositories/PluginMetaMigrations.repository.ts index 05f0e403c..fd17f19d7 100644 --- a/server/src/db/repositories/PluginMetaMigrations.repository.ts +++ b/server/src/db/repositories/PluginMetaMigrations.repository.ts @@ -1,4 +1,4 @@ import type { PluginMetaMigrations } from '../entities/PluginMetaMigrations.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PluginMetaMigrationsRepository extends EntityRepository {} +export class PluginMetaMigrationsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PluginOauthState.repository.ts b/server/src/db/repositories/PluginOauthState.repository.ts index 48211f24c..1e4a43258 100644 --- a/server/src/db/repositories/PluginOauthState.repository.ts +++ b/server/src/db/repositories/PluginOauthState.repository.ts @@ -1,4 +1,4 @@ import type { PluginOauthState } from '../entities/PluginOauthState.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PluginOauthStateRepository extends EntityRepository {} +export class PluginOauthStateRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PluginOauthTokens.repository.ts b/server/src/db/repositories/PluginOauthTokens.repository.ts index 9c3be3162..17fdaba37 100644 --- a/server/src/db/repositories/PluginOauthTokens.repository.ts +++ b/server/src/db/repositories/PluginOauthTokens.repository.ts @@ -1,4 +1,4 @@ import type { PluginOauthTokens } from '../entities/PluginOauthTokens.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PluginOauthTokensRepository extends EntityRepository {} +export class PluginOauthTokensRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PluginScheduledTasks.repository.ts b/server/src/db/repositories/PluginScheduledTasks.repository.ts index c41303237..84f1e63e5 100644 --- a/server/src/db/repositories/PluginScheduledTasks.repository.ts +++ b/server/src/db/repositories/PluginScheduledTasks.repository.ts @@ -1,4 +1,4 @@ import type { PluginScheduledTasks } from '../entities/PluginScheduledTasks.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PluginScheduledTasksRepository extends EntityRepository {} +export class PluginScheduledTasksRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PluginSettingsFields.repository.ts b/server/src/db/repositories/PluginSettingsFields.repository.ts index f525c784b..ca6bd42d2 100644 --- a/server/src/db/repositories/PluginSettingsFields.repository.ts +++ b/server/src/db/repositories/PluginSettingsFields.repository.ts @@ -1,4 +1,4 @@ import type { PluginSettingsFields } from '../entities/PluginSettingsFields.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PluginSettingsFieldsRepository extends EntityRepository {} +export class PluginSettingsFieldsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PluginUserConfig.repository.ts b/server/src/db/repositories/PluginUserConfig.repository.ts index 849cb756f..9923d6e78 100644 --- a/server/src/db/repositories/PluginUserConfig.repository.ts +++ b/server/src/db/repositories/PluginUserConfig.repository.ts @@ -1,4 +1,4 @@ import type { PluginUserConfig } from '../entities/PluginUserConfig.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PluginUserConfigRepository extends EntityRepository {} +export class PluginUserConfigRepository extends TrekRepository {} diff --git a/server/src/db/repositories/PluginUserErasureQueue.repository.ts b/server/src/db/repositories/PluginUserErasureQueue.repository.ts index b91b4548e..de058f174 100644 --- a/server/src/db/repositories/PluginUserErasureQueue.repository.ts +++ b/server/src/db/repositories/PluginUserErasureQueue.repository.ts @@ -1,4 +1,4 @@ import type { PluginUserErasureQueue } from '../entities/PluginUserErasureQueue.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PluginUserErasureQueueRepository extends EntityRepository {} +export class PluginUserErasureQueueRepository extends TrekRepository {} diff --git a/server/src/db/repositories/Plugins.repository.ts b/server/src/db/repositories/Plugins.repository.ts index 479d6be39..9db1d716c 100644 --- a/server/src/db/repositories/Plugins.repository.ts +++ b/server/src/db/repositories/Plugins.repository.ts @@ -1,4 +1,4 @@ import type { Plugins } from '../entities/Plugins.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class PluginsRepository extends EntityRepository {} +export class PluginsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/ReservationDayPositions.repository.ts b/server/src/db/repositories/ReservationDayPositions.repository.ts index ea3dd5c42..13fce5320 100644 --- a/server/src/db/repositories/ReservationDayPositions.repository.ts +++ b/server/src/db/repositories/ReservationDayPositions.repository.ts @@ -1,4 +1,4 @@ import type { ReservationDayPositions } from '../entities/ReservationDayPositions.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class ReservationDayPositionsRepository extends EntityRepository {} +export class ReservationDayPositionsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/ReservationEndpoints.repository.ts b/server/src/db/repositories/ReservationEndpoints.repository.ts index cf5cfb62b..57d3bcca9 100644 --- a/server/src/db/repositories/ReservationEndpoints.repository.ts +++ b/server/src/db/repositories/ReservationEndpoints.repository.ts @@ -1,4 +1,4 @@ import type { ReservationEndpoints } from '../entities/ReservationEndpoints.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class ReservationEndpointsRepository extends EntityRepository {} +export class ReservationEndpointsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/ReservationTravelers.repository.ts b/server/src/db/repositories/ReservationTravelers.repository.ts index 9a77822a1..802477e5d 100644 --- a/server/src/db/repositories/ReservationTravelers.repository.ts +++ b/server/src/db/repositories/ReservationTravelers.repository.ts @@ -1,4 +1,4 @@ import type { ReservationTravelers } from '../entities/ReservationTravelers.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class ReservationTravelersRepository extends EntityRepository {} +export class ReservationTravelersRepository extends TrekRepository {} diff --git a/server/src/db/repositories/Reservations.repository.ts b/server/src/db/repositories/Reservations.repository.ts index 72a992688..52e920a2a 100644 --- a/server/src/db/repositories/Reservations.repository.ts +++ b/server/src/db/repositories/Reservations.repository.ts @@ -1,4 +1,4 @@ import type { Reservations } from '../entities/Reservations.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class ReservationsRepository extends EntityRepository {} +export class ReservationsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/RoadtripDayBoundaries.repository.ts b/server/src/db/repositories/RoadtripDayBoundaries.repository.ts index 7f275564a..a20a6a021 100644 --- a/server/src/db/repositories/RoadtripDayBoundaries.repository.ts +++ b/server/src/db/repositories/RoadtripDayBoundaries.repository.ts @@ -1,4 +1,4 @@ import type { RoadtripDayBoundaries } from '../entities/RoadtripDayBoundaries.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class RoadtripDayBoundariesRepository extends EntityRepository {} +export class RoadtripDayBoundariesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/RoadtripDayTracks.repository.ts b/server/src/db/repositories/RoadtripDayTracks.repository.ts index c166aa35c..90e3bee4e 100644 --- a/server/src/db/repositories/RoadtripDayTracks.repository.ts +++ b/server/src/db/repositories/RoadtripDayTracks.repository.ts @@ -1,4 +1,4 @@ import type { RoadtripDayTracks } from '../entities/RoadtripDayTracks.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class RoadtripDayTracksRepository extends EntityRepository {} +export class RoadtripDayTracksRepository extends TrekRepository {} diff --git a/server/src/db/repositories/RoadtripPreferences.repository.ts b/server/src/db/repositories/RoadtripPreferences.repository.ts index cc759aa0e..ab7b7acb6 100644 --- a/server/src/db/repositories/RoadtripPreferences.repository.ts +++ b/server/src/db/repositories/RoadtripPreferences.repository.ts @@ -1,4 +1,4 @@ import type { RoadtripPreferences } from '../entities/RoadtripPreferences.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class RoadtripPreferencesRepository extends EntityRepository {} +export class RoadtripPreferencesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/RoadtripVias.repository.ts b/server/src/db/repositories/RoadtripVias.repository.ts index 96d7c7575..7d3b41847 100644 --- a/server/src/db/repositories/RoadtripVias.repository.ts +++ b/server/src/db/repositories/RoadtripVias.repository.ts @@ -1,4 +1,4 @@ import type { RoadtripVias } from '../entities/RoadtripVias.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class RoadtripViasRepository extends EntityRepository {} +export class RoadtripViasRepository extends TrekRepository {} diff --git a/server/src/db/repositories/RouteUsageDaily.repository.ts b/server/src/db/repositories/RouteUsageDaily.repository.ts index ab0be4bb6..f11a741ba 100644 --- a/server/src/db/repositories/RouteUsageDaily.repository.ts +++ b/server/src/db/repositories/RouteUsageDaily.repository.ts @@ -1,4 +1,4 @@ import type { RouteUsageDaily } from '../entities/RouteUsageDaily.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class RouteUsageDailyRepository extends EntityRepository {} +export class RouteUsageDailyRepository extends TrekRepository {} diff --git a/server/src/db/repositories/SchemaVersion.repository.ts b/server/src/db/repositories/SchemaVersion.repository.ts index 7c6c36d10..7523911d3 100644 --- a/server/src/db/repositories/SchemaVersion.repository.ts +++ b/server/src/db/repositories/SchemaVersion.repository.ts @@ -1,4 +1,4 @@ import type { SchemaVersion } from '../entities/SchemaVersion.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class SchemaVersionRepository extends EntityRepository {} +export class SchemaVersionRepository extends TrekRepository {} diff --git a/server/src/db/repositories/SchoolHolidayCountries.repository.ts b/server/src/db/repositories/SchoolHolidayCountries.repository.ts index bb847e2ec..4d603c730 100644 --- a/server/src/db/repositories/SchoolHolidayCountries.repository.ts +++ b/server/src/db/repositories/SchoolHolidayCountries.repository.ts @@ -1,4 +1,4 @@ import type { SchoolHolidayCountries } from '../entities/SchoolHolidayCountries.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class SchoolHolidayCountriesRepository extends EntityRepository {} +export class SchoolHolidayCountriesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/SchoolHolidayPeriods.repository.ts b/server/src/db/repositories/SchoolHolidayPeriods.repository.ts index 718600436..f0a8efba4 100644 --- a/server/src/db/repositories/SchoolHolidayPeriods.repository.ts +++ b/server/src/db/repositories/SchoolHolidayPeriods.repository.ts @@ -1,4 +1,4 @@ import type { SchoolHolidayPeriods } from '../entities/SchoolHolidayPeriods.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class SchoolHolidayPeriodsRepository extends EntityRepository {} +export class SchoolHolidayPeriodsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/SchoolHolidayRegions.repository.ts b/server/src/db/repositories/SchoolHolidayRegions.repository.ts index 5d069dbdb..c37c1ba06 100644 --- a/server/src/db/repositories/SchoolHolidayRegions.repository.ts +++ b/server/src/db/repositories/SchoolHolidayRegions.repository.ts @@ -1,4 +1,4 @@ import type { SchoolHolidayRegions } from '../entities/SchoolHolidayRegions.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class SchoolHolidayRegionsRepository extends EntityRepository {} +export class SchoolHolidayRegionsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/Settings.repository.ts b/server/src/db/repositories/Settings.repository.ts index 52bc743f7..7f8080795 100644 --- a/server/src/db/repositories/Settings.repository.ts +++ b/server/src/db/repositories/Settings.repository.ts @@ -1,6 +1,6 @@ import type { Settings } from '../entities/Settings.entity'; import { toRow, type AssertRowKeys } from './_shared/rows'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; /** A `settings` row (the per-user key/value table) as the API emits it. */ export interface SettingRow { @@ -12,23 +12,25 @@ export interface SettingRow { const _settingRowKeys: AssertRowKeys = true; -export class SettingsRepository extends EntityRepository { +export class SettingsRepository extends TrekRepository { /** * `SELECT key, value FROM settings WHERE user_id = ?` * - * `disableIdentityMap: true` (Plan 3b Task 1 fix round) — a "rows out" + * `disableIdentityMap: true`, applied by the base class's default (Plan 3b + * interlude B — `_shared/trek-repository.ts`) — a "rows out" * read, converted via `toRow` and discarded. */ async getForUser(userId: number): Promise { - const rows = await this.find({ user: userId }, { disableIdentityMap: true }); + const rows = await this.find({ user: userId }); return rows.map((row) => toRow(row) as SettingRow); } /** * `SELECT value FROM settings WHERE user_id = ? AND key = ?` * - * `disableIdentityMap: true` (Plan 3b Task 1 fix round, supersedes Plan - * 3a's I1 — see `Users.repository.ts`'s class-level docstring and + * `disableIdentityMap: true`, applied by the base class's default (Plan 3b + * interlude B — supersedes Plan 3a's I1 — see `Users.repository.ts`'s + * class-level docstring and * `.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md` B1): this * filter is `(user, key)` — `Settings`'s PK is the surrogate `id`, not * this composite — so it was never PK-only and always re-queried even @@ -39,7 +41,7 @@ export class SettingsRepository extends EntityRepository { * sees it) rather than assume it. */ async getOne(userId: number, key: string): Promise { - const row = await this.findOne({ user: userId, key }, { disableIdentityMap: true }); + const row = await this.findOne({ user: userId, key }); return row ? (toRow(row) as SettingRow) : null; } diff --git a/server/src/db/repositories/ShareTokens.repository.ts b/server/src/db/repositories/ShareTokens.repository.ts index 5de1ea260..e02c067f7 100644 --- a/server/src/db/repositories/ShareTokens.repository.ts +++ b/server/src/db/repositories/ShareTokens.repository.ts @@ -1,4 +1,4 @@ import type { ShareTokens } from '../entities/ShareTokens.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class ShareTokensRepository extends EntityRepository {} +export class ShareTokensRepository extends TrekRepository {} diff --git a/server/src/db/repositories/Tags.repository.ts b/server/src/db/repositories/Tags.repository.ts index 385f8230b..b6024e3ee 100644 --- a/server/src/db/repositories/Tags.repository.ts +++ b/server/src/db/repositories/Tags.repository.ts @@ -1,7 +1,7 @@ import type { Tags } from '../entities/Tags.entity'; import { findOwnedByUser, listForOwner } from './_shared/owned-lookup'; import { toRow, type AssertRowKeys } from './_shared/rows'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; /** A `tags` row as the API emits it. */ export interface TagRow { @@ -14,7 +14,7 @@ export interface TagRow { const _tagRowKeys: AssertRowKeys = true; -export class TagsRepository extends EntityRepository { +export class TagsRepository extends TrekRepository { /** * `SELECT * FROM tags WHERE user_id = ? ORDER BY name ASC`, via the shared * `listForOwner` helper (`tags.user_id` is `NOT NULL`, a strictly-owned @@ -32,9 +32,10 @@ export class TagsRepository extends EntityRepository { * own write (`createTag`/`patch` refresh/return their own entity * directly). * - * `disableIdentityMap: true` per `findOwnedByUser`'s own docstring (Plan - * 3b Task 1 fix round, supersedes Plan 3a's I1) — this is a "rows out" - * read, converted via `toRow` and discarded. + * `disableIdentityMap: true` by the base class's default (`_shared/ + * trek-repository.ts`, Plan 3b interlude B), per `findOwnedByUser`'s own + * docstring — this is a "rows out" read, converted via `toRow` and + * discarded. */ async findByIdAndUser(id: number, userId: number): Promise { const tag = await findOwnedByUser(this, id, 'user', userId); @@ -47,69 +48,66 @@ export class TagsRepository extends EntityRepository { * `#10b981` defaulting stays in `TagsService` (D4's defaults rule) — this * writes exactly what it is given. * - * Named `createTag`, not `create`: `EntityRepository#create` is a - * synchronous, non-persisting factory with an incompatible signature — - * shadowing it would fail to compile, the same collision Task 1 hit - * naming a method `insert`. - * - * Followed by `em.refresh` before `toRow` (D4's create rule), same as - * `CategoriesRepository.createCategory`. + * `this.insert` (Plan 3b interlude B, finishing F2's sweep), never + * `create()` + `persist().flush()`, same reasoning as + * `CategoriesRepository.createCategory`: `flush()` commits the *whole* + * unit of work of the request's `EntityManager`, not just this row. + * Followed by a read-back (`findOne({ id })`, `disableIdentityMap: true` + * by the base class's default): the returned row carries the generated + * `id` and the `defaultRaw` `created_at` the same way a legacy + * INSERT-then-reselect pair did. */ async createTag(input: { user_id: number; name: string; color: string }): Promise { - const tag = this.create({ + const id = await this.insert({ user: input.user_id, name: input.name, color: input.color, }); - await this.getEntityManager().persist(tag).flush(); - await this.getEntityManager().refresh(tag); - return toRow(tag) as TagRow; + const inserted = await this.findOne({ id }); + if (!inserted) { + throw new Error('createTag: read-back after insert found no row'); + } + return toRow(inserted) as TagRow; } /** * `UPDATE tags SET name = COALESCE(?, name), color = COALESCE(?, color) * WHERE id = ?`, re-selected — no `user_id` filter, matching the legacy - * statement exactly. `assign` only touches the keys present on `changes` - * (the entity-API equivalent of `COALESCE` against a bind parameter), so - * `TagsService` omits a field entirely, rather than passing it as - * `undefined`, to leave it untouched — reproducing the legacy `|| null` - * coalesce-away-empty-string behaviour. Returns `null` when no row matches - * `id`, mirroring the legacy re-select's `undefined`. + * statement exactly. `nativeUpdate` only writes the keys present on + * `changes` (the entity-API equivalent of `COALESCE` against a bind + * parameter), so `TagsService` omits a field entirely, rather than + * passing it as `undefined`, to leave it untouched — reproducing the + * legacy `|| null` coalesce-away-empty-string behaviour. Returns `null` + * when no row matches `id`, mirroring the legacy re-select's `undefined`. * - * The lookup `findOne({ id }, { refresh: true })` is PK-only, so without - * `refresh: true` MikroORM would answer it from the identity map instead - * of the DB (Task 3 review, Important 1) — same three consequences as - * `CategoriesRepository.patch`: a stale untouched column, a patch that - * diffs against a stale value and silently drops the UPDATE, or (since - * `remove()` is a `nativeDelete` that does not clear the identity map) a - * fabricated row for an id already deleted earlier in the request instead - * of `null`. `refresh: true` forces a real re-query every time. - * - * No re-`findOne`/`refresh` after `flush`, same reasoning as - * `CategoriesRepository.patch`: `assign` already mutated the managed (and - * now guaranteed-fresh) entity in place, and no column here is - * DB-computed on UPDATE. - * - * **Excluded from the Plan 3b Task 1 fix round's `disableIdentityMap: true` - * ruling, deliberately**: unlike every other read in this repository, this - * one is not a "rows out" read — the entity is kept MANAGED on purpose so - * `assign`+`flush` can diff and write it. `disableIdentityMap: true` would - * return a *detached* entity (per the ruling's own mechanism); `assign`ing - * it would still mutate the JS object, but the following `flush()` would - * have no effect on it (a detached entity is not in `flush()`'s unit of - * work) — the write would silently stop persisting. `refresh: true` stays - * here for exactly the reason it always was: this is the only call in the - * repository that manages this row, so there is no B1-shaped staleness - * risk (that requires a SECOND managed, differently-projected read of the - * same row still lingering in the identity map — nothing else here leaves - * one now that every other read is `disableIdentityMap: true`). + * **Converted from `findOne({ refresh: true })` + `assign` + `flush()` + * to `nativeUpdate` + a typed partial (Plan 3b interlude B, per + * `task-1-rereview.md`'s ruling on `CategoriesRepository.patch`, applied + * here identically): "convert them, don't complete the sweep on the + * read."** `nativeUpdate` writes the given columns directly, with no ORM + * change-set diff against an in-memory snapshot — that removes the whole + * class of staleness `refresh: true` existed to guard against (Task 3 + * review, Important 1: an untouched column reading stale, a patch + * silently dropped because it happened to match a stale in-memory value, + * and a patch after `remove()` fabricating a row), not just the one B1 + * shape the rest of the program's `disableIdentityMap: true` ruling + * closes. When `changes` is non-empty, `nativeUpdate`'s own affected-row + * count IS the existence check (0 ⇒ `null`, no separate read); when + * `changes` is empty, a plain existence read (`disableIdentityMap: true` + * by the base class's default, like every other read in this repository + * now) stands in for it. Either way the return value is a fresh read, not + * the entity `assign` used to mutate in place. */ async patch(id: number, changes: { name?: string; color?: string }): Promise { - const tag = await this.findOne({ id }, { refresh: true }); - if (!tag) return null; - this.assign(tag, changes); - await this.getEntityManager().flush(); - return toRow(tag) as TagRow; + if (Object.keys(changes).length > 0) { + const affected = await this.nativeUpdate({ id }, changes); + if (affected === 0) return null; + } else { + const existing = await this.findOne({ id }, { fields: ['id'] }); + if (!existing) return null; + } + const tag = await this.findOne({ id }); + return tag ? (toRow(tag) as TagRow) : null; } /** `DELETE FROM tags WHERE id = ?`. Returns the affected row count. */ diff --git a/server/src/db/repositories/TodoCategoryAssignees.repository.ts b/server/src/db/repositories/TodoCategoryAssignees.repository.ts index ae9ae8c7a..d01c4d8a2 100644 --- a/server/src/db/repositories/TodoCategoryAssignees.repository.ts +++ b/server/src/db/repositories/TodoCategoryAssignees.repository.ts @@ -1,4 +1,4 @@ import type { TodoCategoryAssignees } from '../entities/TodoCategoryAssignees.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class TodoCategoryAssigneesRepository extends EntityRepository {} +export class TodoCategoryAssigneesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/TodoItems.repository.ts b/server/src/db/repositories/TodoItems.repository.ts index b2cbb0a2a..129da71d5 100644 --- a/server/src/db/repositories/TodoItems.repository.ts +++ b/server/src/db/repositories/TodoItems.repository.ts @@ -1,4 +1,4 @@ import type { TodoItems } from '../entities/TodoItems.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class TodoItemsRepository extends EntityRepository {} +export class TodoItemsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/TrekPhotoCacheMeta.repository.ts b/server/src/db/repositories/TrekPhotoCacheMeta.repository.ts index 36edec01e..4c2980677 100644 --- a/server/src/db/repositories/TrekPhotoCacheMeta.repository.ts +++ b/server/src/db/repositories/TrekPhotoCacheMeta.repository.ts @@ -1,4 +1,4 @@ import type { TrekPhotoCacheMeta } from '../entities/TrekPhotoCacheMeta.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class TrekPhotoCacheMetaRepository extends EntityRepository {} +export class TrekPhotoCacheMetaRepository extends TrekRepository {} diff --git a/server/src/db/repositories/TrekPhotos.repository.ts b/server/src/db/repositories/TrekPhotos.repository.ts index 90805908c..ff32c18a5 100644 --- a/server/src/db/repositories/TrekPhotos.repository.ts +++ b/server/src/db/repositories/TrekPhotos.repository.ts @@ -1,4 +1,4 @@ import type { TrekPhotos } from '../entities/TrekPhotos.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class TrekPhotosRepository extends EntityRepository {} +export class TrekPhotosRepository extends TrekRepository {} diff --git a/server/src/db/repositories/TripAlbumLinks.repository.ts b/server/src/db/repositories/TripAlbumLinks.repository.ts index 55ce4912d..b7cc49e27 100644 --- a/server/src/db/repositories/TripAlbumLinks.repository.ts +++ b/server/src/db/repositories/TripAlbumLinks.repository.ts @@ -1,4 +1,4 @@ import type { TripAlbumLinks } from '../entities/TripAlbumLinks.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class TripAlbumLinksRepository extends EntityRepository {} +export class TripAlbumLinksRepository extends TrekRepository {} diff --git a/server/src/db/repositories/TripDocumentLinks.repository.ts b/server/src/db/repositories/TripDocumentLinks.repository.ts index a969d9414..5b98d9448 100644 --- a/server/src/db/repositories/TripDocumentLinks.repository.ts +++ b/server/src/db/repositories/TripDocumentLinks.repository.ts @@ -1,4 +1,4 @@ import type { TripDocumentLinks } from '../entities/TripDocumentLinks.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class TripDocumentLinksRepository extends EntityRepository {} +export class TripDocumentLinksRepository extends TrekRepository {} diff --git a/server/src/db/repositories/TripFiles.repository.ts b/server/src/db/repositories/TripFiles.repository.ts index 4dc77ba81..4cea6c0e8 100644 --- a/server/src/db/repositories/TripFiles.repository.ts +++ b/server/src/db/repositories/TripFiles.repository.ts @@ -1,4 +1,4 @@ import type { TripFiles } from '../entities/TripFiles.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class TripFilesRepository extends EntityRepository {} +export class TripFilesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/TripInviteTokens.repository.ts b/server/src/db/repositories/TripInviteTokens.repository.ts index a0acc7716..37b729254 100644 --- a/server/src/db/repositories/TripInviteTokens.repository.ts +++ b/server/src/db/repositories/TripInviteTokens.repository.ts @@ -1,4 +1,4 @@ import type { TripInviteTokens } from '../entities/TripInviteTokens.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class TripInviteTokensRepository extends EntityRepository {} +export class TripInviteTokensRepository extends TrekRepository {} diff --git a/server/src/db/repositories/TripMembers.repository.ts b/server/src/db/repositories/TripMembers.repository.ts index f3a386838..5b63033df 100644 --- a/server/src/db/repositories/TripMembers.repository.ts +++ b/server/src/db/repositories/TripMembers.repository.ts @@ -1,4 +1,4 @@ import type { TripMembers } from '../entities/TripMembers.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class TripMembersRepository extends EntityRepository {} +export class TripMembersRepository extends TrekRepository {} diff --git a/server/src/db/repositories/TripPhotos.repository.ts b/server/src/db/repositories/TripPhotos.repository.ts index d50d9b221..8cc7659f2 100644 --- a/server/src/db/repositories/TripPhotos.repository.ts +++ b/server/src/db/repositories/TripPhotos.repository.ts @@ -1,4 +1,4 @@ import type { TripPhotos } from '../entities/TripPhotos.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class TripPhotosRepository extends EntityRepository {} +export class TripPhotosRepository extends TrekRepository {} diff --git a/server/src/db/repositories/Trips.repository.ts b/server/src/db/repositories/Trips.repository.ts index 254178690..1f7ae08d8 100644 --- a/server/src/db/repositories/Trips.repository.ts +++ b/server/src/db/repositories/Trips.repository.ts @@ -1,5 +1,5 @@ import type { Trips } from '../entities/Trips.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; /** What a trip-scoped request learns about the trip once access is verified. */ export interface TripAccess { @@ -8,7 +8,7 @@ export interface TripAccess { currency: string | null; } -export class TripsRepository extends EntityRepository { +export class TripsRepository extends TrekRepository { /** * The trip if the user owns it or is a member of it, else undefined. * diff --git a/server/src/db/repositories/UserNoticeDismissals.repository.ts b/server/src/db/repositories/UserNoticeDismissals.repository.ts index 098ebe1e5..96763e80a 100644 --- a/server/src/db/repositories/UserNoticeDismissals.repository.ts +++ b/server/src/db/repositories/UserNoticeDismissals.repository.ts @@ -1,4 +1,4 @@ import type { UserNoticeDismissals } from '../entities/UserNoticeDismissals.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class UserNoticeDismissalsRepository extends EntityRepository {} +export class UserNoticeDismissalsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/Users.repository.ts b/server/src/db/repositories/Users.repository.ts index 40720fa9b..93a2795c6 100644 --- a/server/src/db/repositories/Users.repository.ts +++ b/server/src/db/repositories/Users.repository.ts @@ -1,7 +1,7 @@ import { Users } from '../entities/Users.entity'; import { toRow, type AssertRowKeys } from './_shared/rows'; import { columnIncrementedBy, currentTimestamp, lower } from '../dialect/sql-functions'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; /** * The three third-party keys that belong to the instance rather than to a @@ -175,7 +175,7 @@ export interface ResetTargetRow { password_version: number; } -export class UsersRepository extends EntityRepository { +export class UsersRepository extends TrekRepository { /** * **Ruling (Plan 3b Task 1 fix round, supersedes Plan 3a's I1 "`refresh: * true` on every PK-only `findOne`"):** every row-out read in this @@ -191,9 +191,22 @@ export class UsersRepository extends EntityRepository { * only mattered for an entity that stayed in the map, and none does now. * See https://mikro-orm.io/docs/entity-manager#disableidentitymap and * `.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md` (B1). + * + * **Plan 3b interlude B update:** this option is now applied by the base + * class, not per call — `TrekRepository`'s `find`/`findOne` overrides + * (`_shared/trek-repository.ts`) merge `{ disableIdentityMap: true }` into + * every call's options unless the caller passes `disableIdentityMap: + * false` explicitly, so no method below names it any more. The base class + * also closes the gap the Task 1 re-review found in this mechanism + * (`task-1-rereview.md` F-R1): `disableIdentityMap: true` resolves with + * MikroORM's non-validating `getContext(false)`, so a converted read no + * longer failed closed outside a request context — every override + * validates first (`this.getEntityManager().getContext()`, the + * VALIDATING form) and only then delegates, restoring the ratchet for + * reads AND for the write paths that never validated at all (F7 INFO). */ async getEmail(userId: number): Promise { - const row = await this.findOne({ id: userId }, { fields: ['email'], disableIdentityMap: true }); + const row = await this.findOne({ id: userId }, { fields: ['email'] }); return row?.email ?? null; } @@ -206,7 +219,7 @@ export class UsersRepository extends EntityRepository { * `disableIdentityMap: true` per the class-level ruling above. */ async getApiKeyColumn(userId: number, name: InstanceApiKeyName): Promise { - const row = await this.findOne({ id: userId }, { fields: [name], disableIdentityMap: true }); + const row = await this.findOne({ id: userId }, { fields: [name] }); return row ? (row[name] ?? null) : null; } @@ -224,7 +237,7 @@ export class UsersRepository extends EntityRepository { async findByIdWithPasswordVersion(id: number): Promise { const row = await this.findOne( { id }, - { fields: ['id', 'username', 'email', 'role', 'password_version'], disableIdentityMap: true }, + { fields: ['id', 'username', 'email', 'role', 'password_version'] }, ); return row ? { id: row.id, username: row.username, email: row.email, role: row.role, password_version: row.password_version } : null; } @@ -235,7 +248,7 @@ export class UsersRepository extends EntityRepository { /** `SELECT password_version FROM users WHERE id = ?`. `?? 0` stays in the caller (AU2/TK7/O2 each fall back differently in spirit, identically in value). */ async getPasswordVersion(id: number): Promise { - const row = await this.findOne({ id }, { fields: ['password_version'], disableIdentityMap: true }); + const row = await this.findOne({ id }, { fields: ['password_version'] }); return row?.password_version ?? null; } @@ -254,7 +267,7 @@ export class UsersRepository extends EntityRepository { /** `SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1`. */ async findAdminNeedingPasswordChange(): Promise { - const row = await this.findOne({ role: 'admin', must_change_password: 1 }, { fields: ['id'], disableIdentityMap: true }); + const row = await this.findOne({ role: 'admin', must_change_password: 1 }, { fields: ['id'] }); return row?.id ?? null; } @@ -273,7 +286,7 @@ export class UsersRepository extends EntityRepository { $or: [{ [lower(platform, 'email')]: email.toLowerCase() }, { [lower(platform, 'username')]: username.toLowerCase() }], is_guest: 0, }, - { fields: ['id'], disableIdentityMap: true }, + { fields: ['id'] }, ); return row?.id ?? null; } @@ -292,19 +305,22 @@ export class UsersRepository extends EntityRepository { * always the literal `0` on insert, never a parameter, matching both * statements exactly. * - * `em.insert` (F2, Plan 3b Task 1 fix round), never `create()` + - * `persist().flush()`: `flush()` commits the *whole* unit of work of the - * request's `EntityManager`, not just this row — the review caught it - * emitting a full-row `UPDATE users … WHERE id = ?` against an unrelated, - * previously-read user in the same EM. `em.insert` fires a single native + * `this.insert` (F2, Plan 3b Task 1 fix round; routed through the + * repository rather than `this.getEntityManager().insert(Users, …)` so it + * goes through the base class's validation — see + * `_shared/trek-repository.ts`), never `create()` + `persist().flush()`: + * `flush()` commits the *whole* unit of work of the request's + * `EntityManager`, not just this row — the review caught it emitting a + * full-row `UPDATE users … WHERE id = ?` against an unrelated, + * previously-read user in the same EM. `insert` fires a single native * INSERT with no side effects on the context/identity map (the same rule * as `AuditLogRepository.insertEntry`). Followed by a read-back - * (`findById`, `disableIdentityMap: true` per the class-level ruling): the - * returned row carries the generated `id` and the `defaultRaw` + * (`findById`, `disableIdentityMap: true` by the base class's default): + * the returned row carries the generated `id` and the `defaultRaw` * `created_at` the same way a legacy INSERT-then-reselect pair did. */ async insertUser(row: NewUserRow): Promise { - const id = await this.getEntityManager().insert(Users, { + const id = await this.insert({ username: row.username, email: row.email, password_hash: row.password_hash, @@ -315,7 +331,7 @@ export class UsersRepository extends EntityRepository { oidc_issuer: row.oidc_issuer ?? null, avatar: row.avatar ?? null, }); - const inserted = await this.findById(id as number); + const inserted = await this.findById(id); if (!inserted) { throw new Error('insertUser: read-back after insert found no row'); } @@ -329,10 +345,7 @@ export class UsersRepository extends EntityRepository { /** `SELECT * FROM users WHERE LOWER(email) = LOWER(?) AND COALESCE(is_guest, 0) = 0` */ async findByEmailCI(email: string): Promise { const platform = this.getEntityManager().getPlatform(); - const user = await this.findOne( - { [lower(platform, 'email')]: email.toLowerCase(), is_guest: 0 }, - { disableIdentityMap: true }, - ); + const user = await this.findOne({ [lower(platform, 'email')]: email.toLowerCase(), is_guest: 0 }); return user ? (toRow(user) as UserRow) : null; } @@ -349,7 +362,7 @@ export class UsersRepository extends EntityRepository { * unfiltered legacy statement, including matching a guest row. */ async findByEmailExact(email: string): Promise { - const user = await this.findOne({ email }, { disableIdentityMap: true }); + const user = await this.findOne({ email }); return user ? (toRow(user) as UserRow) : null; } @@ -384,7 +397,6 @@ export class UsersRepository extends EntityRepository { { id }, { fields: ['id', 'username', 'email', 'role', 'avatar', 'oidc_issuer', 'created_at', 'mfa_enabled', 'must_change_password'], - disableIdentityMap: true, }, ); return row @@ -408,7 +420,7 @@ export class UsersRepository extends EntityRepository { /** `SELECT password_hash, password_version FROM users WHERE id = ?` */ async getPasswordHashAndVersion(id: number): Promise<{ password_hash: string; password_version: number } | null> { - const row = await this.findOne({ id }, { fields: ['password_hash', 'password_version'], disableIdentityMap: true }); + const row = await this.findOne({ id }, { fields: ['password_hash', 'password_version'] }); return row ? { password_hash: row.password_hash, password_version: row.password_version } : null; } @@ -444,7 +456,7 @@ export class UsersRepository extends EntityRepository { /** `SELECT role FROM users WHERE id = ?` */ async getRole(id: number): Promise { - const row = await this.findOne({ id }, { fields: ['role'], disableIdentityMap: true }); + const row = await this.findOne({ id }, { fields: ['role'] }); return row?.role ?? null; } @@ -464,7 +476,7 @@ export class UsersRepository extends EntityRepository { * collapse both into the same value. */ async getMfaEnabled(id: number): Promise<{ mfa_enabled: number | null } | null> { - const row = await this.findOne({ id }, { fields: ['mfa_enabled'], disableIdentityMap: true }); + const row = await this.findOne({ id }, { fields: ['mfa_enabled'] }); return row ? { mfa_enabled: row.mfa_enabled ?? null } : null; } @@ -497,7 +509,7 @@ export class UsersRepository extends EntityRepository { * `disableIdentityMap: true` per the class-level ruling above. */ async findById(id: number): Promise { - const user = await this.findOne({ id }, { disableIdentityMap: true }); + const user = await this.findOne({ id }); return user ? (toRow(user) as UserRow) : null; } @@ -552,7 +564,7 @@ export class UsersRepository extends EntityRepository { async findForPasswordReset(email: string): Promise { const row = await this.findOne( { email, is_guest: 0 }, - { fields: ['id', 'email', 'password_hash', 'oidc_sub'], disableIdentityMap: true }, + { fields: ['id', 'email', 'password_hash', 'oidc_sub'] }, ); return row ? { id: row.id, email: row.email, password_hash: row.password_hash, oidc_sub: row.oidc_sub ?? null } : null; } @@ -565,7 +577,7 @@ export class UsersRepository extends EntityRepository { async findResetTarget(id: number): Promise { const row = await this.findOne( { id }, - { fields: ['id', 'email', 'mfa_enabled', 'mfa_secret', 'mfa_backup_codes', 'password_version'], disableIdentityMap: true }, + { fields: ['id', 'email', 'mfa_enabled', 'mfa_secret', 'mfa_backup_codes', 'password_version'] }, ); return row ? { @@ -585,7 +597,7 @@ export class UsersRepository extends EntityRepository { /** `SELECT password_hash FROM users WHERE id = ?` */ async getPasswordHash(id: number): Promise { - const row = await this.findOne({ id }, { fields: ['password_hash'], disableIdentityMap: true }); + const row = await this.findOne({ id }, { fields: ['password_hash'] }); return row?.password_hash ?? null; } @@ -595,7 +607,7 @@ export class UsersRepository extends EntityRepository { /** `SELECT id, email FROM users WHERE id = ?` */ async findIdAndEmail(id: number): Promise<{ id: number; email: string } | null> { - const row = await this.findOne({ id }, { fields: ['id', 'email'], disableIdentityMap: true }); + const row = await this.findOne({ id }, { fields: ['id', 'email'] }); return row ? { id: row.id, email: row.email } : null; } @@ -605,7 +617,7 @@ export class UsersRepository extends EntityRepository { /** `SELECT * FROM users WHERE oidc_sub = ? AND oidc_issuer = ?` */ async findByOidcIdentity(sub: string, issuer: string): Promise { - const user = await this.findOne({ oidc_sub: sub, oidc_issuer: issuer }, { disableIdentityMap: true }); + const user = await this.findOne({ oidc_sub: sub, oidc_issuer: issuer }); return user ? (toRow(user) as UserRow) : null; } @@ -658,7 +670,7 @@ export class UsersRepository extends EntityRepository { const platform = this.getEntityManager().getPlatform(); const row = await this.findOne( { [lower(platform, 'username')]: username.toLowerCase(), id: { $ne: excludeId }, is_guest: 0 }, - { fields: ['id'], disableIdentityMap: true }, + { fields: ['id'] }, ); return row?.id ?? null; } @@ -674,7 +686,7 @@ export class UsersRepository extends EntityRepository { const platform = this.getEntityManager().getPlatform(); const row = await this.findOne( { [lower(platform, 'username')]: username.toLowerCase() }, - { fields: ['id'], disableIdentityMap: true }, + { fields: ['id'] }, ); return row?.id ?? null; } @@ -687,7 +699,7 @@ export class UsersRepository extends EntityRepository { async getApiKeyColumns(id: number): Promise { const row = await this.findOne( { id }, - { fields: ['role', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'amap_api_key'], disableIdentityMap: true }, + { fields: ['role', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'amap_api_key'] }, ); return row ? { @@ -737,7 +749,6 @@ export class UsersRepository extends EntityRepository { { id }, { fields: ['id', 'username', 'email', 'role', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'amap_api_key', 'avatar', 'mfa_enabled'], - disableIdentityMap: true, }, ); return row @@ -765,7 +776,7 @@ export class UsersRepository extends EntityRepository { const platform = this.getEntityManager().getPlatform(); const row = await this.findOne( { [lower(platform, 'email')]: email.toLowerCase(), id: { $ne: excludeId }, is_guest: 0 }, - { fields: ['id'], disableIdentityMap: true }, + { fields: ['id'] }, ); return row?.id ?? null; } @@ -798,7 +809,7 @@ export class UsersRepository extends EntityRepository { /** `SELECT avatar FROM users WHERE id = ?` */ async getAvatar(id: number): Promise { - const row = await this.findOne({ id }, { fields: ['avatar'], disableIdentityMap: true }); + const row = await this.findOne({ id }, { fields: ['avatar'] }); return row?.avatar ?? null; } @@ -822,7 +833,7 @@ export class UsersRepository extends EntityRepository { /** `SELECT id, username, email, role, avatar FROM users WHERE id = ?` */ async findProfileBasic(id: number): Promise { - const row = await this.findOne({ id }, { fields: ['id', 'username', 'email', 'role', 'avatar'], disableIdentityMap: true }); + const row = await this.findOne({ id }, { fields: ['id', 'username', 'email', 'role', 'avatar'] }); return row ? { id: row.id, username: row.username, email: row.email, role: row.role, avatar: row.avatar ?? null } : null; } @@ -834,7 +845,7 @@ export class UsersRepository extends EntityRepository { async listOthersNonGuest(excludeId: number): Promise { const rows = await this.find( { id: { $ne: excludeId }, is_guest: 0 }, - { fields: ['id', 'username', 'avatar'], orderBy: { username: 'asc' }, disableIdentityMap: true }, + { fields: ['id', 'username', 'avatar'], orderBy: { username: 'asc' } }, ); return rows.map((row) => ({ id: row.id, username: row.username, avatar: row.avatar ?? null })); } @@ -845,7 +856,7 @@ export class UsersRepository extends EntityRepository { /** `SELECT role, openweather_api_key FROM users WHERE id = ?` */ async getRoleAndWeatherKey(id: number): Promise<{ role: string; openweather_api_key: string | null } | null> { - const row = await this.findOne({ id }, { fields: ['role', 'openweather_api_key'], disableIdentityMap: true }); + const row = await this.findOne({ id }, { fields: ['role', 'openweather_api_key'] }); return row ? { role: row.role, openweather_api_key: row.openweather_api_key ?? null } : null; } } diff --git a/server/src/db/repositories/VacayCompanyHolidays.repository.ts b/server/src/db/repositories/VacayCompanyHolidays.repository.ts index 47834c177..16188ec58 100644 --- a/server/src/db/repositories/VacayCompanyHolidays.repository.ts +++ b/server/src/db/repositories/VacayCompanyHolidays.repository.ts @@ -1,4 +1,4 @@ import type { VacayCompanyHolidays } from '../entities/VacayCompanyHolidays.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class VacayCompanyHolidaysRepository extends EntityRepository {} +export class VacayCompanyHolidaysRepository extends TrekRepository {} diff --git a/server/src/db/repositories/VacayEntries.repository.ts b/server/src/db/repositories/VacayEntries.repository.ts index faae865e4..ff28edb36 100644 --- a/server/src/db/repositories/VacayEntries.repository.ts +++ b/server/src/db/repositories/VacayEntries.repository.ts @@ -1,4 +1,4 @@ import type { VacayEntries } from '../entities/VacayEntries.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class VacayEntriesRepository extends EntityRepository {} +export class VacayEntriesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/VacayHolidayCalendars.repository.ts b/server/src/db/repositories/VacayHolidayCalendars.repository.ts index 181118c55..2d4bbd0f6 100644 --- a/server/src/db/repositories/VacayHolidayCalendars.repository.ts +++ b/server/src/db/repositories/VacayHolidayCalendars.repository.ts @@ -1,4 +1,4 @@ import type { VacayHolidayCalendars } from '../entities/VacayHolidayCalendars.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class VacayHolidayCalendarsRepository extends EntityRepository {} +export class VacayHolidayCalendarsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/VacayPlanMembers.repository.ts b/server/src/db/repositories/VacayPlanMembers.repository.ts index 68eaffd82..e43d3f987 100644 --- a/server/src/db/repositories/VacayPlanMembers.repository.ts +++ b/server/src/db/repositories/VacayPlanMembers.repository.ts @@ -1,4 +1,4 @@ import type { VacayPlanMembers } from '../entities/VacayPlanMembers.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class VacayPlanMembersRepository extends EntityRepository {} +export class VacayPlanMembersRepository extends TrekRepository {} diff --git a/server/src/db/repositories/VacayPlans.repository.ts b/server/src/db/repositories/VacayPlans.repository.ts index 53ba81469..505dc7465 100644 --- a/server/src/db/repositories/VacayPlans.repository.ts +++ b/server/src/db/repositories/VacayPlans.repository.ts @@ -1,4 +1,4 @@ import type { VacayPlans } from '../entities/VacayPlans.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class VacayPlansRepository extends EntityRepository {} +export class VacayPlansRepository extends TrekRepository {} diff --git a/server/src/db/repositories/VacayShares.repository.ts b/server/src/db/repositories/VacayShares.repository.ts index 327a332c9..33088986e 100644 --- a/server/src/db/repositories/VacayShares.repository.ts +++ b/server/src/db/repositories/VacayShares.repository.ts @@ -1,4 +1,4 @@ import type { VacayShares } from '../entities/VacayShares.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class VacaySharesRepository extends EntityRepository {} +export class VacaySharesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/VacayUserColors.repository.ts b/server/src/db/repositories/VacayUserColors.repository.ts index 81217a8ad..cb857b51e 100644 --- a/server/src/db/repositories/VacayUserColors.repository.ts +++ b/server/src/db/repositories/VacayUserColors.repository.ts @@ -1,4 +1,4 @@ import type { VacayUserColors } from '../entities/VacayUserColors.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class VacayUserColorsRepository extends EntityRepository {} +export class VacayUserColorsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/VacayUserSettings.repository.ts b/server/src/db/repositories/VacayUserSettings.repository.ts index aed5745cf..e821c6f19 100644 --- a/server/src/db/repositories/VacayUserSettings.repository.ts +++ b/server/src/db/repositories/VacayUserSettings.repository.ts @@ -1,4 +1,4 @@ import type { VacayUserSettings } from '../entities/VacayUserSettings.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class VacayUserSettingsRepository extends EntityRepository {} +export class VacayUserSettingsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/VacayUserYears.repository.ts b/server/src/db/repositories/VacayUserYears.repository.ts index 6de7df02f..a77dabb7e 100644 --- a/server/src/db/repositories/VacayUserYears.repository.ts +++ b/server/src/db/repositories/VacayUserYears.repository.ts @@ -1,4 +1,4 @@ import type { VacayUserYears } from '../entities/VacayUserYears.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class VacayUserYearsRepository extends EntityRepository {} +export class VacayUserYearsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/VacayYears.repository.ts b/server/src/db/repositories/VacayYears.repository.ts index 038708285..f758dcf16 100644 --- a/server/src/db/repositories/VacayYears.repository.ts +++ b/server/src/db/repositories/VacayYears.repository.ts @@ -1,4 +1,4 @@ import type { VacayYears } from '../entities/VacayYears.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class VacayYearsRepository extends EntityRepository {} +export class VacayYearsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/VisitedCountries.repository.ts b/server/src/db/repositories/VisitedCountries.repository.ts index 04cb510a6..4f2b69886 100644 --- a/server/src/db/repositories/VisitedCountries.repository.ts +++ b/server/src/db/repositories/VisitedCountries.repository.ts @@ -1,4 +1,4 @@ import type { VisitedCountries } from '../entities/VisitedCountries.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class VisitedCountriesRepository extends EntityRepository {} +export class VisitedCountriesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/VisitedRegions.repository.ts b/server/src/db/repositories/VisitedRegions.repository.ts index 67a41349f..05ac8c8ea 100644 --- a/server/src/db/repositories/VisitedRegions.repository.ts +++ b/server/src/db/repositories/VisitedRegions.repository.ts @@ -1,4 +1,4 @@ import type { VisitedRegions } from '../entities/VisitedRegions.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; -export class VisitedRegionsRepository extends EntityRepository {} +export class VisitedRegionsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/WebauthnChallenges.repository.ts b/server/src/db/repositories/WebauthnChallenges.repository.ts index 8382be217..ce52f860b 100644 --- a/server/src/db/repositories/WebauthnChallenges.repository.ts +++ b/server/src/db/repositories/WebauthnChallenges.repository.ts @@ -1,5 +1,5 @@ import type { WebauthnChallenges } from '../entities/WebauthnChallenges.entity'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; /** `PasskeyService.storeChallenge` (PK2). */ export interface NewChallengeRow { @@ -25,7 +25,7 @@ interface WebauthnChallengesKyselyDB { } /** The single-use, TTL'd WebAuthn ceremony challenge store — PK1–PK3. */ -export class WebauthnChallengesRepository extends EntityRepository { +export class WebauthnChallengesRepository extends TrekRepository { /** * `DELETE FROM webauthn_challenges WHERE expires_at < ?` — best-effort * sweep run before minting a new challenge (no cron for this table; see @@ -83,10 +83,15 @@ export class WebauthnChallengesRepository extends EntityRepository { - const row = await this.getEntityManager() - .getKysely() + const row = await this.kysely() .deleteFrom('webauthn_challenges') .where('challenge', '=', challenge) .where('type', '=', type) diff --git a/server/src/db/repositories/WebauthnCredentials.repository.ts b/server/src/db/repositories/WebauthnCredentials.repository.ts index e86d7419b..290144130 100644 --- a/server/src/db/repositories/WebauthnCredentials.repository.ts +++ b/server/src/db/repositories/WebauthnCredentials.repository.ts @@ -1,7 +1,7 @@ import type { WebauthnCredentials } from '../entities/WebauthnCredentials.entity'; import { toRow, type AssertRowKeys } from './_shared/rows'; import { currentTimestamp } from '../dialect/sql-functions'; -import { EntityRepository } from '@mikro-orm/sql'; +import { TrekRepository } from './_shared/trek-repository'; /** * A full `webauthn_credentials` row (`SELECT * ...`), as PK9 (`passkeyLoginVerify`) @@ -78,7 +78,7 @@ function toPanelRow(row: { id: number; name?: string | null; device_type?: strin * `uow.transactional`). `hasAny` is untouched — nothing here changes its * name or shape. */ -export class WebauthnCredentialsRepository extends EntityRepository { +export class WebauthnCredentialsRepository extends TrekRepository { /** * `SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1` (MP3/AU24 * — identical statement, two callers). `findOne` with a narrow `fields` @@ -87,7 +87,9 @@ export class WebauthnCredentialsRepository extends EntityRepository 0`, which would still * scan/aggregate every matching row. * - * `disableIdentityMap: true` (every row-out read in this file): this + * `disableIdentityMap: true` (every row-out read in this file, applied by + * the base class's default since Plan 3b interlude B — + * `_shared/trek-repository.ts`): this * method's caller (`mfa-policy.guard.ts`, `AuthService.updateAppSettings`) * can run in the same request as other `webauthn_credentials`/`users` * reads and writes; a `findOne` that joins the identity map (even for a @@ -103,7 +105,7 @@ export class WebauthnCredentialsRepository extends EntityRepository { - const row = await this.findOne({ user: userId }, { fields: ['id'], disableIdentityMap: true }); + const row = await this.findOne({ user: userId }, { fields: ['id'] }); return row !== null; } @@ -113,7 +115,7 @@ export class WebauthnCredentialsRepository extends EntityRepository { - const rows = await this.find({ user: userId }, { fields: ['credential_id', 'transports'], disableIdentityMap: true }); + const rows = await this.find({ user: userId }, { fields: ['credential_id', 'transports'] }); return rows.map((row) => ({ credential_id: row.credential_id, transports: row.transports ?? null })); } @@ -131,7 +133,7 @@ export class WebauthnCredentialsRepository extends EntityRepository { - const row = await this.findOne({ credential_id: credentialId }, { fields: ['id'], disableIdentityMap: true }); + const row = await this.findOne({ credential_id: credentialId }, { fields: ['id'] }); return row !== null; } @@ -180,7 +182,7 @@ export class WebauthnCredentialsRepository extends EntityRepository { - const row = await this.findOne({ credential_id: credentialId }, { fields: [...PANEL_FIELDS], disableIdentityMap: true }); + const row = await this.findOne({ credential_id: credentialId }, { fields: [...PANEL_FIELDS] }); return row ? toPanelRow(row) : null; } @@ -199,7 +201,7 @@ export class WebauthnCredentialsRepository extends EntityRepository { - const credential = await this.findOne({ credential_id: credentialId }, { disableIdentityMap: true }); + const credential = await this.findOne({ credential_id: credentialId }); return credential ? (toRow(credential) as WebauthnCredentialRow) : null; } @@ -222,7 +224,7 @@ export class WebauthnCredentialsRepository extends EntityRepository { - const rows = await this.find({ user: userId }, { fields: [...PANEL_FIELDS], orderBy: { created_at: 'desc' }, disableIdentityMap: true }); + const rows = await this.find({ user: userId }, { fields: [...PANEL_FIELDS], orderBy: { created_at: 'desc' } }); return rows.map((row) => toPanelRow(row)); } diff --git a/server/src/db/repositories/_shared/trek-repository.ts b/server/src/db/repositories/_shared/trek-repository.ts new file mode 100644 index 000000000..2df2670f7 --- /dev/null +++ b/server/src/db/repositories/_shared/trek-repository.ts @@ -0,0 +1,192 @@ +import { + EntityRepository, + type CountOptions, + type DeleteOptions, + type EntityData, + type FilterQuery, + type FindAllOptions, + type FindOneOptions, + type FindOptions, + type GetKyselyOptions, + type Loaded, + type NativeInsertUpdateOptions, + type Primary, + type QueryBuilder, + type RequiredEntityData, + type SqlEntityManager, + type UpdateOptions, + type UpsertOptions, + type WithUsingOptions, +} from '@mikro-orm/sql'; + +/** + * The one base class every repository in `src/db/repositories/*.ts` extends, + * instead of `@mikro-orm/sql`'s `EntityRepository` directly. + * + * **Why it exists.** Plan 3b Task 1's `disableIdentityMap` ruling + * (`Users.repository.ts`'s class docstring, task-1-review.md B1) made every + * row-out read pass `disableIdentityMap: true` to stop a stale, partially + * hydrated identity-map entity from being written back over an intervening + * `nativeUpdate`. The re-review (`task-1-rereview.md` F-R1) found the + * side effect: `disableIdentityMap: true` resolves its `EntityManager` with + * `getContext(false)` (`node_modules/@mikro-orm/core/EntityManager.js`, + * the `findOne`/`find` `disableIdentityMap` branch), and `false` is the one + * argument that SKIPS the `allowGlobalContext` check — + * `getContext(validate = true)` is the only place `ValidationError + * .cannotUseGlobalContext()` is thrown, gated on `validate` + * (`node_modules/@mikro-orm/core/EntityManager.js:1912-1925`). So every + * converted read stopped failing closed outside a request context: it now + * succeeds silently against the global `EntityManager` instead of throwing. + * Separately, the *write* paths (`nativeUpdate`/`nativeDelete`/`count`/ + * `insert`/`upsert`) never validated in the first place — they all resolve + * with `getContext(false)` too (task-1-review.md F7 INFO) — so an unwrapped + * entrypoint that only ever writes was never caught at all. + * + * This class restores the program's D6 ratchet ("an unwrapped entrypoint + * fails loudly") for every path a repository exposes, in one place: each + * override below calls {@link TrekRepository.validateRequestContext} FIRST — + * MikroORM's own, VALIDATING `getContext()` (`validate` defaults to `true`) + * — which throws `ValidationError.cannotUseGlobalContext()` outside + * `withRequestContext`/`RequestContext.create`/an open transaction, exactly + * as a managed (identity-map) read did before the `disableIdentityMap` + * ruling — and only then delegates to `super`. Every overridden method keeps + * MikroORM's own signature and does nothing else: this class does not change + * what a repository method returns or which SQL it emits, only whether it + * runs at all outside a request context. + * + * **Read-path default.** `find`/`findOne`/`findAll` additionally merge + * `{ disableIdentityMap: true }` into the caller's options, unless the + * caller passes `disableIdentityMap: false` explicitly — the class-level + * ruling every repository used to restate on its own `Users.repository.ts`, + * `McpTokens.repository.ts`, etc. — so no repository needs to pass the + * option itself any more. See + * https://mikro-orm.io/docs/entity-manager#disableidentitymap. + * + * **Kysely.** `em.getKysely()` (used for the one or two statements per + * repository the QueryBuilder cannot express — a recursive CTE, a + * `DELETE … RETURNING`) resolves its `EntityManager` with `getContext(false)` + * too (`node_modules/@mikro-orm/sql/SqlEntityManager.js`), so it has the + * exact same gap as the write paths above. {@link TrekRepository.kysely} is + * the one place a repository reaches Kysely from now on, instead of calling + * `this.getEntityManager().getKysely()` directly. + * + * See https://mikro-orm.io/docs/repositories (custom repositories) and + * https://mikro-orm.io/docs/identity-map (`allowGlobalContext`). + */ +export abstract class TrekRepository extends EntityRepository { + /** + * MikroORM's own validating `getContext()` — throws + * `ValidationError.cannotUseGlobalContext()` outside a request context + * (`allowGlobalContext: false`, the production default) or an open + * transaction. `super.getEntityManager()`, not `this.getEntityManager()`: + * the latter is itself overridden below to call this method, and calling + * through `this` here would recurse. + */ + protected validateRequestContext(): void { + super.getEntityManager().getContext(); + } + + /** + * `em.getKysely()`, validated first. The QueryBuilder has no recursive-CTE + * support and its `DELETE … RETURNING` is silently dropped + * (`WebauthnChallenges.repository.ts::claimChallenge`'s docstring), so a + * handful of methods across the program need this escape hatch — always + * through here now, never `this.getEntityManager().getKysely()` directly. + * No explicit return-type annotation: `SqlEntityManager.getKysely`'s + * return type is expressed in terms of `this` (the entity list the calling + * `EntityManager` was constructed with), which does not survive being + * spelled out via `ReturnType<...>` on its own — inference from the + * `return` statement below keeps it intact. + */ + protected kysely(options?: TOptions) { + this.validateRequestContext(); + return this.getEntityManager().getKysely(options); + } + + override getEntityManager(): SqlEntityManager { + this.validateRequestContext(); + return super.getEntityManager(); + } + + /** + * `find`/`findOne`/`findAll` below drop the base signature's `Using` + * type parameter and its `IndexFilterQuery`/`using` machinery (MikroORM's + * named-index query hint) rather than reproduce it: no repository in this + * program passes `using`, and TypeScript's override-compatibility check + * on that parameter's conditional type — `[Using] extends [never] ? ... : + * ...` compared against the base class's own instantiation of it — does + * not hold even for a byte-identical copy of the declared signature (a + * generic-defaults quirk in how the checker instantiates the two sides + * for comparison, not a real behavioural difference). Every other generic + * (`Hint`/`Fields`/`Excludes`, the ones every repository's `fields`/ + * `populate` options actually use) is preserved exactly. + */ + override async findOne( + where: FilterQuery, + options?: FindOneOptions, + ): Promise | null> { + this.validateRequestContext(); + const disableIdentityMap = options?.disableIdentityMap ?? true; + return super.findOne(where, { ...options, disableIdentityMap }); + } + + override async find( + where: FilterQuery, + options?: FindOptions, + ): Promise[]> { + this.validateRequestContext(); + const disableIdentityMap = options?.disableIdentityMap ?? true; + return super.find(where, { ...options, disableIdentityMap }); + } + + override async findAll< + Hint extends string = never, + Fields extends string = never, + Excludes extends string = never, + Using extends string = never, + >( + options?: WithUsingOptions, Entity, Using>, + ): Promise[]> { + this.validateRequestContext(); + const disableIdentityMap = options?.disableIdentityMap ?? true; + return super.findAll({ ...options, disableIdentityMap }); + } + + override async count(where?: FilterQuery, options?: CountOptions): Promise { + this.validateRequestContext(); + return super.count(where, options); + } + + override async nativeUpdate(where: FilterQuery, data: EntityData, options?: UpdateOptions): Promise { + this.validateRequestContext(); + return super.nativeUpdate(where, data, options); + } + + override async nativeDelete(where: FilterQuery, options?: DeleteOptions): Promise { + this.validateRequestContext(); + return super.nativeDelete(where, options); + } + + override async insert(data: Entity | RequiredEntityData, options?: NativeInsertUpdateOptions): Promise> { + this.validateRequestContext(); + return super.insert(data, options); + } + + override async upsert( + entityOrData?: EntityData | Entity, + options?: UpsertOptions, + ): Promise { + this.validateRequestContext(); + return super.upsert(entityOrData, options); + } + + override createQueryBuilder(alias?: RootAlias): QueryBuilder { + this.validateRequestContext(); + return super.createQueryBuilder(alias); + } + + override qb(alias?: RootAlias): QueryBuilder { + this.validateRequestContext(); + return super.qb(alias); + } +} diff --git a/server/src/nest/auth/jwt-auth.guard.ts b/server/src/nest/auth/jwt-auth.guard.ts index 8e9cb313e..6806b58b3 100644 --- a/server/src/nest/auth/jwt-auth.guard.ts +++ b/server/src/nest/auth/jwt-auth.guard.ts @@ -35,7 +35,7 @@ import { extractToken, verifyJwtAndLoadUser } from './jwt-verify'; * `@mikro-orm/nestjs`'s provider factory injects `orm.em`, the ORM's single * GLOBAL, context-resolving `EntityManager`, the same object every request * shares. It resolves to the request's transactional fork at QUERY TIME: - * `EntityRepository.getRepository` builds the repository holding a + * `EntityManager.getRepository` builds the repository holding a * reference to `this.em` with no `getContext()` call, and * `EntityRepository.findOne` calls `this.getEntityManager().findOne(...)`, * which resolves the live `AsyncLocalStorage`/`TransactionContext` inside diff --git a/server/src/nest/common/row-id.ts b/server/src/nest/common/row-id.ts index d3bae9205..9980e3534 100644 --- a/server/src/nest/common/row-id.ts +++ b/server/src/nest/common/row-id.ts @@ -38,6 +38,30 @@ * 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. + * + * **The "accepted narrowing" is a different SIZE depending on what the + * legacy seam bound (task-3-rereview.md R1).** Not every legacy raw-SQL + * call site bound the route string straight into `WHERE id = ?` and leaned + * on SQLite's affinity as described above (an "affinity seam" — + * `registration-invites.service.ts::deleteInvite` is one, and so are the + * categories/tags/token routes Task 2 converted): some called `Number(id)` + * FIRST, in application code, before the query ever ran (a "`Number()` + * seam" — `passkey.service.ts::renamePasskey`/`deletePasskey`, + * `AdminService.resetUserPasskeys`). On an affinity seam, `toRowId` removes + * only the genuine false-accepts described above (whitespace, decimal + * point, exponent, leading `+`). On a `Number()` seam it removes a WIDER + * set: `Number('0x10')`, `Number('1e1')`, `Number('16.0')` and `Number(' + * 16')` all coerce to a real integer in JavaScript (unlike SQLite's + * affinity, which never converts a hex literal), so on that kind of seam + * `toRowId` additionally refuses every hex and exponent form that used to + * hit a REAL row, not just malformed ones. Both are the same deliberate, + * accepted deviation — `toRowId` is right for both — but a regression test + * that asserts "`'0x10'` gets the legacy 404" is only true on an affinity + * seam; on a `Number()` seam the legacy statement acted on the row `toRowId` + * now refuses, so the honest claim is "the deliberate `toRowId` narrowing", + * not "parity with the legacy". Check which kind of seam a route's legacy + * handler was (`git show :` on the actual statement, not the + * route) before writing that test. */ export function toRowId(value: unknown): number | null { if (typeof value === 'number') { diff --git a/server/src/nest/oauth/oauth.helpers.ts b/server/src/nest/oauth/oauth.helpers.ts index 2d87b2c05..c613a3c23 100644 --- a/server/src/nest/oauth/oauth.helpers.ts +++ b/server/src/nest/oauth/oauth.helpers.ts @@ -146,38 +146,6 @@ export function redirectUriMatches(allowed: string, requested: string): boolean && a.password === r.password; } -// --------------------------------------------------------------------------- -// DB row types -// --------------------------------------------------------------------------- - -export interface OAuthClientRow { - id: string; - user_id: number; - name: string; - client_id: string; - client_secret_hash: string; - redirect_uris: string; // JSON array - allowed_scopes: string; // JSON array - created_at: string; - is_public: number; // 0 | 1 (SQLite boolean) - created_via: string; // 'settings_ui' | 'browser-registration' - allows_client_credentials: number; // 0 | 1 -} - -export interface OAuthTokenRow { - id: number; - client_id: string; - user_id: number; - access_token_hash: string; - refresh_token_hash: string; - scopes: string; // JSON array - audience: string | null; - access_token_expires_at: string; - refresh_token_expires_at: string; - revoked_at: string | null; - parent_token_id: number | null; -} - // --------------------------------------------------------------------------- // Token helpers // --------------------------------------------------------------------------- diff --git a/server/tests/integration/passkey.test.ts b/server/tests/integration/passkey.test.ts index 5af013829..cbd6c2951 100644 --- a/server/tests/integration/passkey.test.ts +++ b/server/tests/integration/passkey.test.ts @@ -87,8 +87,21 @@ describe('Passkey management — non-numeric id parity (Plan 3b Task 3 review, F expect(res.body).toEqual({ error: 'Passkey not found' }); }); - it('PASSKEY-INT-003 — PATCH /auth/passkey/credentials/0x10 (hex-literal id) returns the legacy 404, not a 500', async () => { + // task-3-rereview.md R1: on the passkey credential routes the LEGACY bound + // `Number(id)` (unlike the invite route's raw-string bind PASSKEY-INT-003's + // sibling cases guard against), so `Number('0x10') === 16` and the legacy + // statement would have ACTED ON credential 16, not answered a 404 — this + // case pins the deliberate `toRowId` narrowing the fix round chose + // (accepted deviation), not "the legacy 404". Credential 16 is seeded so + // the case is load-bearing: dropping the `toRowId` guard makes + // `renamePasskey` fall back to `Number(id)`, which would rename credential + // 16 instead of 404ing — verified by hand, recorded in the task report. + it('PASSKEY-INT-003 — PATCH /auth/passkey/credentials/0x10 (hex-literal id) is refused by the toRowId narrowing, even though credential 16 exists', async () => { const { user } = createUser(testDb); + testDb.prepare( + `INSERT INTO webauthn_credentials (id, user_id, credential_id, public_key, name) + VALUES (16, ?, 'cred-16', X'00', 'Original16')`, + ).run(user.id); const res = await request(app) .patch('/api/auth/passkey/credentials/0x10') @@ -97,6 +110,8 @@ describe('Passkey management — non-numeric id parity (Plan 3b Task 3 review, F expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Passkey not found' }); + const row = testDb.prepare('SELECT name FROM webauthn_credentials WHERE id = 16').get() as { name: string }; + expect(row.name).toBe('Original16'); // untouched — not renamed to 'Ghost' }); it('PASSKEY-INT-004 — DELETE /auth/passkey/credentials/abc (non-numeric id, correct password) returns the legacy 404, not a 500', async () => { diff --git a/server/tests/unit/db/repositories/InviteTokens.repository.test.ts b/server/tests/unit/db/repositories/InviteTokens.repository.test.ts index f10f082b4..00e0ce392 100644 --- a/server/tests/unit/db/repositories/InviteTokens.repository.test.ts +++ b/server/tests/unit/db/repositories/InviteTokens.repository.test.ts @@ -169,6 +169,17 @@ describe('InviteTokensRepository', () => { expect(sql).toMatch(/left join `?trips`?/i); expect(sql).not.toMatch(/left join `?users`?/i); }); + + it('INVREPO-016: findWithCreatorAndTrip\'s generated SQL joins users with an INNER JOIN and trips with a LEFT JOIN — pins RI5\'s join TYPE (task-3-rereview.md R2; a `leftJoin` rewrite of the users join must fail this)', async () => { + const { user: admin } = createUser(testDb, { username: 'join-type-check-single' }); + const invite = createInviteToken(testDb, { token: 'join-type-check-single', created_by: admin.id }); + + const { sql } = await captureSql(() => invites.findWithCreatorAndTrip(invite.id)); + + expect(sql).toMatch(/inner join `?users`?/i); + expect(sql).toMatch(/left join `?trips`?/i); + expect(sql).not.toMatch(/left join `?users`?/i); + }); }); // RI6/RI7 — deleteInvite's 404 check and the delete itself. diff --git a/server/tests/unit/db/repositories/OauthClients.repository.test.ts b/server/tests/unit/db/repositories/OauthClients.repository.test.ts index 1f0db586f..bba4f8723 100644 --- a/server/tests/unit/db/repositories/OauthClients.repository.test.ts +++ b/server/tests/unit/db/repositories/OauthClients.repository.test.ts @@ -210,15 +210,26 @@ describe('OauthClientsRepository', () => { }); describe('identity-map write-back regression (program RULING, D-shape)', () => { - it('OAUTHCLIENTREPO-016: listByUser then findAuthRow then updateSecretHash inside uow.transactional — the write survives', async () => { + // task-4-review.md F1: the D-shape needs the `nativeUpdate`'s target + // column in the FIRST, WIDER projection and absent from the SECOND, + // narrower one — a stale write-back only reverts a column the second + // read's snapshot doesn't already carry fresh. The original ordering + // here (`listByUser`, narrow, then `findAuthRow`, wide) had it backwards: + // `findAuthRow`'s own re-snapshot of `client_secret_hash` left that + // column clean, so the mutation this test exists to catch (reverting + // `disableIdentityMap: true` back to `refresh: true`) passed anyway. + // `findAuthRow` (wide, carries `client_secret_hash`) now goes FIRST and + // `findOwned` (narrow, no `client_secret_hash`) second — mutation-proven + // load-bearing in the task report. + it('OAUTHCLIENTREPO-016: findAuthRow then findOwned then updateSecretHash inside uow.transactional — the write survives', async () => { const { user } = createUser(testDb); const { id, clientId } = seedClient({ userId: user.id, secretHash: 'original', name: 'kept-name' }); await withRequestContext(t.orm, async () => { - // Projection A - await clients.listByUser(user.id); - // Projection B — a different field set on the same row + // Projection A — wide, carries client_secret_hash. await clients.findAuthRow(clientId); + // Projection B — narrow, a different (smaller) field set on the same row. + await clients.findOwned(id, user.id); // The intended write, inside the request's own transaction. await uow.transactional(async () => { await clients.updateSecretHash(id, 'rotated'); diff --git a/server/tests/unit/db/repositories/OauthConsents.repository.test.ts b/server/tests/unit/db/repositories/OauthConsents.repository.test.ts index bc0dfd1fd..6bbdcfa3f 100644 --- a/server/tests/unit/db/repositories/OauthConsents.repository.test.ts +++ b/server/tests/unit/db/repositories/OauthConsents.repository.test.ts @@ -85,22 +85,33 @@ describe('OauthConsentsRepository', () => { }); }); - describe('identity-map isolation (program RULING, D-shape)', () => { - it('OAUTHCONSENTREPO-007: findScopes leaves no managed entity behind — a raw write to the same row on the same transaction connection survives the transaction\'s closing flush', async () => { + // task-4-review.md F2: this is NOT a B1/D-shape regression test — that + // shape needs TWO managed reads with different field sets on the same + // row (task-1-rereview.md M-A/M-B), and `OauthConsentsRepository` has + // exactly one read method, so the D-shape is not constructible here at + // all. Mutating `findScopes`'s `disableIdentityMap: true` to `refresh: + // true` leaves this case green either way — it was never testing the + // mechanism its old name/comment claimed. What it DOES pin, genuinely: + // a single `findScopes` read leaves nothing dirty (the entity's snapshot + // and current data are identical, so the change-set computer finds no + // diff), and a raw write to the same row on the transaction's own + // connection, made afterward, is never fought by the closing flush. + describe('findScopes does not dirty the entity it reads', () => { + it('OAUTHCONSENTREPO-007: findScopes then a raw write on the same transaction connection — the raw write survives the transaction\'s closing flush', async () => { const { user } = createUser(testDb); seedClient(user.id, 'proto-7'); testDb.prepare(`INSERT INTO oauth_consents (client_id, user_id, scopes) VALUES (?, ?, ?)`).run('proto-7', user.id, '["a"]'); await withRequestContext(t.orm, async () => { await uow.transactional(async () => { - // Projection: the only read shape this repository has. + // The only read shape this repository has — a single projection, + // so no second, differently-projected read of the same row can + // ever be lingering in the identity map for this one to collide + // with. await consents.findScopes('proto-7', user.id); // A write bypassing the ORM entirely, on the SAME single SQLite // connection the open transaction holds (D3/D6) — the shape any - // nativeUpdate write from elsewhere in the same request takes. If - // findScopes had left a managed entity in the identity map (the - // B1 mechanism), the transaction's closing flush would try to - // write the READ snapshot back over this change. + // nativeUpdate write from elsewhere in the same request takes. testDb.prepare('UPDATE oauth_consents SET scopes = ? WHERE client_id = ? AND user_id = ?').run('["a","b"]', 'proto-7', user.id); }); }); diff --git a/server/tests/unit/db/repositories/trek-repository.test.ts b/server/tests/unit/db/repositories/trek-repository.test.ts new file mode 100644 index 000000000..1bbf0dd64 --- /dev/null +++ b/server/tests/unit/db/repositories/trek-repository.test.ts @@ -0,0 +1,223 @@ +/** + * `TrekRepository` — the base class every repository under + * `src/db/repositories/*.ts` extends (Plan 3b interlude B, + * `.superpowers/sdd/2026-09-22-orm-phase3b/task-base-repo-brief.md`). + * + * Restores the program's D6 ratchet ("an unwrapped entrypoint fails + * loudly") for every repository path, closing the gap + * `task-1-rereview.md` F-R1 found: the `disableIdentityMap: true` ruling + * (Plan 3b Task 1, B1) resolves its `EntityManager` with MikroORM's + * non-validating `getContext(false)`, so a converted read stopped failing + * closed outside a request context, and the write paths + * (`nativeUpdate`/`nativeDelete`/`count`/`insert`/`upsert`) never validated + * in the first place (`task-1-review.md` F7 INFO). This file drives every + * overridden method against a real ORM with `allowGlobalContext: false` + * (the production setting) — outside any request context each one must + * reject with MikroORM's own `cannotUseGlobalContext` `ValidationError`; + * inside `withRequestContext`/`uow.transactional` each must succeed exactly + * as before. + * + * Uses the two concrete repositories the base class actually protects + * (`UsersRepository` for find/findOne/count/nativeUpdate/nativeDelete/ + * insert/upsert/qb, `WebauthnChallengesRepository.claimChallenge` for the + * one `kysely()` statement) rather than instantiating the abstract class + * directly — `TrekRepository` is never instantiated on its own in + * production either. + * + * **D-shape RULE for every regression test in the program written against + * the `disableIdentityMap` ruling (task-4-review.md F1):** the column the + * `nativeUpdate` targets must be in the FIRST, WIDER projection and absent + * from the SECOND, narrower one. A stale write-back can only revert a + * column whose value the second read's own snapshot does not already carry + * fresh — if the write's target column is in the narrower, later-read + * projection instead, that read's own snapshot is already correct for it + * and no staleness is observable no matter what `disableIdentityMap` does. + * `OAUTHCLIENTREPO-016` (`OauthClients.repository.test.ts`) had this + * backwards until this task; `OAUTHTOKREPO-029` (`OauthTokens.repository + * .test.ts`) already had it right and is the shape to copy. + */ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +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 type { UsersRepository } from '../../../../src/db/repositories/Users.repository'; +import { WebauthnChallenges } from '../../../../src/db/entities/WebauthnChallenges.entity'; +import type { WebauthnChallengesRepository } from '../../../../src/db/repositories/WebauthnChallenges.repository'; +import { UnitOfWork } from '../../../../src/nest/database/unit-of-work'; +import { withRequestContext } from '../../../../src/nest/database/request-context'; + +const GLOBAL_CONTEXT_ERROR = /global EntityManager|global context/i; + +const testDb = createSnapshotTestDb(); +let t: TestOrm; +let users: UsersRepository; +let challenges: WebauthnChallengesRepository; +let uow: UnitOfWork; + +// Global context DISALLOWED here on purpose — the production setting, like +// tests/unit/nest/database/request-context.test.ts and +// tests/unit/nest/cron-registrar.service.test.ts's D6 describe block — so a +// repository call with no wrapper around it genuinely throws. +beforeAll(async () => { + t = await createTestOrm(testDb, { allowGlobalContext: false }); + users = t.repo(Users); + challenges = t.repo(WebauthnChallenges); + uow = new UnitOfWork(t.em); +}); +// `t.clear()` is deliberately NOT called here (unlike most repository test +// files): `EntityManager.clear()` itself calls the VALIDATING `getContext()` +// (`node_modules/@mikro-orm/core/EntityManager.js:1785`), so it would throw +// the same `cannotUseGlobalContext` this file exists to test, outside any +// request context. Every read in this file passes through +// `disableIdentityMap: true` (the base class's default), so nothing ever +// populates the identity map for a stale entry to clear between tests. +beforeEach(() => { + resetTestDb(testDb); +}); +afterAll(async () => { + await t.close(); + testDb.close(); +}); + +describe('TrekRepository — every overridden path fails closed outside a request context', () => { + it('TREKREPO-001: findOne rejects', async () => { + await expect(users.findOne({ id: 1 })).rejects.toThrow(GLOBAL_CONTEXT_ERROR); + }); + + it('TREKREPO-002: find rejects', async () => { + await expect(users.find({})).rejects.toThrow(GLOBAL_CONTEXT_ERROR); + }); + + it('TREKREPO-003: findAll rejects', async () => { + await expect(users.findAll()).rejects.toThrow(GLOBAL_CONTEXT_ERROR); + }); + + it('TREKREPO-004: count rejects', async () => { + await expect(users.count({})).rejects.toThrow(GLOBAL_CONTEXT_ERROR); + }); + + it('TREKREPO-005: nativeUpdate rejects', async () => { + await expect(users.nativeUpdate({ id: 1 }, { username: 'outside-context' })).rejects.toThrow(GLOBAL_CONTEXT_ERROR); + }); + + it('TREKREPO-006: nativeDelete rejects', async () => { + await expect(users.nativeDelete({ id: -1 })).rejects.toThrow(GLOBAL_CONTEXT_ERROR); + }); + + it('TREKREPO-007: insert rejects', async () => { + await expect( + users.insert({ username: 'outside-context', email: 'outside@example.test', password_hash: 'h', role: 'user', first_seen_version: '1.0' }), + ).rejects.toThrow(GLOBAL_CONTEXT_ERROR); + }); + + it('TREKREPO-008: upsert rejects', async () => { + await expect( + users.upsert({ id: 1, username: 'outside-context' }, { onConflictFields: ['id'], onConflictAction: 'merge' }), + ).rejects.toThrow(GLOBAL_CONTEXT_ERROR); + }); + + it('TREKREPO-009: a QueryBuilder read (qb) rejects', () => { + expect(() => users.qb('u')).toThrow(GLOBAL_CONTEXT_ERROR); + }); + + it('TREKREPO-010: createQueryBuilder rejects', () => { + expect(() => users.createQueryBuilder('u')).toThrow(GLOBAL_CONTEXT_ERROR); + }); + + it('TREKREPO-011: a Kysely statement (the protected kysely() helper) rejects', async () => { + await expect(challenges.claimChallenge('outside-context', 'registration', Date.now())).rejects.toThrow(GLOBAL_CONTEXT_ERROR); + }); + + it('TREKREPO-012: getEntityManager() itself rejects', () => { + expect(() => users.getEntityManager()).toThrow(GLOBAL_CONTEXT_ERROR); + }); +}); + +describe('TrekRepository — every overridden path succeeds inside withRequestContext', () => { + it('TREKREPO-013: findOne/find/findAll/count/nativeUpdate/nativeDelete/insert/upsert/qb/kysely all succeed', async () => { + const { user } = createUser(testDb, { username: 'inside-before', email: 'inside-before@example.test' }); + + await withRequestContext(t.orm, async () => { + await expect(users.findOne({ id: user.id })).resolves.not.toBeNull(); + await expect(users.find({ id: user.id })).resolves.toHaveLength(1); + await expect(users.findAll({ limit: 1 })).resolves.toHaveLength(1); + await expect(users.count({})).resolves.toBeGreaterThan(0); + await expect(users.nativeUpdate({ id: user.id }, { username: 'inside-after' })).resolves.toBe(1); + + const qbRow = await users + .qb('u') + .select('u.id') + .where({ id: user.id }) + .execute<{ id: number } | undefined>('get', false); + expect(qbRow?.id).toBe(user.id); + + const cbRow = await users + .createQueryBuilder('u') + .select('u.id') + .where({ id: user.id }) + .execute<{ id: number } | undefined>('get', false); + expect(cbRow?.id).toBe(user.id); + + // Kysely: proving the statement runs at all (no throw), not its result shape. + await expect(challenges.claimChallenge('inside-context', 'registration', Date.now())).resolves.toBeNull(); + + const inserted = await users.insert({ + username: 'inside-new', + email: 'inside-new@example.test', + password_hash: 'h', + role: 'user', + first_seen_version: '1.0', + }); + expect(typeof inserted).toBe('number'); + + await users.upsert( + { id: user.id, username: 'inside-upserted', email: user.email, password_hash: 'h', role: 'user', first_seen_version: '1.0' }, + { onConflictFields: ['id'], onConflictAction: 'merge' }, + ); + + expect(users.getEntityManager()).toBeDefined(); + + await users.nativeDelete({ id: inserted as number }); + }); + + const row = testDb.prepare('SELECT username FROM users WHERE id = ?').get(user.id) as { username: string }; + expect(row.username).toBe('inside-upserted'); // the upsert's merge is the last write and must stick + }); +}); + +describe('TrekRepository — inside uow.transactional', () => { + it('TREKREPO-014: a disableIdentityMap read sees the transaction\'s own uncommitted write', async () => { + const { user } = createUser(testDb, { username: 'tx-before' }); + let seenInsideTx: string | undefined; + + await withRequestContext(t.orm, async () => { + await uow.transactional(async () => { + await users.nativeUpdate({ id: user.id }, { username: 'tx-uncommitted' }); + const row = await users.findOne({ id: user.id }, { fields: ['username'] }); + seenInsideTx = row?.username; + }); + }); + + expect(seenInsideTx).toBe('tx-uncommitted'); + }); + + it('TREKREPO-015: disableIdentityMap is in effect by default — a raw UPDATE between two reads is seen, no stale write-back on the closing flush (reuses identity-map-writeback.test.ts\'s shape)', async () => { + const { user } = createUser(testDb, { username: 'before', email: 'before@example.test' }); + + await withRequestContext(t.orm, async () => { + // Two differently-projected reads of the same row — the D-shape B1 + // depends on, if the entity were left managed. + await users.findOne({ id: user.id }, { fields: ['username'] }); + await users.findOne({ id: user.id }, { fields: ['email'] }); + await uow.transactional(async () => { + await users.nativeUpdate({ id: user.id }, { username: 'after' }); + }); + }); + + const row = testDb.prepare('SELECT username, email FROM users WHERE id = ?').get(user.id) as { username: string; email: string }; + expect(row.username).toBe('after'); // the nativeUpdate must stick + expect(row.email).toBe('before@example.test'); // untouched by either read + }); +}); diff --git a/server/tests/unit/nest/cron-registrar.service.test.ts b/server/tests/unit/nest/cron-registrar.service.test.ts index cec6fc3e2..6b3381ba2 100644 --- a/server/tests/unit/nest/cron-registrar.service.test.ts +++ b/server/tests/unit/nest/cron-registrar.service.test.ts @@ -207,6 +207,31 @@ describe('CronRegistrarService', () => { expect(caught).toBeUndefined(); expect(Array.isArray(result)).toBe(true); }); + + // Plan 3b interlude B mutation proof (b): the SAME wrapper, but through a + // real TrekRepository-backed call rather than a bare `em.find` — proving + // the wrapper is load-bearing for the base class's write paths too + // (`count`/`nativeUpdate`/`nativeDelete`/`insert`/`upsert`), which never + // validated their own context before this class existed + // (task-1-review.md F7 INFO) and so would NOT have failed this way on + // the pre-interlude-B tree. + it('CRONREG-011B — with MikroORM injected, a TrekRepository-backed count() inside onTick succeeds; dropping the wrapper (mutation, not committed) makes it throw cannotUseGlobalContext', async () => { + const registry = new SchedulerRegistry(); + const registrar = new CronRegistrarService(registry, { isTest: () => false } as RuntimeEnvService, t.orm); + const users = t.repo(Users); + let result: unknown; + let caught: unknown; + registrar.register('job', '0 2 * * *', async () => { + try { + result = await users.count({}); + } catch (e) { + caught = e; + } + }); + await h.jobs[0].onTick(); + expect(caught).toBeUndefined(); + expect(typeof result).toBe('number'); + }); }); describe('runOnBoot (task-6-fix-brief.md item 7 — the boot-sweep choke point)', () => { diff --git a/server/tests/unit/nest/files.service.test.ts b/server/tests/unit/nest/files.service.test.ts index 70ed9d42c..eb29b8033 100644 --- a/server/tests/unit/nest/files.service.test.ts +++ b/server/tests/unit/nest/files.service.test.ts @@ -75,13 +75,20 @@ import type { TripFile, User } from '../../../src/types'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; import type { EntityManager } from '@mikro-orm/core'; +import { Users } from '../../../src/db/entities/Users.entity'; const storageDelete = vi.fn(); const storageStub = { delete: storageDelete } as unknown as import('../../../src/nest/storage/storage.service').StorageService; // EntityManager stub (Plan 3b Task 1 RULING): verifyJwtAndLoadUser is // fully mocked above, so `em.getRepository` never needs to return // anything meaningful; it just has to not throw when the service calls it. -const emStub = { getRepository: () => ({}) } as unknown as EntityManager; +// `getRepository` is a spy, not a plain arrow (task-1-rereview.md F-R4): a +// fixed `() => ({})` pinned only the RETURN value, not which entity class +// `files.service.ts` asks for — the assertion below would hold even if the +// production code asked for `Trips`. The spy lets FILE-SVC-032/033 pin +// `Users` specifically. +const getRepository = vi.fn(() => ({})); +const emStub = { getRepository } as unknown as EntityManager; const svc = new FilesService(new DatabaseService(testDb), permissionsStub, new RealtimeService(), new EphemeralTokenService(), storageStub, emStub); beforeAll(() => { @@ -564,13 +571,18 @@ describe('authenticateDownload', () => { // always returns a fresh `{}` (task-1-review.md F6) — deep-equal, not a // reference match, so this pins WHICH repository (an empty object, this // suite's stand-in for UsersRepository), not merely "a repository". + // `getRepository` itself is a spy (task-1-rereview.md F-R4), so this ALSO + // pins which entity class was asked for: the deep-equal alone would still + // hold if the production code called `em.getRepository(Trips)`. expect(verifyJwtAndLoadUser).toHaveBeenCalledWith('cookie-jwt', {}); + expect(getRepository).toHaveBeenCalledWith(Users); }); it('FILE-SVC-033: a bearer token is used when no cookie is present; invalid JWTs 401', async () => { verifyJwtAndLoadUser.mockReturnValue({ id: 7 }); expect(await svc.authenticateDownload(req({ bearer: 'bearer-jwt' }))).toEqual({ userId: 7 }); expect(verifyJwtAndLoadUser).toHaveBeenCalledWith('bearer-jwt', {}); // see FILE-SVC-032's comment + expect(getRepository).toHaveBeenCalledWith(Users); verifyJwtAndLoadUser.mockReturnValue(null); expect(await svc.authenticateDownload(req({ bearer: 'stale' }))).toEqual({ error: 'Invalid or expired token', status: 401 }); diff --git a/server/tests/unit/nest/passkey.service.test.ts b/server/tests/unit/nest/passkey.service.test.ts index acf33aa06..5303edad8 100644 --- a/server/tests/unit/nest/passkey.service.test.ts +++ b/server/tests/unit/nest/passkey.service.test.ts @@ -726,10 +726,19 @@ describe('renamePasskey', () => { expect(await svc.renamePasskey(user.id, '999999', 'Ghost')).toEqual({ error: 'Passkey not found', status: 404 }); }); - it('PASSKEY-SVC-042: 404s (not a 500) on a non-numeric or prefixed-numeric id — Plan 3b Task 3 review, F1', async () => { + it('PASSKEY-SVC-042: 404s (not a 500) on a non-numeric id — Plan 3b Task 3 review, F1', async () => { const { user } = createUser(testDb); expect(await svc.renamePasskey(user.id, 'abc', 'Ghost')).toEqual({ error: 'Passkey not found', status: 404 }); expect(await svc.renamePasskey(user.id, '1abc', 'Ghost')).toEqual({ error: 'Passkey not found', status: 404 }); + }); + + // task-3-rereview.md R1: the legacy `renamePasskey` bound `Number(id)` + // (unlike the invite route's raw-string bind), so `Number('0x10') === 16` + // — the legacy statement would have ACTED on credential 16, not 404'd. + // This pins the deliberate `toRowId` narrowing the fix round chose + // (accepted deviation, not parity), not "the legacy 404". + it('PASSKEY-SVC-042B: refuses a prefixed-numeric id (0x10) via the toRowId narrowing', async () => { + const { user } = createUser(testDb); expect(await svc.renamePasskey(user.id, '0x10', 'Ghost')).toEqual({ error: 'Passkey not found', status: 404 }); }); }); @@ -759,6 +768,13 @@ describe('deletePasskey', () => { it('PASSKEY-SVC-043: 404s (not a 500) on a non-numeric id once the password check passes — Plan 3b Task 3 review, F1', async () => { const { user, password } = createUser(testDb); expect(await svc.deletePasskey(user.id, 'abc', password)).toEqual({ error: 'Passkey not found', status: 404 }); + }); + + // task-3-rereview.md R1: same distinction as PASSKEY-SVC-042B — + // `deletePasskey`'s legacy bind was `Number(id)`, so `'0x10'` would have + // acted on credential 16, not 404'd. This pins the `toRowId` narrowing. + it('PASSKEY-SVC-043B: refuses a prefixed-numeric id (0x10) via the toRowId narrowing, once the password check passes', async () => { + const { user, password } = createUser(testDb); expect(await svc.deletePasskey(user.id, '0x10', password)).toEqual({ error: 'Passkey not found', status: 404 }); });