Files
TREK/server/tests/unit/db/transaction-yield.test.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

210 lines
11 KiB
TypeScript

/**
* Program rule 24 (`docs/superpowers/plans/2026-09-21-orm-migration-program.md`),
* added by Plan 3c Task 9's whole-plan review (reviewer A, ruling 5): "transactional
* bodies await DB work only." The server holds ONE better-sqlite3 connection, shared
* by MikroORM's Kysely-backed driver (`orm-driver.ts`'s `BoundSqliteDriver`) and by
* any code holding the raw `better-sqlite3` handle directly (Plan 4 Task 4 deleted
* `DatabaseService`, the class that used to wrap `db.prepare` statements this way — the
* hazard below is unchanged, since `MaintenanceRepository`/`DemoRepository`'s own
* `connection.execute()` calls and the still-exported `db` Proxy in
* `db/database.ts` are the same kind of direct, non-Kysely-queued access). This
* file pins the two concrete behaviours that measurement (fork A2, real
* `UnitOfWork` + `withRequestContext` + the bound driver, no mocks) found when a
* `uow.transactional` body is left open across a real `await` (which no converted
* service does today — a static scan of all 131 `.transactional(` call sites found
* none awaiting non-DB I/O; this file's own transactional bodies hold one open
* ONLY to observe the window, which is exactly the shape a future violation of
* rule 24 would take):
*
* A. Another request's ORM/Kysely statement QUEUES behind the open transaction —
* Kysely's own `ConnectionMutex` for SQLite serialises every statement onto
* the one connection, so there is no interleaving: the queued statement's
* result only becomes observable after the holder's transaction settles.
* B. A raw `better-sqlite3` statement (`db.prepare` then `.get`/`.run`, the same
* shape `DatabaseService` used to wrap) issued while that same transaction is
* open does NOT queue — it shares the connection directly, with no mutex of
* its own, so it runs INSIDE the open transaction: a "dirty read" of the
* transaction's uncommitted write, which then reverts to its pre-transaction
* value the moment the holder rolls back (the hazard rule 24 exists to keep
* out of production code, by keeping every transactional body DB-only).
*
* Both requests run through `withRequestContext` (MikroORM's `RequestContext`,
* AsyncLocalStorage-based), the same per-request fork every real HTTP/MCP/WS/cron
* entrypoint uses — not two calls sharing one ambient EntityManager, which would
* make MikroORM treat the second as a SAVEPOINT of the first (`UnitOfWork`'s own
* docstring: NESTED propagation) rather than the independent, connection-mutex-
* queued transaction two unrelated concurrent requests actually are.
*/
import { Trips } from '../../../src/db/entities/Trips.entity';
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
import { withRequestContext } from '../../../src/nest/database/request-context';
import { UnitOfWork } from '../../../src/nest/database/unit-of-work';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createUser, createTrip } from '../../helpers/factories';
import { findRow } from '../../helpers/factories/rows';
import { resetTestDb } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const testDb = createSnapshotTestDb();
let t: TestOrm;
let trips: TripsRepository;
let uow: UnitOfWork;
beforeAll(async () => {
t = await createTestOrm(testDb);
trips = t.repo(Trips);
uow = new UnitOfWork(t.em);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
describe('transaction yield (rule 24)', () => {
it("A — another request's ORM statement queues behind an open transaction: no interleaving, and its result only becomes observable after the holder commits", async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { end_date: '2026-01-01' });
const order: string[] = [];
const HOLD_MS = 40;
// Request 1: opens a transaction, holds it open across a real `await` (the
// window this test needs to observe — never how production code shapes a
// transactional body; see the file docstring), then writes and commits.
const holder = withRequestContext(t.orm, async () => {
return uow.transactional(async () => {
order.push('holder:open');
await sleep(HOLD_MS);
await trips.setEndDate(trip.id, '2099-12-31');
order.push('holder:commit');
});
});
// Give the holder a tick to actually open its transaction before the
// second "request" starts, so the queued statement is provably issued
// while the first is open, not racing to start first itself.
await sleep(5);
// Request 2: an unrelated concurrent request's plain ORM read (no
// transaction of its own) for the SAME row, through its own forked
// context — the shape `TripAccessGuard`/`findAccessible` reads take.
const queued = withRequestContext(t.orm, async () => {
order.push('queued:issue');
const row = await trips.getTitle(trip.id);
order.push('queued:resolve');
return row;
});
await Promise.all([holder, queued]);
// The queued read was ISSUED while the holder was still open (between
// "holder:open" and "holder:commit")…
expect(order.indexOf('queued:issue')).toBeGreaterThan(order.indexOf('holder:open'));
expect(order.indexOf('queued:issue')).toBeLessThan(order.indexOf('holder:commit'));
// …but it only RESOLVED after the holder committed — the connection
// mutex queued it rather than interleaving it inside the open transaction.
expect(order.indexOf('queued:resolve')).toBeGreaterThan(order.indexOf('holder:commit'));
});
it('B — a raw DatabaseService read AND write, issued from a SECOND, unrelated request while a transaction is open, run INSIDE it (a dirty read of the uncommitted write; the raw write is gone once the holder rolls back) — L-3, Task 9 fix round 2', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Original Title', end_date: '2026-01-01' });
let dirtyRead: string | null | undefined;
const HOLD_MS = 40;
// Request 1 (the holder): opens a transaction, writes, holds it open
// across a real `await`, then forces a rollback.
const holder = withRequestContext(t.orm, async () => {
await expect(
uow.transactional(async () => {
await trips.setEndDate(trip.id, '2099-12-31');
await sleep(HOLD_MS);
// Force a rollback: the write above, and anything the second
// request below lands inside this transaction, must not survive.
throw new Error('forced rollback (rule 24 probe)');
}),
).rejects.toThrow('forced rollback');
});
// Give the holder a tick to actually open its transaction and perform
// its write before the second, UNRELATED request starts — the queued
// statement/raw statement below must provably run while the first is
// still open, not race to start first itself (same pattern as probe A).
await sleep(5);
// Request 2: a fully separate concurrent request, through its OWN
// forked `withRequestContext` — not a call from inside the holder's own
// body (L-3: that would only prove a request can dirty-read its own
// uncommitted write, not the cross-request hazard rule 24 names). It
// issues a raw `better-sqlite3` READ and WRITE directly against the
// shared handle (the same shape `DatabaseService.get`/`.run` used to
// wrap — `this.conn.prepare` then `.get`/`.run`, bypassing MikroORM/
// Kysely entirely). Neither is behind Kysely's `ConnectionMutex` (probe
// A), so both share the one better-sqlite3 connection directly and run
// INSIDE the holder's still-open, uncommitted transaction.
await withRequestContext(t.orm, async () => {
// test-sql-allow: a raw read on the shared handle, outside Kysely's mutex, is the hazard this probe measures.
const row = testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id) as
{ end_date: string | null } | undefined;
dirtyRead = row?.end_date;
// test-sql-allow: a raw write on the shared handle, outside Kysely's mutex, is the hazard this probe measures.
testDb.prepare('UPDATE trips SET title = ? WHERE id = ?').run('DIRTY-WRITE-MARKER', trip.id);
});
await holder;
// The second request's raw read saw the holder's uncommitted write — a
// genuine cross-request dirty read, proving the raw path is NOT queued
// behind the transaction the way an ORM/Kysely statement is (probe A).
expect(dirtyRead).toBe('2099-12-31');
// …and the second request's own raw UPDATE, though it returned with no
// error and no queueing (an apparent "success"), was made INSIDE the
// holder's transaction — it is silently erased the moment the holder
// rolls back. This is exactly the hazard rule 24 keeps out of
// production code: a raw statement issued mid-transaction by ANOTHER
// request would otherwise report success on a write this rollback just
// undid.
const after = (await findRow(t, Trips, { id: trip.id }))!;
expect(after.end_date).toBe('2026-01-01');
expect(after.title).toBe('Original Title');
});
it('I — a second, unrelated transaction opened while the first is still open does not error (no nested-BEGIN exception); it queues and commits its own write once the first settles', async () => {
const { user } = createUser(testDb);
const tripA = createTrip(testDb, user.id, { end_date: '2026-01-01' });
const tripB = createTrip(testDb, user.id, { end_date: '2026-01-01' });
const HOLD_MS = 40;
const first = withRequestContext(t.orm, () =>
uow.transactional(async () => {
await sleep(HOLD_MS);
await trips.setEndDate(tripA.id, '2099-01-01');
}),
);
// Give the first a moment to actually open before the second starts, so
// the second's own BEGIN is provably attempted while one is already open.
await sleep(5);
const second = withRequestContext(t.orm, () =>
uow.transactional(async () => {
await trips.setEndDate(tripB.id, '2099-02-01');
}),
);
await expect(Promise.all([first, second])).resolves.toBeDefined();
const rowA = (await findRow(t, Trips, { id: tripA.id }))!;
const rowB = (await findRow(t, Trips, { id: tripB.id }))!;
expect(rowA.end_date).toBe('2099-01-01');
expect(rowB.end_date).toBe('2099-02-01');
});
});