Files
TREK/server/src/db/repositories/MaintenanceRepository.ts
T
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

114 lines
5.7 KiB
TypeScript

import type { EntityManager } from '@mikro-orm/core';
/**
* Plan 3i Task 3 — R1's raw-connection exception, named ahead of this plan by
* the program brief's own rule 4 ("connection.execute() (only in
* MaintenanceRepository/DemoRepository, with a comment)").
*
* Not a `TrekRepository<Entity>` — it backs no table and no entity of its
* own. It exists purely as the one place two whole-database-file statements
* (a `PRAGMA` and a `VACUUM INTO`) run against the ORM's own bound driver
* connection instead of scattering raw `better-sqlite3` calls across
* `backup.impl.ts`/`demo-reset.ts`. `find/findOne → create/assign/flush →
* nativeUpdate/nativeDelete → em.upsert → qb() → em.getKysely()` all fail
* for these two statements — neither is expressible against an entity at
* all — so `connection.execute()` (the last tier in the program's query-API
* order) is the correct tool, not a shortcut around it.
*
* Provided through Nest DI by `MaintenanceModule` (`nest/database/`), a
* factory over the context-resolving global `EntityManager`; its consumers
* inject it: the readiness probe, `UserCleanupService` and the SQLite backup
* port (`SqliteDatabaseBackup`), which the backup, the restore and the demo
* reset all go through. The class stays free of Nest decorators like every
* other file under `db/`, so a test can still build one over its own
* `EntityManager`.
*/
export class MaintenanceRepository {
constructor(private readonly em: EntityManager) {}
/**
* MikroORM's own validating `getContext()` — throws
* `ValidationError.cannotUseGlobalContext()` outside a request context (the
* production default), the same fail-closed ratchet `TrekRepository`
* applies to every entity-bound repository. Called first by both methods
* below, since raw `connection.execute()` has no such guard of its own.
*/
private validateRequestContext(): void {
this.em.getContext();
}
/** `SELECT 1`: whether the database answers at all, for the readiness probe. */
async ping(): Promise<void> {
this.validateRequestContext();
await this.em.getConnection().execute('SELECT 1');
}
/**
* `PRAGMA wal_checkpoint(TRUNCATE)` — rendered text pinned, identical to
* the legacy `db.exec('PRAGMA wal_checkpoint(TRUNCATE)')` it replaces.
* Reached through the backup port's `checkpoint()`, which the backup
* (BK1) and the demo reset (DMR3) call. Flushes the WAL into the main db
* file before a snapshot or a swap; every caller treats it as best effort.
*/
async walCheckpoint(): Promise<void> {
this.validateRequestContext();
await this.em.getConnection().execute('PRAGMA wal_checkpoint(TRUNCATE)');
}
/**
* `` VACUUM INTO '<path>' `` — rendered text pinned, identical to the
* legacy `` db.exec(`VACUUM INTO '${dbSnap.replaceAll("'", "''")}'`) ``
* (BK2, reached through the backup port's `snapshot()`): single quotes in `path` are
* escaped by doubling, the same escaping the legacy statement used — a
* consistent point-in-time snapshot under concurrent writers, with no
* MikroORM/Kysely equivalent.
*/
async vacuumInto(path: string): Promise<void> {
this.validateRequestContext();
const escaped = path.replaceAll("'", "''");
await this.em.getConnection().execute(`VACUUM INTO '${escaped}'`);
}
/**
* Plan 4 Task 4 — UC1 (`UserCleanupService.erasePluginUserData`): a
* best-effort per-user delete across the three plugin-held tables
* (`plugin_user_config`/`plugin_oauth_tokens`/`plugin_oauth_state`). These
* are `nest/plugins`' own tables (Plan 3b Task 5's "stays raw" ruling,
* narrowed by Plan 4 Task 8a to just this trio — UC2/UC3 already moved to
* `PluginsRepository`/`PluginUserErasureQueueRepository`), not an entity
* managed by any repository, so `connection.execute()` (rule 4's last
* tier) is the correct tool here, same shape as `walCheckpoint`/
* `vacuumInto` above — with ONE difference those two don't need: this is
* the first `MaintenanceRepository` caller ever invoked from INSIDE an
* open `UnitOfWork.transactional(...)` (`deleteUserCompletely`'s
* transaction). `Connection#execute(query, params, method, ctx)`'s `ctx`
* defaults to the plain (non-transactional) Kysely client when omitted —
* `walCheckpoint`/`vacuumInto` always omit it because their callers
* (the backup port) never run inside a transaction, but
* omitting it here deadlocked: this repo's `better-sqlite3` connection is
* single-connection/single-writer, the open transaction holds it for its
* whole lifetime, and a bare `execute()` tries to check out that SAME
* connection fresh instead of reusing the held one — a checkout that only
* frees once the transaction (which is itself awaiting THIS call)
* completes. `em.getTransactionContext()` returns the open transaction's
* driver handle when called from inside one (resolved the same
* `TransactionContext`-async-local-storage way `getContext()` is, so
* `this.em` need not be the transactional fork itself) and `undefined`
* otherwise, so passing it through is correct both inside and outside a
* transaction. Each statement is independently best-effort: a
* slimmed-down test schema may not carry one or more of these tables at
* all, matching the legacy per-table try/catch exactly.
*/
async deletePluginUserData(userId: number): Promise<void> {
this.validateRequestContext();
const ctx = this.em.getTransactionContext();
for (const table of ['plugin_user_config', 'plugin_oauth_tokens', 'plugin_oauth_state']) {
try {
await this.em.getConnection().execute(`DELETE FROM ${table} WHERE user_id = ?`, [userId], 'run', ctx);
} catch {
/* table absent (slim schema) */
}
}
}
}