refactor(db): TrekRepository base — every repository path validates the request context and reads bypass the identity map by default

This commit is contained in:
jubnl
2026-09-22 22:37:49 +02:00
parent 8ba55bda30
commit 21ade19d44
137 changed files with 1085 additions and 520 deletions
@@ -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<AddonRow, Addons> = true;
export class AddonsRepository extends EntityRepository<Addons> {
export class AddonsRepository extends TrekRepository<Addons> {
/**
* `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<Addons> {
* request's identity map.
*/
async isEnabled(id: string): Promise<boolean> {
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<Addons> {
* for no parity benefit.
*/
async listEnabled(): Promise<AddonRow[]> {
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);
}
}
@@ -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<AppSettingsRow, AppSettings> = true;
export class AppSettingsRepository extends EntityRepository<AppSettings> {
export class AppSettingsRepository extends TrekRepository<AppSettings> {
/**
* `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<AppSettings> {
* identity map to become a stale pending change at a later `flush()`.
*/
async getValue(key: string): Promise<string | null> {
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<AppSettings> {
* value" from "no row" (both read as absent).
*/
async getValues(keys: string[]): Promise<Map<string, string>> {
const rows = await this.find({ key: { $in: keys } }, { disableIdentityMap: true });
const rows = await this.find({ key: { $in: keys } });
const values = new Map<string, string>();
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<AppSettings> {
* a prefix that itself contains `%`/`_`.
*/
async findByKeyPrefix(prefix: string): Promise<AppSettingsRow[]> {
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);
}
@@ -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<AssignmentParticipants> {}
export class AssignmentParticipantsRepository extends TrekRepository<AssignmentParticipants> {}
@@ -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<AuditLogRow, 'id' | 'created_at'>;
// join to `Users` for `username`/`email`) and `count(): Promise<number>`.
// Not implemented here — see plan3a-sql-inventory.md §1.
export class AuditLogRepository extends EntityRepository<AuditLog> {
export class AuditLogRepository extends TrekRepository<AuditLog> {
/**
* 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<AuditLog> {
* inserts `createDay`/`createNote` rather than `create`.
*/
async insertEntry(entry: NewAuditLogRow): Promise<void> {
await this.getEntityManager().insert(AuditLog, {
await this.insert({
user: entry.user_id,
action: entry.action,
resource: entry.resource,
@@ -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<BucketList> {}
export class BucketListRepository extends TrekRepository<BucketList> {}
@@ -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<BudgetCategoryOrder> {}
export class BudgetCategoryOrderRepository extends TrekRepository<BudgetCategoryOrder> {}
@@ -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<BudgetItemMembers> {}
export class BudgetItemMembersRepository extends TrekRepository<BudgetItemMembers> {}
@@ -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<BudgetItemPayers> {}
export class BudgetItemPayersRepository extends TrekRepository<BudgetItemPayers> {}
@@ -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<BudgetItems> {}
export class BudgetItemsRepository extends TrekRepository<BudgetItems> {}
@@ -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<BudgetSettlements> {}
export class BudgetSettlementsRepository extends TrekRepository<BudgetSettlements> {}
@@ -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<CategoryRow, Categories> = true;
export class CategoriesRepository extends EntityRepository<Categories> {
export class CategoriesRepository extends TrekRepository<Categories> {
/** `SELECT * FROM categories ORDER BY name ASC` */
async list(): Promise<CategoryRow[]> {
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<Categories> {
* 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<Categories> {
* dirty.
*/
async findById(id: number): Promise<CategoryRow | null> {
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<Categories> {
* 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<CategoryRow> {
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<CategoryRow | null> {
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. */
@@ -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<CollabLinks> {}
export class CollabLinksRepository extends TrekRepository<CollabLinks> {}
@@ -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<CollabMessageReactions> {}
export class CollabMessageReactionsRepository extends TrekRepository<CollabMessageReactions> {}
@@ -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<CollabMessages> {}
export class CollabMessagesRepository extends TrekRepository<CollabMessages> {}
@@ -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<CollabNotes> {}
export class CollabNotesRepository extends TrekRepository<CollabNotes> {}
@@ -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<CollabPollVotes> {}
export class CollabPollVotesRepository extends TrekRepository<CollabPollVotes> {}
@@ -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<CollabPolls> {}
export class CollabPollsRepository extends TrekRepository<CollabPolls> {}
@@ -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<CollectionLabels> {}
export class CollectionLabelsRepository extends TrekRepository<CollectionLabels> {}
@@ -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<CollectionMembers> {}
export class CollectionMembersRepository extends TrekRepository<CollectionMembers> {}
@@ -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<CollectionPlaceRatings> {}
export class CollectionPlaceRatingsRepository extends TrekRepository<CollectionPlaceRatings> {}
@@ -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<CollectionPlaces> {}
export class CollectionPlacesRepository extends TrekRepository<CollectionPlaces> {}
@@ -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<Collections> {}
export class CollectionsRepository extends TrekRepository<Collections> {}
@@ -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<DawarichConnections> {}
export class DawarichConnectionsRepository extends TrekRepository<DawarichConnections> {}
@@ -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<DawarichVisitSuggestions> {}
export class DawarichVisitSuggestionsRepository extends TrekRepository<DawarichVisitSuggestions> {}
@@ -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<DayAccommodations> {}
export class DayAccommodationsRepository extends TrekRepository<DayAccommodations> {}
@@ -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<DayAssignments> {}
export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {}
@@ -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<DayNoteRow, DayNotes> = true;
export class DayNotesRepository extends EntityRepository<DayNotes> {
export class DayNotesRepository extends TrekRepository<DayNotes> {
/**
* 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<DayNotes> {
* `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<DayNotes> {
sort_order: number | null;
color: string | null;
}): Promise<DayNoteRow> {
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<DayNotes> {
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;
}
}
+18 -11
View File
@@ -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<DayRow, Days> = true;
export class DaysRepository extends EntityRepository<Days> {
export class DaysRepository extends TrekRepository<Days> {
/** `SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC` */
async listByTrip(trip_id: number): Promise<DayRow[]> {
const days = await this.find({ trip: trip_id }, { orderBy: { day_number: 'asc' } });
@@ -54,12 +54,17 @@ export class DaysRepository extends EntityRepository<Days> {
* 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<Days> {
date: string | null;
notes: string | null;
}): Promise<DayRow> {
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;
}
}
@@ -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<DocumentConnections> {}
export class DocumentConnectionsRepository extends TrekRepository<DocumentConnections> {}
@@ -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<DocumentProviderFields> {}
export class DocumentProviderFieldsRepository extends TrekRepository<DocumentProviderFields> {}
@@ -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<DocumentProviders> {}
export class DocumentProvidersRepository extends TrekRepository<DocumentProviders> {}
@@ -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<DocumentSyncItems> {}
export class DocumentSyncItemsRepository extends TrekRepository<DocumentSyncItems> {}
@@ -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<FileLinks> {}
export class FileLinksRepository extends TrekRepository<FileLinks> {}
@@ -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<GooglePlacePhotoMeta> {}
export class GooglePlacePhotoMetaRepository extends TrekRepository<GooglePlacePhotoMeta> {}
@@ -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<HiddenCountries> {}
export class HiddenCountriesRepository extends TrekRepository<HiddenCountries> {}
@@ -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<HiddenRegions> {}
export class HiddenRegionsRepository extends TrekRepository<HiddenRegions> {}
@@ -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<IdempotencyKeys> {}
export class IdempotencyKeysRepository extends TrekRepository<IdempotencyKeys> {}
@@ -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<InviteTokens> {
export class InviteTokensRepository extends TrekRepository<InviteTokens> {
/**
* `SELECT * FROM invite_tokens WHERE token = ?`
*
@@ -66,7 +66,7 @@ export class InviteTokensRepository extends EntityRepository<InviteTokens> {
* isolated, unmanaged read can never be part of that flush.
*/
async findByToken(token: string): Promise<InviteTokenRow | null> {
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<InviteTokens> {
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<InviteTokens> {
* unrelated repositories side by side, not a compiler conflict).
*/
async findIdById(id: number): Promise<number | null> {
const row = await this.findOne({ id }, { fields: ['id'], disableIdentityMap: true });
const row = await this.findOne({ id }, { fields: ['id'] });
return row ? row.id : null;
}
@@ -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<JourneyBooks> {}
export class JourneyBooksRepository extends TrekRepository<JourneyBooks> {}
@@ -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<JourneyContributors> {}
export class JourneyContributorsRepository extends TrekRepository<JourneyContributors> {}
@@ -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<JourneyEntries> {}
export class JourneyEntriesRepository extends TrekRepository<JourneyEntries> {}
@@ -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<JourneyEntryPhotos> {}
export class JourneyEntryPhotosRepository extends TrekRepository<JourneyEntryPhotos> {}
@@ -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<JourneyPhotos> {}
export class JourneyPhotosRepository extends TrekRepository<JourneyPhotos> {}
@@ -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<JourneyShareTokens> {}
export class JourneyShareTokensRepository extends TrekRepository<JourneyShareTokens> {}
@@ -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<JourneyTrips> {}
export class JourneyTripsRepository extends TrekRepository<JourneyTrips> {}
@@ -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<Journeys> {}
export class JourneysRepository extends TrekRepository<Journeys> {}
@@ -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<McpTokens> {
export class McpTokensRepository extends TrekRepository<McpTokens> {
/**
* `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<McpTokens> {
* 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<McpTokens> {
{
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<McpTokens> {
* 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<McpTokens> {
async findBasic(id: number): Promise<McpTokenBasicRow | null> {
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<McpTokens> {
* is the hazard, not specifically a PK-only filter).
*/
async findOwnedByKind(id: number, userId: number, kind: string): Promise<McpTokenIdRow | null> {
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;
}
@@ -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<Migrations> {}
export class MigrationsRepository extends TrekRepository<Migrations> {}
@@ -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<NotificationChannelPreferences> {}
export class NotificationChannelPreferencesRepository extends TrekRepository<NotificationChannelPreferences> {}
@@ -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<Notifications> {}
export class NotificationsRepository extends TrekRepository<Notifications> {}
@@ -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<OauthClients> {
export class OauthClientsRepository extends TrekRepository<OauthClients> {
// ---------------------------------------------------------------------
// OA1 — self-service client list
// ---------------------------------------------------------------------
@@ -129,7 +131,6 @@ export class OauthClientsRepository extends EntityRepository<OauthClients> {
{
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<OauthClients> {
* does too, never relying on a DB-generated default.
*/
async insertClient(row: NewOauthClientRow): Promise<OauthClientPublicRow> {
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<OauthClients> {
{ 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<OauthClients> {
async findOwned(id: string, userId: number): Promise<OauthClientOwnedRow | null> {
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<OauthClients> {
async findSdkProjection(clientId: string): Promise<OauthClientSdkRow | null> {
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<OauthClients> {
async findAuthRow(clientId: string): Promise<OauthClientAuthRow | null> {
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<OauthClients> {
* projections).
*/
async findByClientIdFull(clientId: string): Promise<OauthClientRow | null> {
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;
}
}
@@ -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<OauthConsentRow, OauthConsents> = 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<OauthConsents> {
export class OauthConsentsRepository extends TrekRepository<OauthConsents> {
// ---------------------------------------------------------------------
// OA11 — consent-sufficiency read
// ---------------------------------------------------------------------
@@ -33,7 +54,7 @@ export class OauthConsentsRepository extends EntityRepository<OauthConsents> {
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<OauthConsents> {
*/
async upsertGrant(clientId: string, userId: number, scopes: string): Promise<void> {
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' },
);
@@ -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<OauthTokenScalarColumns, OauthTokens
* OA8/OA13/OA14/OA16-OA20/OA22-OA28/OA31-OA33 — the busiest table in this
* domain. Every row-out read passes `disableIdentityMap: true` (program
* RULING, Task 1 review B1 — see `Users.repository.ts`'s class-level
* docstring). No method here hashes, compares or generates a token —
* docstring; applied by the base class's default since Plan 3b interlude B,
* `_shared/trek-repository.ts`). No method here hashes, compares or generates a token —
* `OauthService` does (`hashToken`/`generateAccessToken`/…), this
* repository only ever sees an already-computed hash string.
*
@@ -161,7 +162,7 @@ const _oauthTokenScalarProbe: AssertRowKeys<OauthTokenScalarColumns, OauthTokens
* OAUTHTOKREPO-030) — and `listActiveByUser`/`listAllActiveWithClientAndUser`
* below use the QueryBuilder like every other join in this file.
*/
export class OauthTokensRepository extends EntityRepository<OauthTokens> {
export class OauthTokensRepository extends TrekRepository<OauthTokens> {
// ---------------------------------------------------------------------
// OA13 / OA14 — token issuance
// ---------------------------------------------------------------------
@@ -185,7 +186,7 @@ export class OauthTokensRepository extends EntityRepository<OauthTokens> {
* ::insertToken` avoid.
*/
async insertToken(row: NewOauthTokenRow): Promise<void> {
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<OauthTokens> {
* the mirror directly leaves it `undefined`.
*/
async findParent(id: number): Promise<OauthTokenParentRow | null> {
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<OauthTokens> {
* 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<number[]> {
const rows = await this.getEntityManager()
.getKysely<OauthTokensKyselyDB>()
const rows = await this.kysely<OauthTokensKyselyDB>()
.withRecursive('chain', (db) =>
db.selectFrom('oauth_tokens')
.select('id')
@@ -383,7 +387,7 @@ export class OauthTokensRepository extends EntityRepository<OauthTokens> {
* 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<OauthTokens> {
async findByRefreshTokenHash(hash: string): Promise<OauthTokenRefreshRow | null> {
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<OauthTokens> {
async findByAccessOrRefreshHashAndClient(hash: string, clientId: string): Promise<OauthTokenUserIdRow | null> {
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<OauthTokens> {
/** `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<OauthTokenOwnedRow | null> {
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<OauthTokens> {
/** `SELECT id, user_id, client_id FROM oauth_tokens WHERE id = ?` — unscoped (admin), distinct from `findOwnedById` (OA27). */
async findById(id: number): Promise<OauthTokenAdminRow | null> {
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;
}
}
@@ -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<PackingBags> {}
export class PackingBagsRepository extends TrekRepository<PackingBags> {}
@@ -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<PackingCategoryAssignees> {}
export class PackingCategoryAssigneesRepository extends TrekRepository<PackingCategoryAssignees> {}
@@ -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<PackingItemContributors> {}
export class PackingItemContributorsRepository extends TrekRepository<PackingItemContributors> {}
@@ -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<PackingItems> {}
export class PackingItemsRepository extends TrekRepository<PackingItems> {}
@@ -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<PackingTemplateCategories> {}
export class PackingTemplateCategoriesRepository extends TrekRepository<PackingTemplateCategories> {}
@@ -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<PackingTemplateItems> {}
export class PackingTemplateItemsRepository extends TrekRepository<PackingTemplateItems> {}
@@ -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<PackingTemplates> {}
export class PackingTemplatesRepository extends TrekRepository<PackingTemplates> {}
@@ -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<PasswordResetTokens> {}
export class PasswordResetTokensRepository extends TrekRepository<PasswordResetTokens> {}
@@ -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<PhotoProviderFieldRow, PhotoProviderFields> = true;
export class PhotoProviderFieldsRepository extends EntityRepository<PhotoProviderFields> {
export class PhotoProviderFieldsRepository extends TrekRepository<PhotoProviderFields> {
/**
* `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<PhotoProvide
* precedent): reading it back after `find()` needs no extra populate, only
* a `create()`/twin write would.
*
* `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 listAllOrdered(): Promise<PhotoProviderFieldRow[]> {
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);
}
}
@@ -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<PhotoProviderRow, PhotoProviders> = true;
export class PhotoProvidersRepository extends EntityRepository<PhotoProviders> {
export class PhotoProvidersRepository extends TrekRepository<PhotoProviders> {
/**
* `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<PhotoProviders> {
* 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<PhotoProviderRow[]> {
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);
}
}
@@ -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<Photos> {}
export class PhotosRepository extends TrekRepository<Photos> {}
@@ -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<PlaceDetailsCache> {}
export class PlaceDetailsCacheRepository extends TrekRepository<PlaceDetailsCache> {}
@@ -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<PlaceRatings> {}
export class PlaceRatingsRepository extends TrekRepository<PlaceRatings> {}
@@ -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<PlaceRegions> {}
export class PlaceRegionsRepository extends TrekRepository<PlaceRegions> {}
@@ -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<PlaceShadowPicks> {}
export class PlaceShadowPicksRepository extends TrekRepository<PlaceShadowPicks> {}
@@ -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<Places> {}
export class PlacesRepository extends TrekRepository<Places> {}
@@ -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<PluginActions> {}
export class PluginActionsRepository extends TrekRepository<PluginActions> {}
@@ -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<PluginCapabilityAudit> {}
export class PluginCapabilityAuditRepository extends TrekRepository<PluginCapabilityAudit> {}
@@ -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<PluginEgressHosts> {}
export class PluginEgressHostsRepository extends TrekRepository<PluginEgressHosts> {}
@@ -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<PluginEntityMetadata> {}
export class PluginEntityMetadataRepository extends TrekRepository<PluginEntityMetadata> {}
@@ -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<PluginErrorLog> {}
export class PluginErrorLogRepository extends TrekRepository<PluginErrorLog> {}
@@ -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<PluginMetaMigrations> {}
export class PluginMetaMigrationsRepository extends TrekRepository<PluginMetaMigrations> {}
@@ -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<PluginOauthState> {}
export class PluginOauthStateRepository extends TrekRepository<PluginOauthState> {}
@@ -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<PluginOauthTokens> {}
export class PluginOauthTokensRepository extends TrekRepository<PluginOauthTokens> {}
@@ -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<PluginScheduledTasks> {}
export class PluginScheduledTasksRepository extends TrekRepository<PluginScheduledTasks> {}
@@ -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<PluginSettingsFields> {}
export class PluginSettingsFieldsRepository extends TrekRepository<PluginSettingsFields> {}
@@ -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<PluginUserConfig> {}
export class PluginUserConfigRepository extends TrekRepository<PluginUserConfig> {}
@@ -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<PluginUserErasureQueue> {}
export class PluginUserErasureQueueRepository extends TrekRepository<PluginUserErasureQueue> {}
@@ -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<Plugins> {}
export class PluginsRepository extends TrekRepository<Plugins> {}
@@ -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<ReservationDayPositions> {}
export class ReservationDayPositionsRepository extends TrekRepository<ReservationDayPositions> {}
@@ -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<ReservationEndpoints> {}
export class ReservationEndpointsRepository extends TrekRepository<ReservationEndpoints> {}
@@ -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<ReservationTravelers> {}
export class ReservationTravelersRepository extends TrekRepository<ReservationTravelers> {}
@@ -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<Reservations> {}
export class ReservationsRepository extends TrekRepository<Reservations> {}
@@ -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<RoadtripDayBoundaries> {}
export class RoadtripDayBoundariesRepository extends TrekRepository<RoadtripDayBoundaries> {}
@@ -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<RoadtripDayTracks> {}
export class RoadtripDayTracksRepository extends TrekRepository<RoadtripDayTracks> {}
@@ -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<RoadtripPreferences> {}
export class RoadtripPreferencesRepository extends TrekRepository<RoadtripPreferences> {}
@@ -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<RoadtripVias> {}
export class RoadtripViasRepository extends TrekRepository<RoadtripVias> {}
@@ -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<RouteUsageDaily> {}
export class RouteUsageDailyRepository extends TrekRepository<RouteUsageDaily> {}
@@ -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<SchemaVersion> {}
export class SchemaVersionRepository extends TrekRepository<SchemaVersion> {}
@@ -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<SchoolHolidayCountries> {}
export class SchoolHolidayCountriesRepository extends TrekRepository<SchoolHolidayCountries> {}
@@ -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<SchoolHolidayPeriods> {}
export class SchoolHolidayPeriodsRepository extends TrekRepository<SchoolHolidayPeriods> {}
@@ -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<SchoolHolidayRegions> {}
export class SchoolHolidayRegionsRepository extends TrekRepository<SchoolHolidayRegions> {}
@@ -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<SettingRow, Settings> = true;
export class SettingsRepository extends EntityRepository<Settings> {
export class SettingsRepository extends TrekRepository<Settings> {
/**
* `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<SettingRow[]> {
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<Settings> {
* sees it) rather than assume it.
*/
async getOne(userId: number, key: string): Promise<SettingRow | null> {
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;
}
@@ -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<ShareTokens> {}
export class ShareTokensRepository extends TrekRepository<ShareTokens> {}
+52 -54
View File
@@ -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<TagRow, Tags> = true;
export class TagsRepository extends EntityRepository<Tags> {
export class TagsRepository extends TrekRepository<Tags> {
/**
* `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<Tags> {
* 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<TagRow | null> {
const tag = await findOwnedByUser<Tags, 'user'>(this, id, 'user', userId);
@@ -47,69 +48,66 @@ export class TagsRepository extends EntityRepository<Tags> {
* `#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<TagRow> {
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<TagRow | null> {
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. */
@@ -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<TodoCategoryAssignees> {}
export class TodoCategoryAssigneesRepository extends TrekRepository<TodoCategoryAssignees> {}
@@ -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<TodoItems> {}
export class TodoItemsRepository extends TrekRepository<TodoItems> {}

Some files were not shown because too many files have changed in this diff Show More