mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
fix(db): row-out repository reads bypass the identity map — stale write-back over nativeUpdate at flush; insertUser via em.insert; settings round-trip pinned
This commit is contained in:
@@ -20,14 +20,17 @@ export class AddonsRepository extends EntityRepository<Addons> {
|
||||
/**
|
||||
* `SELECT enabled FROM addons WHERE id = ?`
|
||||
*
|
||||
* `refresh: true` (Task 0 review, I1 — carried forward for every PK read in
|
||||
* this plan): a primary-key `findOne` answers a repeat call from the
|
||||
* identity map, invisible to a write on the same id inside the same
|
||||
* request (`admin.service.ts`'s still-raw `UPDATE addons SET enabled = ?`
|
||||
* on the same connection).
|
||||
* `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
|
||||
* `.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
|
||||
* same request (`admin.service.ts`'s still-raw `UPDATE addons SET enabled
|
||||
* = ?` on the same connection), and the entity never lands in the
|
||||
* request's identity map.
|
||||
*/
|
||||
async isEnabled(id: string): Promise<boolean> {
|
||||
const row = await this.findOne({ id }, { fields: ['enabled'], refresh: true });
|
||||
const row = await this.findOne({ id }, { fields: ['enabled'], disableIdentityMap: true });
|
||||
return !!row?.enabled;
|
||||
}
|
||||
|
||||
@@ -40,7 +43,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' } });
|
||||
const rows = await this.find({ enabled: true }, { orderBy: { sort_order: 'asc' }, disableIdentityMap: true });
|
||||
return rows.map((row) => toRow(row) as AddonRow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,19 +14,17 @@ export class AppSettingsRepository extends EntityRepository<AppSettings> {
|
||||
/**
|
||||
* `SELECT value FROM app_settings WHERE key = ?`
|
||||
*
|
||||
* `refresh: true` (Task 0 review, I1): this is a primary-key lookup, so
|
||||
* without it MikroORM answers a repeat call from the identity map rather
|
||||
* than re-querying — invisible to a raw/native write or `deleteValue` on
|
||||
* the same key inside the same request, which is exactly the coexistence
|
||||
* case this plan is built on (raw SQL and the ORM sharing one connection
|
||||
* while callers convert one at a time). `refresh` keeps this a single
|
||||
* query either way; only a stale identity-map hit is avoided.
|
||||
* Side effect of `refresh`: an UNFLUSHED in-memory change to the selected
|
||||
* field on that entity is discarded (the entity reverts to the row). No
|
||||
* caller mutates these entities before reading, by design (D4: rows out).
|
||||
* `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 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,
|
||||
* so a raw/native write or `deleteValue` on the same key inside the same
|
||||
* request is always seen, and the entity never lands in the request's
|
||||
* 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'], refresh: true });
|
||||
const row = await this.findOne({ key }, { fields: ['value'], disableIdentityMap: true });
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
@@ -37,7 +35,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 } });
|
||||
const rows = await this.find({ key: { $in: keys } }, { disableIdentityMap: true });
|
||||
const values = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
if (row.key != null && row.value != null) values.set(row.key, row.value);
|
||||
@@ -82,7 +80,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}%` } });
|
||||
const rows = await this.find({ key: { $like: `${prefix}%` } }, { disableIdentityMap: true });
|
||||
return rows.map((row) => toRow(row) as AppSettingsRow);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const _categoryRowKeys: AssertRowKeys<CategoryRow, Categories> = true;
|
||||
export class CategoriesRepository extends EntityRepository<Categories> {
|
||||
/** `SELECT * FROM categories ORDER BY name ASC` */
|
||||
async list(): Promise<CategoryRow[]> {
|
||||
const categories = await this.find({}, { orderBy: { name: 'asc' } });
|
||||
const categories = await this.find({}, { orderBy: { name: 'asc' }, disableIdentityMap: true });
|
||||
return categories.map((category) => toRow(category) as CategoryRow);
|
||||
}
|
||||
|
||||
@@ -30,19 +30,18 @@ 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).
|
||||
*
|
||||
* `refresh: true` (Task 0 review, I1 — every PK-only `findOne` in a
|
||||
* repository takes it) and it is load-bearing here, not decorative: `{ id
|
||||
* }` is exactly the primary key, so MikroORM answers this call from the
|
||||
* identity map with zero queries whenever the row is already loaded
|
||||
* (Task 3 review, Important 2's correction — the short-circuit is driven
|
||||
* by the filter being PK-only, not by a `fields` restriction, which was
|
||||
* the wrong mechanism this docstring stated before the fix). Without
|
||||
* `refresh`, a raw write to this row earlier in the same request would
|
||||
* make this method return the stale snapshot instead of the current DB
|
||||
* row.
|
||||
* `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
|
||||
* `.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
|
||||
* identity map, so a raw write to this row earlier in the same request is
|
||||
* always seen, and there is nothing here for a later `flush()` to find
|
||||
* dirty.
|
||||
*/
|
||||
async findById(id: number): Promise<CategoryRow | null> {
|
||||
const category = await this.findOne({ id }, { refresh: true });
|
||||
const category = await this.findOne({ id }, { disableIdentityMap: true });
|
||||
return category ? (toRow(category) as CategoryRow) : null;
|
||||
}
|
||||
|
||||
@@ -103,6 +102,16 @@ export class CategoriesRepository extends EntityRepository<Categories> {
|
||||
* 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.
|
||||
*/
|
||||
async patch(id: number, changes: { name?: string; color?: string; icon?: string }): Promise<CategoryRow | null> {
|
||||
const category = await this.findOne({ id }, { refresh: true });
|
||||
|
||||
@@ -28,9 +28,12 @@ export class PhotoProviderFieldsRepository extends EntityRepository<PhotoProvide
|
||||
* `persist(false)` twin of the `provider` relation (Days.trip_id
|
||||
* 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"
|
||||
* read, converted via `toRow` and discarded.
|
||||
*/
|
||||
async listAllOrdered(): Promise<PhotoProviderFieldRow[]> {
|
||||
const rows = await this.find({}, { orderBy: { sort_order: 'asc', id: 'asc' } });
|
||||
const rows = await this.find({}, { orderBy: { sort_order: 'asc', id: 'asc' }, disableIdentityMap: true });
|
||||
return rows.map((row) => toRow(row) as PhotoProviderFieldRow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,12 @@ export class PhotoProvidersRepository extends EntityRepository<PhotoProviders> {
|
||||
* entity (unlike `Addons.enabled`, which is `p.boolean()`), so it comes
|
||||
* 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"
|
||||
* read, converted via `toRow` and discarded.
|
||||
*/
|
||||
async listEnabled(): Promise<PhotoProviderRow[]> {
|
||||
const rows = await this.find({ enabled: 1 }, { orderBy: { sort_order: 'asc', id: 'asc' } });
|
||||
const rows = await this.find({ enabled: 1 }, { orderBy: { sort_order: 'asc', id: 'asc' }, disableIdentityMap: true });
|
||||
return rows.map((row) => toRow(row) as PhotoProviderRow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,28 +13,33 @@ export interface SettingRow {
|
||||
const _settingRowKeys: AssertRowKeys<SettingRow, Settings> = true;
|
||||
|
||||
export class SettingsRepository extends EntityRepository<Settings> {
|
||||
/** `SELECT key, value FROM settings WHERE user_id = ?` */
|
||||
/**
|
||||
* `SELECT key, value FROM settings WHERE user_id = ?`
|
||||
*
|
||||
* `disableIdentityMap: true` (Plan 3b Task 1 fix round) — a "rows out"
|
||||
* read, converted via `toRow` and discarded.
|
||||
*/
|
||||
async getForUser(userId: number): Promise<SettingRow[]> {
|
||||
const rows = await this.find({ user: userId });
|
||||
const rows = await this.find({ user: userId }, { disableIdentityMap: true });
|
||||
return rows.map((row) => toRow(row) as SettingRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELECT value FROM settings WHERE user_id = ? AND key = ?`
|
||||
*
|
||||
* No `refresh: true`: the identity-map short-circuit (Task 0 review, I1;
|
||||
* corrected by the Task 3 review — it is NOT about a `fields` restriction)
|
||||
* only fires for a PRIMARY-KEY-ONLY filter (`findOne({ id })` / `findOne(pk)`):
|
||||
* MikroORM answers that shape straight from the identity map with zero
|
||||
* queries, invisible to a write on the same row earlier in the request.
|
||||
* This filter is `(user, key)` — `Settings`'s PK is the surrogate `id`, not
|
||||
* this composite — so it is never PK-only and always re-queries, the same
|
||||
* guarantee a `find()` call gets. `SETTINGSREPO-012`/`013` pin this
|
||||
* directly (a raw UPDATE/DELETE on the same row, then `getOne` in the same
|
||||
* request, sees it) rather than assume it.
|
||||
* `disableIdentityMap: true` (Plan 3b Task 1 fix round, 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
|
||||
* before this ruling; `disableIdentityMap: true` is applied uniformly
|
||||
* regardless, matching every other read in this repository.
|
||||
* `SETTINGSREPO-012`/`013` pin the always-fresh-read property directly (a
|
||||
* raw UPDATE/DELETE on the same row, then `getOne` in the same request,
|
||||
* sees it) rather than assume it.
|
||||
*/
|
||||
async getOne(userId: number, key: string): Promise<SettingRow | null> {
|
||||
const row = await this.findOne({ user: userId, key });
|
||||
const row = await this.findOne({ user: userId, key }, { disableIdentityMap: true });
|
||||
return row ? (toRow(row) as SettingRow) : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,21 +32,12 @@ export class TagsRepository extends EntityRepository<Tags> {
|
||||
* own write (`createTag`/`patch` refresh/return their own entity
|
||||
* directly).
|
||||
*
|
||||
* `refresh: true` (Task 0 review, I1 — the ruling every Plan 3 repository
|
||||
* follows for a PK-scoped read). This call's filter is `{ id, user }`,
|
||||
* not PK-only, so MikroORM's identity-map short-circuit — which fires
|
||||
* only when the filter is exactly the primary key, not (as an earlier
|
||||
* version of this docstring said) when a `fields` restriction is present
|
||||
* (Task 3 review, Important 2's correction) — never engages here anyway:
|
||||
* this call already re-queries and merges fresh data on every call
|
||||
* regardless of `refresh`. `refresh` stays threaded through to match the
|
||||
* blanket ruling and to stay correct if a future caller narrows the
|
||||
* filter to PK-only. `findOwnedByUser`'s optional `options` parameter
|
||||
* (added here, not duplicated — Task 0's helper had no reads that needed
|
||||
* it yet) passes straight through to the underlying `findOne`.
|
||||
* `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.
|
||||
*/
|
||||
async findByIdAndUser(id: number, userId: number): Promise<TagRow | null> {
|
||||
const tag = await findOwnedByUser<Tags, 'user'>(this, id, 'user', userId, { refresh: true });
|
||||
const tag = await findOwnedByUser<Tags, 'user'>(this, id, 'user', userId);
|
||||
return tag ? (toRow(tag) as TagRow) : null;
|
||||
}
|
||||
|
||||
@@ -98,6 +89,20 @@ export class TagsRepository extends EntityRepository<Tags> {
|
||||
* `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`).
|
||||
*/
|
||||
async patch(id: number, changes: { name?: string; color?: string }): Promise<TagRow | null> {
|
||||
const tag = await this.findOne({ id }, { refresh: true });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Users } from '../entities/Users.entity';
|
||||
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';
|
||||
@@ -177,18 +177,23 @@ export interface ResetTargetRow {
|
||||
|
||||
export class UsersRepository extends EntityRepository<Users> {
|
||||
/**
|
||||
* `SELECT email FROM users WHERE id = ?`
|
||||
*
|
||||
* `refresh: true` (Task 0 review, I1): a primary-key `findOne` is answered
|
||||
* from the identity map on a repeat call, which would hide a raw/native
|
||||
* `UPDATE users SET email = ...` on the same id inside the same request.
|
||||
* `refresh` keeps this a single query and always sees the current row.
|
||||
* Side effect of `refresh`: an UNFLUSHED in-memory change to the selected
|
||||
* field on that entity is discarded (the entity reverts to the row). No
|
||||
* caller mutates these entities before reading, by design (D4: rows out).
|
||||
* **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
|
||||
* repository (`findOne`/`find`) passes `disableIdentityMap: true` instead.
|
||||
* MikroORM forks the EM with `keepTransactionContext: true`, loads inside
|
||||
* that fork (so the read still sees the transaction's own uncommitted
|
||||
* writes), and clears the fork afterwards
|
||||
* (`node_modules/@mikro-orm/core/EntityManager.js:807-814`) — the entity
|
||||
* is never added to the *request's* identity map, so it can never become a
|
||||
* stale pending change-set entry that a later `flush()` (including the
|
||||
* implicit one at `UnitOfWork.transactional` commit) writes back over an
|
||||
* intervening `nativeUpdate`. `refresh: true` is dropped as redundant: it
|
||||
* 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).
|
||||
*/
|
||||
async getEmail(userId: number): Promise<string | null> {
|
||||
const row = await this.findOne({ id: userId }, { fields: ['email'], refresh: true });
|
||||
const row = await this.findOne({ id: userId }, { fields: ['email'], disableIdentityMap: true });
|
||||
return row?.email ?? null;
|
||||
}
|
||||
|
||||
@@ -198,10 +203,10 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
* not an interpolated column — MikroORM's `fields` option only accepts a
|
||||
* real property of `Users`, so this can never select an arbitrary column.
|
||||
*
|
||||
* `refresh: true` for the same identity-map reason as `getEmail` above.
|
||||
* `disableIdentityMap: true` per the class-level ruling above.
|
||||
*/
|
||||
async getApiKeyColumn(userId: number, name: InstanceApiKeyName): Promise<string | null> {
|
||||
const row = await this.findOne({ id: userId }, { fields: [name], refresh: true });
|
||||
const row = await this.findOne({ id: userId }, { fields: [name], disableIdentityMap: true });
|
||||
return row ? (row[name] ?? null) : null;
|
||||
}
|
||||
|
||||
@@ -214,12 +219,12 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
*
|
||||
* The canonical session-invalidation read: `verifyJwtAndLoadUser` compares
|
||||
* the returned `password_version` against the JWT's `pv` claim on every
|
||||
* request. PK-only `findOne` → `refresh: true` (I1).
|
||||
* request. `disableIdentityMap: true` per the class-level ruling above.
|
||||
*/
|
||||
async findByIdWithPasswordVersion(id: number): Promise<UserWithPasswordVersion | null> {
|
||||
const row = await this.findOne(
|
||||
{ id },
|
||||
{ fields: ['id', 'username', 'email', 'role', 'password_version'], refresh: true },
|
||||
{ fields: ['id', 'username', 'email', 'role', 'password_version'], disableIdentityMap: true },
|
||||
);
|
||||
return row ? { id: row.id, username: row.username, email: row.email, role: row.role, password_version: row.password_version } : null;
|
||||
}
|
||||
@@ -230,7 +235,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
|
||||
/** `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<number | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['password_version'], refresh: true });
|
||||
const row = await this.findOne({ id }, { fields: ['password_version'], disableIdentityMap: true });
|
||||
return row?.password_version ?? null;
|
||||
}
|
||||
|
||||
@@ -249,7 +254,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
|
||||
/** `SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1`. */
|
||||
async findAdminNeedingPasswordChange(): Promise<number | null> {
|
||||
const row = await this.findOne({ role: 'admin', must_change_password: 1 }, { fields: ['id'] });
|
||||
const row = await this.findOne({ role: 'admin', must_change_password: 1 }, { fields: ['id'], disableIdentityMap: true });
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
@@ -268,7 +273,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
$or: [{ [lower(platform, 'email')]: email.toLowerCase() }, { [lower(platform, 'username')]: username.toLowerCase() }],
|
||||
is_guest: 0,
|
||||
},
|
||||
{ fields: ['id'] },
|
||||
{ fields: ['id'], disableIdentityMap: true },
|
||||
);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
@@ -287,12 +292,19 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
* always the literal `0` on insert, never a parameter, matching both
|
||||
* statements exactly.
|
||||
*
|
||||
* Followed by `refresh` (D4's create rule), not relied-on `RETURNING`:
|
||||
* the returned row carries the generated `id` and the `defaultRaw`
|
||||
* `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
|
||||
* 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`
|
||||
* `created_at` the same way a legacy INSERT-then-reselect pair did.
|
||||
*/
|
||||
async insertUser(row: NewUserRow): Promise<UserRow> {
|
||||
const user = this.create({
|
||||
const id = await this.getEntityManager().insert(Users, {
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
password_hash: row.password_hash,
|
||||
@@ -303,9 +315,11 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
oidc_issuer: row.oidc_issuer ?? null,
|
||||
avatar: row.avatar ?? null,
|
||||
});
|
||||
await this.getEntityManager().persist(user).flush();
|
||||
await this.getEntityManager().refresh(user);
|
||||
return toRow(user) as UserRow;
|
||||
const inserted = await this.findById(id as number);
|
||||
if (!inserted) {
|
||||
throw new Error('insertUser: read-back after insert found no row');
|
||||
}
|
||||
return inserted;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -315,7 +329,27 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
/** `SELECT * FROM users WHERE LOWER(email) = LOWER(?) AND COALESCE(is_guest, 0) = 0` */
|
||||
async findByEmailCI(email: string): Promise<UserRow | null> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const user = await this.findOne({ [lower(platform, 'email')]: email.toLowerCase(), is_guest: 0 });
|
||||
const user = await this.findOne(
|
||||
{ [lower(platform, 'email')]: email.toLowerCase(), is_guest: 0 },
|
||||
{ disableIdentityMap: true },
|
||||
);
|
||||
return user ? (toRow(user) as UserRow) : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AuthService.demoLogin (auth.service.ts:~376) — inventory-correction
|
||||
// addition, Plan 3b Task 1 fix round item 7 (pre-authorised for Task 5).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `SELECT * FROM users WHERE email = ?` — case-**sensitive**, **no** guest
|
||||
* filter, full row. Distinct from both `findByEmailCI` (case-insensitive
|
||||
* + guest-filtered) and `findForPasswordReset` (case-sensitive but
|
||||
* projected + guest-filtered): `AuthService.demoLogin` needs the exact,
|
||||
* unfiltered legacy statement, including matching a guest row.
|
||||
*/
|
||||
async findByEmailExact(email: string): Promise<UserRow | null> {
|
||||
const user = await this.findOne({ email }, { disableIdentityMap: true });
|
||||
return user ? (toRow(user) as UserRow) : null;
|
||||
}
|
||||
|
||||
@@ -350,7 +384,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
{ id },
|
||||
{
|
||||
fields: ['id', 'username', 'email', 'role', 'avatar', 'oidc_issuer', 'created_at', 'mfa_enabled', 'must_change_password'],
|
||||
refresh: true,
|
||||
disableIdentityMap: true,
|
||||
},
|
||||
);
|
||||
return row
|
||||
@@ -374,7 +408,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
|
||||
/** `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'], refresh: true });
|
||||
const row = await this.findOne({ id }, { fields: ['password_hash', 'password_version'], disableIdentityMap: true });
|
||||
return row ? { password_hash: row.password_hash, password_version: row.password_version } : null;
|
||||
}
|
||||
|
||||
@@ -410,7 +444,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
|
||||
/** `SELECT role FROM users WHERE id = ?` */
|
||||
async getRole(id: number): Promise<string | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['role'], refresh: true });
|
||||
const row = await this.findOne({ id }, { fields: ['role'], disableIdentityMap: true });
|
||||
return row?.role ?? null;
|
||||
}
|
||||
|
||||
@@ -430,7 +464,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
* 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'], refresh: true });
|
||||
const row = await this.findOne({ id }, { fields: ['mfa_enabled'], disableIdentityMap: true });
|
||||
return row ? { mfa_enabled: row.mfa_enabled ?? null } : null;
|
||||
}
|
||||
|
||||
@@ -460,10 +494,10 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
* caller that needs more than a handful of columns (AU30 `disableMfa`,
|
||||
* AU32 `verifyMfaLogin`, PK4 `passkeyRegisterOptions`, PK10
|
||||
* `passkeyLoginVerify`, O15 `findOrCreateUser`'s post-transaction re-select).
|
||||
* PK-only `findOne` → `refresh: true` (I1).
|
||||
* `disableIdentityMap: true` per the class-level ruling above.
|
||||
*/
|
||||
async findById(id: number): Promise<UserRow | null> {
|
||||
const user = await this.findOne({ id }, { refresh: true });
|
||||
const user = await this.findOne({ id }, { disableIdentityMap: true });
|
||||
return user ? (toRow(user) as UserRow) : null;
|
||||
}
|
||||
|
||||
@@ -518,7 +552,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
async findForPasswordReset(email: string): Promise<PasswordResetLookup | null> {
|
||||
const row = await this.findOne(
|
||||
{ email, is_guest: 0 },
|
||||
{ fields: ['id', 'email', 'password_hash', 'oidc_sub'] },
|
||||
{ fields: ['id', 'email', 'password_hash', 'oidc_sub'], disableIdentityMap: true },
|
||||
);
|
||||
return row ? { id: row.id, email: row.email, password_hash: row.password_hash, oidc_sub: row.oidc_sub ?? null } : null;
|
||||
}
|
||||
@@ -531,7 +565,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
async findResetTarget(id: number): Promise<ResetTargetRow | null> {
|
||||
const row = await this.findOne(
|
||||
{ id },
|
||||
{ fields: ['id', 'email', 'mfa_enabled', 'mfa_secret', 'mfa_backup_codes', 'password_version'], refresh: true },
|
||||
{ fields: ['id', 'email', 'mfa_enabled', 'mfa_secret', 'mfa_backup_codes', 'password_version'], disableIdentityMap: true },
|
||||
);
|
||||
return row
|
||||
? {
|
||||
@@ -551,7 +585,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
|
||||
/** `SELECT password_hash FROM users WHERE id = ?` */
|
||||
async getPasswordHash(id: number): Promise<string | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['password_hash'], refresh: true });
|
||||
const row = await this.findOne({ id }, { fields: ['password_hash'], disableIdentityMap: true });
|
||||
return row?.password_hash ?? null;
|
||||
}
|
||||
|
||||
@@ -561,7 +595,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
|
||||
/** `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'], refresh: true });
|
||||
const row = await this.findOne({ id }, { fields: ['id', 'email'], disableIdentityMap: true });
|
||||
return row ? { id: row.id, email: row.email } : null;
|
||||
}
|
||||
|
||||
@@ -571,7 +605,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
|
||||
/** `SELECT * FROM users WHERE oidc_sub = ? AND oidc_issuer = ?` */
|
||||
async findByOidcIdentity(sub: string, issuer: string): Promise<UserRow | null> {
|
||||
const user = await this.findOne({ oidc_sub: sub, oidc_issuer: issuer });
|
||||
const user = await this.findOne({ oidc_sub: sub, oidc_issuer: issuer }, { disableIdentityMap: true });
|
||||
return user ? (toRow(user) as UserRow) : null;
|
||||
}
|
||||
|
||||
@@ -624,7 +658,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const row = await this.findOne(
|
||||
{ [lower(platform, 'username')]: username.toLowerCase(), id: { $ne: excludeId }, is_guest: 0 },
|
||||
{ fields: ['id'] },
|
||||
{ fields: ['id'], disableIdentityMap: true },
|
||||
);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
@@ -638,7 +672,10 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
*/
|
||||
async findIdByUsernameCIAny(username: string): Promise<number | null> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const row = await this.findOne({ [lower(platform, 'username')]: username.toLowerCase() }, { fields: ['id'] });
|
||||
const row = await this.findOne(
|
||||
{ [lower(platform, 'username')]: username.toLowerCase() },
|
||||
{ fields: ['id'], disableIdentityMap: true },
|
||||
);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
@@ -650,7 +687,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
async getApiKeyColumns(id: number): Promise<UserApiKeyColumns | null> {
|
||||
const row = await this.findOne(
|
||||
{ id },
|
||||
{ fields: ['role', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'amap_api_key'], refresh: true },
|
||||
{ fields: ['role', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'amap_api_key'], disableIdentityMap: true },
|
||||
);
|
||||
return row
|
||||
? {
|
||||
@@ -700,7 +737,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
{ id },
|
||||
{
|
||||
fields: ['id', 'username', 'email', 'role', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'amap_api_key', 'avatar', 'mfa_enabled'],
|
||||
refresh: true,
|
||||
disableIdentityMap: true,
|
||||
},
|
||||
);
|
||||
return row
|
||||
@@ -728,7 +765,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const row = await this.findOne(
|
||||
{ [lower(platform, 'email')]: email.toLowerCase(), id: { $ne: excludeId }, is_guest: 0 },
|
||||
{ fields: ['id'] },
|
||||
{ fields: ['id'], disableIdentityMap: true },
|
||||
);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
@@ -761,7 +798,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
|
||||
/** `SELECT avatar FROM users WHERE id = ?` */
|
||||
async getAvatar(id: number): Promise<string | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['avatar'], refresh: true });
|
||||
const row = await this.findOne({ id }, { fields: ['avatar'], disableIdentityMap: true });
|
||||
return row?.avatar ?? null;
|
||||
}
|
||||
|
||||
@@ -785,7 +822,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
|
||||
/** `SELECT id, username, email, role, avatar FROM users WHERE id = ?` */
|
||||
async findProfileBasic(id: number): Promise<UserProfileBasic | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['id', 'username', 'email', 'role', 'avatar'], refresh: true });
|
||||
const row = await this.findOne({ id }, { fields: ['id', 'username', 'email', 'role', 'avatar'], disableIdentityMap: true });
|
||||
return row ? { id: row.id, username: row.username, email: row.email, role: row.role, avatar: row.avatar ?? null } : null;
|
||||
}
|
||||
|
||||
@@ -797,7 +834,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
async listOthersNonGuest(excludeId: number): Promise<UserDirectoryRow[]> {
|
||||
const rows = await this.find(
|
||||
{ id: { $ne: excludeId }, is_guest: 0 },
|
||||
{ fields: ['id', 'username', 'avatar'], orderBy: { username: 'asc' } },
|
||||
{ fields: ['id', 'username', 'avatar'], orderBy: { username: 'asc' }, disableIdentityMap: true },
|
||||
);
|
||||
return rows.map((row) => ({ id: row.id, username: row.username, avatar: row.avatar ?? null }));
|
||||
}
|
||||
@@ -808,7 +845,7 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
|
||||
/** `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'], refresh: true });
|
||||
const row = await this.findOne({ id }, { fields: ['role', 'openweather_api_key'], disableIdentityMap: true });
|
||||
return row ? { role: row.role, openweather_api_key: row.openweather_api_key ?? null } : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,13 @@ import type { EntityRepository, FilterQuery, FilterValue, OrderDefinition } from
|
||||
*
|
||||
* listForOwner<Tags, 'user', 'name'>(tags, 'user', userId, 'name');
|
||||
* findOwnedByUser<Tags, 'user'>(tags, id, 'user', userId);
|
||||
* findOwnedByUser<Tags, 'user'>(tags, id, 'user', userId, { refresh: true });
|
||||
*
|
||||
* Both helpers pass `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
|
||||
* `.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md` B1): both are
|
||||
* "rows out" reads whose entity is converted via `toRow` and discarded, never
|
||||
* assigned to nor flushed, so a throwaway forked context is exactly right.
|
||||
*
|
||||
* Typing (Task 0 review, I2): `T extends { id: unknown }` and `K extends
|
||||
* keyof T` tie `ownerField` to a real property of `T` and `ownerId` to
|
||||
@@ -64,6 +70,7 @@ export async function listForOwner<T extends { id: unknown }, K extends keyof T,
|
||||
): Promise<T[]> {
|
||||
return repo.find({ [ownerField]: ownerId } as FilterQuery<T>, {
|
||||
orderBy: { [orderField]: 'asc' } as OrderDefinition<T>,
|
||||
disableIdentityMap: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,24 +78,15 @@ export async function listForOwner<T extends { id: unknown }, K extends keyof T,
|
||||
* Find one row by id, strictly scoped to an owner:
|
||||
* `SELECT * FROM <table> WHERE id = ? AND <ownerField> = ?`.
|
||||
*
|
||||
* `options.refresh` passes straight through to the underlying `findOne`
|
||||
* (Task 0 review, I1's PK-read ruling: a caller whose read can follow a
|
||||
* raw/native write on the same row within the same request sets it). This
|
||||
* filter is `{ id, [ownerField]: ownerId }` — not PK-only — so MikroORM's
|
||||
* identity-map short-circuit never engages here: that short-circuit fires
|
||||
* only when the filter is exactly the primary key (Task 3 review, Important
|
||||
* 2's correction — `fields` has nothing to do with it, unlike what an
|
||||
* earlier version of this docstring claimed). `refresh` is a harmless
|
||||
* no-op on every call this helper serves today; it stays threaded through
|
||||
* to match the blanket ruling and to stay correct if a future caller
|
||||
* narrows the filter to PK-only.
|
||||
* `disableIdentityMap: true` per the module-level docstring above — this is
|
||||
* always a "rows out" read here, never a caller's staging read for
|
||||
* `assign`+`flush`.
|
||||
*/
|
||||
export async function findOwnedByUser<T extends { id: unknown }, K extends keyof T>(
|
||||
repo: EntityRepository<T>,
|
||||
id: T['id'],
|
||||
ownerField: K,
|
||||
ownerId: FilterValue<T[K]>,
|
||||
options?: { refresh?: boolean },
|
||||
): Promise<T | null> {
|
||||
return repo.findOne({ id, [ownerField]: ownerId } as FilterQuery<T>, options);
|
||||
return repo.findOne({ id, [ownerField]: ownerId } as FilterQuery<T>, { disableIdentityMap: true });
|
||||
}
|
||||
|
||||
@@ -27,9 +27,21 @@ import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
|
||||
* ORM owns `users` everywhere else too) — so no module needs new wiring.
|
||||
* `this.em.getRepository(Users)` inside `canActivate` is the same
|
||||
* `orm.em.getRepository(Users)`-inside-the-request pattern
|
||||
* `platform.routes.ts::servePhoto` uses (Task 0); `this.em` here is already
|
||||
* the request-scoped fork, since `canActivate` only ever runs inside a real
|
||||
* HTTP request Nest's `@mikro-orm/nestjs` middleware has already wrapped.
|
||||
* `platform.routes.ts::servePhoto` uses (Task 0).
|
||||
*
|
||||
* **Correction (Plan 3b Task 1 fix round, task-1-review.md's Ruling):**
|
||||
* `this.em` here is NOT a request-scoped fork — with no `scope` on
|
||||
* `MikroOrmModule.forRoot` and no `Scope.REQUEST` anywhere in `src/`,
|
||||
* `@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
|
||||
* reference to `this.em` with no `getContext()` call, and
|
||||
* `EntityRepository.findOne` calls `this.getEntityManager().findOne(...)`,
|
||||
* which resolves the live `AsyncLocalStorage`/`TransactionContext` inside
|
||||
* that call — so the repository built here still finds the request's own
|
||||
* fork correctly, even though `this.em` itself is the shared global
|
||||
* instance, not a per-request object.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
|
||||
@@ -79,6 +79,10 @@ export class MfaPolicyGuard implements CanActivate {
|
||||
// A user-verified passkey is phishing-resistant and inherently two-factor, so
|
||||
// owning at least one satisfies require_mfa exactly like TOTP does.
|
||||
// (All stored passkeys were registered with userVerification required.)
|
||||
// `=== 1`, not the legacy `row.mfa_enabled === 1 || row.mfa_enabled === true`:
|
||||
// `mfa_enabled` is a SQLite INTEGER column, and `UsersRepository.getMfaEnabled`
|
||||
// types it `number | null` — a JS `boolean` from this column never occurs
|
||||
// through the ORM, so the `=== true` half was dead code (task-1-review.md F4).
|
||||
const mfaOk = row.mfa_enabled === 1;
|
||||
const passkeyOk = await this.webauthnCredentials.hasAny(user.id);
|
||||
if (mfaOk || passkeyOk) return true;
|
||||
|
||||
@@ -72,6 +72,36 @@ describe('PROFILE-001 — Get current user profile', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/auth/me/settings — F3 (task-1-review.md): the B1 regression's
|
||||
// user-visible symptom was exactly this route answering 200 while silently
|
||||
// discarding the write. Asserts the DB row directly, not only the response
|
||||
// body, and the response body against a fresh GET /api/auth/me — either one
|
||||
// alone would have missed B1 (the response body came from the pre-flush
|
||||
// in-memory entity, which still looked right).
|
||||
describe('PUT /api/auth/me/settings (F3)', () => {
|
||||
it('PROFILE-016 — a username+email change persists: the response, a fresh GET, and the raw row all agree', async () => {
|
||||
const { user } = createUser(testDb, { username: 'before-name', email: 'before@example.test' });
|
||||
|
||||
const put = await request(app)
|
||||
.put('/api/auth/me/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ username: 'after-name', email: 'after@example.test' });
|
||||
expect(put.status).toBe(200);
|
||||
expect(put.body.success).toBe(true);
|
||||
expect(put.body.user).toMatchObject({ username: 'after-name', email: 'after@example.test' });
|
||||
|
||||
const get = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(get.status).toBe(200);
|
||||
expect(get.body.user).toMatchObject({ username: 'after-name', email: 'after@example.test' });
|
||||
|
||||
const row = testDb.prepare('SELECT username, email FROM users WHERE id = ?').get(user.id) as { username: string; email: string };
|
||||
expect(row.username).toBe('after-name');
|
||||
expect(row.email).toBe('after@example.test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Avatar', () => {
|
||||
it('PROFILE-002 — upload valid JPEG avatar updates avatar_url', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
@@ -27,8 +27,9 @@ function insertRaw(key: string, value: string | null): void {
|
||||
/**
|
||||
* Runs `fn` while counting queries MikroORM actually issues over the shared
|
||||
* connection, so a regression test can assert "one query, with this value"
|
||||
* rather than only the value — the identity-map bug I1 fixed (Task 0 review)
|
||||
* returned the *right* value from a stale cache with *zero* queries, which a
|
||||
* rather than only the value — a regression in the `disableIdentityMap: true`
|
||||
* ruling (Plan 3b Task 1 fix round, `task-1-review.md` B1) would return the
|
||||
* *right* value from a stale identity-map hit with *zero* queries, which a
|
||||
* value-only assertion would never catch.
|
||||
*/
|
||||
async function withQueryCount<T>(fn: () => Promise<T>): Promise<{ value: T; queries: number }> {
|
||||
@@ -67,12 +68,12 @@ describe('AppSettingsRepository', () => {
|
||||
expect(values.has('collab_links_enabled')).toBe(false);
|
||||
});
|
||||
|
||||
// I1 (Task 0 review): getValue is a primary-key findOne, which MikroORM
|
||||
// answers from the identity map on a repeat call unless `refresh: true` is
|
||||
// set — invisible to a raw write or delete on the same key inside the same
|
||||
// request. These reproduce the review's two failing probes and pin the
|
||||
// Plan 3b Task 1 fix round (task-1-review.md B1): getValue is a
|
||||
// primary-key findOne, which MikroORM would answer from the identity map
|
||||
// on a repeat call absent `disableIdentityMap: true` — invisible to a raw
|
||||
// write or delete on the same key inside the same request. These pin the
|
||||
// fix at one query each.
|
||||
describe('getValue sees a raw write/delete on the same key in the same request (I1, identity-map regression)', () => {
|
||||
describe('getValue sees a raw write/delete on the same key in the same request (disableIdentityMap regression)', () => {
|
||||
it('APPSETREPO-013: deleteValue then getValue reads null, not the deleted row, in one query', async () => {
|
||||
insertRaw('bag_tracking_enabled', 'v');
|
||||
expect(await appSettings.getValue('bag_tracking_enabled')).toBe('v'); // populate the identity map
|
||||
|
||||
@@ -88,10 +88,9 @@ describe('CategoriesRepository', () => {
|
||||
expect(entity?.user?.isInitialized()).toBe(false);
|
||||
});
|
||||
|
||||
// I1 (Task 0 review): findById carries `refresh: true`, the ruling every
|
||||
// Plan 3 PK read follows — and it is load-bearing here (Task 3 review,
|
||||
// Important 2's correction): `{ id }` is exactly the primary key, so
|
||||
// without `refresh` this would be served from the identity map with
|
||||
// Plan 3b Task 1 fix round (task-1-review.md B1): findById carries
|
||||
// `disableIdentityMap: true` — `{ id }` is exactly the primary key, so
|
||||
// without it this would risk being served from the identity map with
|
||||
// zero queries and the stale pre-UPDATE color. `queries === 1` proves
|
||||
// the real re-query happens.
|
||||
it('CATREPO-008: a raw UPDATE on the same row then findById reads the new value, in one query', async () => {
|
||||
|
||||
@@ -92,14 +92,10 @@ describe('TagsRepository', () => {
|
||||
expect(entity?.user.isInitialized()).toBe(false);
|
||||
});
|
||||
|
||||
// I1 (Task 0 review): findByIdAndUser carries `{ refresh: true }` (via
|
||||
// the shared findOwnedByUser helper), the ruling every Plan 3 PK read
|
||||
// follows. This call's filter is `{ id, user }`, not PK-only, so it
|
||||
// always re-queries and merges fresh data regardless of `refresh` (Task
|
||||
// 3 review, Important 2's correction: the identity-map short-circuit
|
||||
// fires only for an exactly-PK filter, not for a `fields` restriction)
|
||||
// — this proves the (still correct) outcome in one query rather than a
|
||||
// refresh-vs-no-refresh contrast.
|
||||
// Plan 3b Task 1 fix round (task-1-review.md B1): findByIdAndUser
|
||||
// carries `disableIdentityMap: true` (via the shared findOwnedByUser
|
||||
// helper) — always re-queries and merges fresh data, in one query, and
|
||||
// never lands in the request's identity map.
|
||||
it('TAGREPO-009: a raw UPDATE on the same row then findByIdAndUser reads the new value, in one query', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const created = createTag(testDb, user.id, { name: 'Stale', color: '#111111' });
|
||||
|
||||
@@ -20,7 +20,8 @@ afterAll(async () => { await t.close(); testDb.close(); });
|
||||
/**
|
||||
* Runs `fn` while counting queries MikroORM actually issues over the shared
|
||||
* connection — see `AppSettings.repository.test.ts` for why a value-only
|
||||
* assertion can't catch the identity-map bug I1 fixed (Task 0 review).
|
||||
* assertion can't catch a regression in the `disableIdentityMap: true`
|
||||
* ruling (Plan 3b Task 1 fix round, `task-1-review.md` B1).
|
||||
*/
|
||||
async function withQueryCount<T>(fn: () => Promise<T>): Promise<{ value: T; queries: number }> {
|
||||
const connection = t.orm.em.getConnection();
|
||||
@@ -70,11 +71,13 @@ describe('UsersRepository', () => {
|
||||
expect(await users.getApiKeyColumn(999999, 'maps_api_key')).toBeNull();
|
||||
});
|
||||
|
||||
// I1 (Task 0 review): getEmail/getApiKeyColumn are primary-key findOne
|
||||
// calls, which MikroORM answers from the identity map on a repeat call
|
||||
// unless `refresh: true` is set — invisible to a raw UPDATE on the same
|
||||
// user id inside the same request. Pins the fix at one query each.
|
||||
describe('getEmail/getApiKeyColumn see a raw UPDATE on the same user in the same request (I1, identity-map regression)', () => {
|
||||
// Plan 3b Task 1 fix round (task-1-review.md B1): getEmail/getApiKeyColumn
|
||||
// are primary-key findOne calls, which MikroORM would answer from the
|
||||
// identity map on a repeat call absent `disableIdentityMap: true` —
|
||||
// invisible to a raw UPDATE on the same user id inside the same request.
|
||||
// Pins the fix at one query each; the property is preserved (B1's fix
|
||||
// dropped `refresh: true`, not this guarantee).
|
||||
describe('getEmail/getApiKeyColumn see a raw UPDATE on the same user in the same request (disableIdentityMap regression)', () => {
|
||||
it('USERSREPO-007: a raw UPDATE users SET email then getEmail reads the new email, in one query', async () => {
|
||||
const { user } = createUser(testDb, { email: 'old@example.com' });
|
||||
expect(await users.getEmail(user.id)).toBe('old@example.com'); // populate the identity map
|
||||
@@ -200,6 +203,37 @@ describe('UsersRepository', () => {
|
||||
expect(row.role).toBe('admin');
|
||||
expect(row.login_count).toBe(0);
|
||||
});
|
||||
|
||||
it('USERSREPO-057: insertUser (F2) never flushes an unrelated pending entity in the same EM', async () => {
|
||||
// Simulate an unrelated pending change elsewhere in the request's EM —
|
||||
// a managed entity persist()-ed but not yet flushed by its own code
|
||||
// path. Before F2's fix, `insertUser`'s `persist().flush()` committed
|
||||
// the WHOLE unit of work, so this row would land in the DB too.
|
||||
const pending = t.em.create(Users, {
|
||||
username: 'pending-unflushed',
|
||||
email: 'pending@example.com',
|
||||
password_hash: 'x',
|
||||
role: 'user',
|
||||
first_seen_version: '9.9.9',
|
||||
login_count: 0,
|
||||
});
|
||||
t.em.persist(pending);
|
||||
|
||||
await users.insertUser({
|
||||
username: 'freshuser',
|
||||
email: 'fresh@example.com',
|
||||
password_hash: 'hashed',
|
||||
role: 'user',
|
||||
first_seen_version: '1.2.3',
|
||||
});
|
||||
|
||||
const pendingRow = testDb.prepare('SELECT * FROM users WHERE username = ?').get('pending-unflushed');
|
||||
expect(pendingRow).toBeUndefined(); // insertUser must not have flushed it
|
||||
const freshRow = testDb.prepare('SELECT * FROM users WHERE username = ?').get('freshuser');
|
||||
expect(freshRow).toBeDefined(); // but the intended row is there
|
||||
|
||||
t.em.clear(); // discard the still-pending unflushed entity before the next test
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByEmailCI (AU12/O4)', () => {
|
||||
@@ -218,6 +252,26 @@ describe('UsersRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Plan 3b Task 1 fix round item 7 — AuthService.demoLogin's exact statement
|
||||
// (`SELECT * FROM users WHERE email = ?`), missing from the inventory,
|
||||
// added now so Task 5 has it.
|
||||
describe('findByEmailExact (AuthService.demoLogin, inventory correction)', () => {
|
||||
it('USERSREPO-057B: matches only the exact case, unlike every other email lookup here', async () => {
|
||||
const { user } = createUser(testDb, { email: 'Demo.User@Example.com' });
|
||||
const row = await users.findByEmailExact('Demo.User@Example.com');
|
||||
expect(row?.id).toBe(user.id);
|
||||
expect(await users.findByEmailExact('demo.user@example.com')).toBeNull(); // different case — no match
|
||||
expect(await users.findByEmailExact('nope@example.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('USERSREPO-057C: a guest row IS returned — no guest filter, unlike findByEmailCI/findForPasswordReset', async () => {
|
||||
const { user } = createUser(testDb, { email: 'demoguest@example.com' });
|
||||
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(user.id);
|
||||
const row = await users.findByEmailExact('demoguest@example.com');
|
||||
expect(row?.id).toBe(user.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('touchLastLogin (AU13/AU34/AU35/PK12/O16)', () => {
|
||||
it('USERSREPO-020: bumps login_count by exactly 1 and stamps last_login, in one statement', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
@@ -315,7 +369,7 @@ describe('UsersRepository', () => {
|
||||
expect(await users.findById(999999)).toBeNull();
|
||||
});
|
||||
|
||||
it('USERSREPO-031: sees a raw UPDATE on the same id in the same request (I1, identity-map regression)', async () => {
|
||||
it('USERSREPO-031: sees a raw UPDATE on the same id in the same request (disableIdentityMap regression)', async () => {
|
||||
const { user } = createUser(testDb, { username: 'before' });
|
||||
await users.findById(user.id); // populate the identity map
|
||||
testDb.prepare('UPDATE users SET username = ? WHERE id = ?').run('after', user.id);
|
||||
|
||||
@@ -60,27 +60,19 @@ describe('findOwnedByUser', () => {
|
||||
expect(await findOwnedByUser<Tags, 'user'>(tags, 999999, 'user', owner.id)).toBeNull();
|
||||
});
|
||||
|
||||
// Task 0 review I1's PK-read ruling, extended to this helper (Task 3):
|
||||
// `options.refresh` passes through to `findOne`. This helper's filter is
|
||||
// `{ id, [ownerField]: ownerId }`, not PK-only, so it always re-queries
|
||||
// and merges fresh data regardless of `refresh` (proven below: a raw
|
||||
// write on the same row is visible either way) — the identity-map
|
||||
// short-circuit (and the staleness it can cause without `refresh`) fires
|
||||
// only when the filter is exactly the primary key, which is what
|
||||
// AppSettingsRepository.getValue's PK-only `findOne` hit and this
|
||||
// multi-condition filter never does (Task 3 review, Important 2's
|
||||
// correction — `fields` has nothing to do with it). `refresh` stays on
|
||||
// this helper regardless, matching the Task 0 review's blanket PK-read
|
||||
// ruling and staying correct if a future caller narrows the filter to
|
||||
// PK-only.
|
||||
it('OWNEDLOOKUP-006: { refresh: true } sees a raw UPDATE on the same row in the same request, in one query', async () => {
|
||||
// Plan 3b Task 1 fix round (task-1-review.md B1): `findOwnedByUser` always
|
||||
// passes `disableIdentityMap: true` now (no longer an opt-in `options`
|
||||
// parameter) — every call answers from a throwaway forked context, so a
|
||||
// raw write on the same row earlier in the request is always visible, in
|
||||
// one query, and the entity never lands in the request's identity map.
|
||||
it('OWNEDLOOKUP-006: sees a raw UPDATE on the same row in the same request, in one query (disableIdentityMap)', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const tag = createTag(testDb, owner.id, { name: 'Old' });
|
||||
expect((await findOwnedByUser<Tags, 'user'>(tags, tag.id, 'user', owner.id))?.name).toBe('Old'); // populate the identity map
|
||||
expect((await findOwnedByUser<Tags, 'user'>(tags, tag.id, 'user', owner.id))?.name).toBe('Old');
|
||||
testDb.prepare('UPDATE tags SET name = ? WHERE id = ?').run('New', tag.id);
|
||||
const connection = t.orm.em.getConnection();
|
||||
const spy = vi.spyOn(connection, 'execute');
|
||||
const found = await findOwnedByUser<Tags, 'user'>(tags, tag.id, 'user', owner.id, { refresh: true });
|
||||
const found = await findOwnedByUser<Tags, 'user'>(tags, tag.id, 'user', owner.id);
|
||||
expect(found?.name).toBe('New');
|
||||
expect(spy.mock.calls.length).toBe(1);
|
||||
spy.mockRestore();
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Regression test for Plan 3b Task 1's B1 finding
|
||||
* (`.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md`): a `Users`
|
||||
* row read with `fields` + `refresh: true` (no `disableIdentityMap`) left a
|
||||
* *partially hydrated, managed* entity in the request `EntityManager`'s
|
||||
* identity map. A second such read, with a *different* field set, on the
|
||||
* *same* row re-snapshotted only its own fields — the first read's fields
|
||||
* stayed on the entity object but fell out of the snapshot MikroORM's
|
||||
* change-set computer diffs against, so they looked like a pending change.
|
||||
* The next `flush()` (including the implicit one at `UnitOfWork.transactional`
|
||||
* commit) then emitted an `UPDATE users SET <those columns> …` carrying the
|
||||
* STALE pre-request values, landing *after* an intervening `nativeUpdate`
|
||||
* inside the same transaction and silently reverting it.
|
||||
*
|
||||
* No single-method test can catch this by construction (`Users.repository.test.ts`
|
||||
* exercises one method per case): this file drives the two-reads-then-a-write
|
||||
* interaction that is the actual trigger, inside `withRequestContext` (every
|
||||
* authenticated request's shape) and `uow.transactional` (every multi-write
|
||||
* request's shape).
|
||||
*
|
||||
* The fix (this task): every row-out read passes `disableIdentityMap: true`.
|
||||
* MikroORM forks the EM with `keepTransactionContext: true`, loads inside the
|
||||
* fork, then clears it — nothing is added to the *request's* identity map, so
|
||||
* there is nothing for a later `flush()` to find dirty.
|
||||
* See https://mikro-orm.io/docs/entity-manager#disableidentitymap and
|
||||
* `node_modules/@mikro-orm/core/EntityManager.js:807-824`.
|
||||
*
|
||||
* Mutation proof (recorded in `task-1-fix-report.md`, not re-run here): reverting
|
||||
* ONE of the two reads below to the pre-fix shape (`fields` + `refresh: true`,
|
||||
* no `disableIdentityMap`) makes IMWB-001 fail with the stale write-back this
|
||||
* test is built to catch.
|
||||
*/
|
||||
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 { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
|
||||
import { withRequestContext } from '../../../../src/nest/database/request-context';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let users: UsersRepository;
|
||||
let uow: UnitOfWork;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
users = t.repo(Users);
|
||||
uow = new UnitOfWork(t.em);
|
||||
});
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
describe('identity-map write-back (task-1-review.md B1)', () => {
|
||||
it('IMWB-001: findByIdWithPasswordVersion then getApiKeyColumns then patchProfile — the profile write survives the request', async () => {
|
||||
const { user } = createUser(testDb, { username: 'before', email: 'before@example.test' });
|
||||
const originalPasswordVersion = (testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version: number }).password_version;
|
||||
|
||||
await withRequestContext(t.orm, async () => {
|
||||
// Projection A — the JwtAuthGuard/GlobalAuthGuard shape.
|
||||
await users.findByIdWithPasswordVersion(user.id);
|
||||
// Projection B — a second UsersRepository read with a different field set,
|
||||
// the UserProfileService shape (e.g. the MfaPolicyGuard/getApiKeyColumns
|
||||
// reads that run on the same request).
|
||||
await users.getApiKeyColumns(user.id);
|
||||
// The intended write, inside the request's own transaction.
|
||||
await uow.transactional(async () => {
|
||||
await users.patchProfile(user.id, { username: 'newname' });
|
||||
});
|
||||
});
|
||||
|
||||
const row = testDb
|
||||
.prepare('SELECT username, email, password_version FROM users WHERE id = ?')
|
||||
.get(user.id) as { username: string; email: string; password_version: number };
|
||||
expect(row.username).toBe('newname'); // the nativeUpdate must stick
|
||||
expect(row.email).toBe('before@example.test'); // untouched by either read
|
||||
expect(row.password_version).toBe(originalPasswordVersion); // untouched by either read
|
||||
});
|
||||
|
||||
it('IMWB-002: findByIdWithPasswordVersion then getApiKeyColumns then setPassword — password_version survives (Task 5 session-invalidation case)', async () => {
|
||||
const { user } = createUser(testDb, { username: 'someone' });
|
||||
|
||||
await withRequestContext(t.orm, async () => {
|
||||
await users.findByIdWithPasswordVersion(user.id);
|
||||
await users.getApiKeyColumns(user.id);
|
||||
await uow.transactional(async () => {
|
||||
await users.setPassword(user.id, 'NEWHASH', 5);
|
||||
});
|
||||
});
|
||||
|
||||
const row = testDb
|
||||
.prepare('SELECT password_hash, password_version, username FROM users WHERE id = ?')
|
||||
.get(user.id) as { password_hash: string; password_version: number; username: string };
|
||||
expect(row.password_hash).toBe('NEWHASH');
|
||||
expect(row.password_version).toBe(5); // the session-invalidation gate — must NOT revert to the pre-change value
|
||||
expect(row.username).toBe('someone'); // untouched by either read
|
||||
});
|
||||
});
|
||||
@@ -37,8 +37,21 @@ function makeReq(overrides: {
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
/** A fake `UsersRepository` whose `findByIdWithPasswordVersion` returns the given row. */
|
||||
function usersRepo(row: UserWithPasswordVersion | null): UsersRepository {
|
||||
/**
|
||||
* A fake `UsersRepository` whose `findByIdWithPasswordVersion` returns the
|
||||
* given row.
|
||||
*
|
||||
* `password_version` is widened to `number | null` here, unlike the real
|
||||
* `UserWithPasswordVersion.password_version: number` — the column is
|
||||
* `INTEGER NOT NULL DEFAULT 0`, so a real row can never carry `null`, but
|
||||
* AUTH-JWT-009 (task-1-review.md F5) still needs to pin
|
||||
* `verifyJwtAndLoadUser`'s `typeof row.password_version === 'number' ? … :
|
||||
* 0` fallback branch, which is otherwise unreachable through the real type.
|
||||
* Widening the fake, not `UserWithPasswordVersion` itself (which would
|
||||
* loosen a production type for a test-only case), keeps that column's real
|
||||
* NOT-NULL guarantee intact everywhere else.
|
||||
*/
|
||||
function usersRepo(row: (Omit<UserWithPasswordVersion, 'password_version'> & { password_version: number | null }) | null): UsersRepository {
|
||||
return { findByIdWithPasswordVersion: vi.fn(async () => row) } as unknown as UsersRepository;
|
||||
}
|
||||
|
||||
@@ -125,8 +138,12 @@ describe('verifyJwtAndLoadUser', () => {
|
||||
expect(await verifyJwtAndLoadUser(current, users)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('AUTH-JWT-009: a pre-pv token still works against a never-reset user (both read as 0)', async () => {
|
||||
const users = usersRepo({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 0 });
|
||||
it('AUTH-JWT-009: a pre-pv token still works against a row whose password_version reads as unset (both fall back to 0)', async () => {
|
||||
// password_version: null pins the `typeof row.password_version === 'number' ? … : 0`
|
||||
// fallback branch directly (task-1-review.md F5) — unreachable through
|
||||
// the real UsersRepository, whose column is NOT NULL DEFAULT 0, but
|
||||
// worth keeping covered rather than deleting the branch it guards.
|
||||
const users = usersRepo({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: null });
|
||||
const legacy = jwt.sign({ id: 1 }, 'test-secret', { algorithm: 'HS256' });
|
||||
|
||||
expect(await verifyJwtAndLoadUser(legacy, users)).not.toBeNull();
|
||||
|
||||
@@ -560,13 +560,17 @@ describe('authenticateDownload', () => {
|
||||
verifyJwtAndLoadUser.mockReturnValue({ id: 42 });
|
||||
const result = await svc.authenticateDownload(req({ cookie: 'cookie-jwt', bearer: 'bearer-jwt' }));
|
||||
expect(result).toEqual({ userId: 42 });
|
||||
expect(verifyJwtAndLoadUser).toHaveBeenCalledWith('cookie-jwt', expect.anything());
|
||||
// The concrete expected argument, not expect.anything(): emStub.getRepository
|
||||
// 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".
|
||||
expect(verifyJwtAndLoadUser).toHaveBeenCalledWith('cookie-jwt', {});
|
||||
});
|
||||
|
||||
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', expect.anything());
|
||||
expect(verifyJwtAndLoadUser).toHaveBeenCalledWith('bearer-jwt', {}); // see FILE-SVC-032's comment
|
||||
|
||||
verifyJwtAndLoadUser.mockReturnValue(null);
|
||||
expect(await svc.authenticateDownload(req({ bearer: 'stale' }))).toEqual({ error: 'Invalid or expired token', status: 401 });
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('applyPlatformUploads', () => {
|
||||
res,
|
||||
next,
|
||||
);
|
||||
expect(h.verifyJwtAndLoadUser).toHaveBeenCalledWith('jwt123', expect.anything());
|
||||
expect(h.verifyJwtAndLoadUser).toHaveBeenCalledWith('jwt123', expect.objectContaining({ findByIdWithPasswordVersion: expect.any(Function) }));
|
||||
expect(h.sendToResponse).toHaveBeenCalledWith('photos', 'a.jpg', res);
|
||||
});
|
||||
|
||||
@@ -188,7 +188,7 @@ describe('applyPlatformUploads', () => {
|
||||
h.verifyJwtAndLoadUser.mockReturnValue({ id: 1 });
|
||||
const res = makeRes();
|
||||
await photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: { token: 'qtok' } }, res, next);
|
||||
expect(h.verifyJwtAndLoadUser).toHaveBeenCalledWith('qtok', expect.anything());
|
||||
expect(h.verifyJwtAndLoadUser).toHaveBeenCalledWith('qtok', expect.objectContaining({ findByIdWithPasswordVersion: expect.any(Function) }));
|
||||
expect(h.sendToResponse).toHaveBeenCalledWith('photos', 'a.jpg', res);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user