diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index af500da..cdeb472 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -391,6 +391,21 @@ export function db(): Database.Database { throw err; } + // Cache only runtime queries, after migrations have finished. Statements + // belong to this connection, so reopening the DB starts with an empty cache. + // Callers share statement options (e.g. raw()); use consistent options for + // identical SQL and pass values to run/get/all rather than binding permanently. + const statements = new Map(); + const prepare = handle.prepare.bind(handle); + handle.prepare = ((sql: string) => { + let statement = statements.get(sql); + if (!statement) { + statement = prepare(sql); + statements.set(sql, statement); + } + return statement; + }) as typeof handle.prepare; + _db = handle; return _db; } diff --git a/tests/unit/creation-queue.test.ts b/tests/unit/creation-queue.test.ts index b150196..95457c3 100644 --- a/tests/unit/creation-queue.test.ts +++ b/tests/unit/creation-queue.test.ts @@ -48,7 +48,11 @@ beforeEach(async () => { vi.mocked(komodo.komodoUpdateTagsOnResource).mockResolvedValue({ ok: true }); const rcon = await import('../../src/lib/server/integrations/rcon.js'); vi.mocked(rcon.rconGetPlayerCount).mockResolvedValue(0); - vi.mocked(rcon.rconPlutoniumVersion).mockResolvedValue({ revision: 5354, error: null }); + vi.mocked(rcon.rconPlutoniumVersion).mockImplementation(async () => { + // Version evidence must be observed strictly after the launch timestamp. + await new Promise(resolve => setTimeout(resolve, 2)); + return { revision: 5354, error: null }; + }); vi.mocked(rcon.rconStatusResult).mockImplementation(async server => status(server)); const { kick } = await import('../../src/lib/server/pipeline/worker.js'); vi.mocked(kick).mockResolvedValue(undefined); diff --git a/tests/unit/db-statements.test.ts b/tests/unit/db-statements.test.ts new file mode 100644 index 0000000..0dc0193 --- /dev/null +++ b/tests/unit/db-statements.test.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; + +let fixture: Fixture; +beforeEach(async () => { + vi.resetModules(); + fixture = await newFixture([]); +}); +afterEach(async () => { await fixture.cleanup(); }); + +describe('prepared statement caching', () => { + it('reuses statements with fresh parameters and remains usable after rollback', async () => { + const { db } = await import('../../src/lib/server/db/index.js'); + const insertSql = 'INSERT INTO settings (key, value) VALUES (?, ?)'; + const selectSql = 'SELECT value FROM settings WHERE key = ?'; + const insert = db().prepare(insertSql); + const select = db().prepare(selectSql); + insert.run('cache-first', 'one'); + expect(() => db().transaction(() => { + db().prepare(insertSql).run('cache-rollback', 'discarded'); + throw new Error('rollback'); + })()).toThrow('rollback'); + db().prepare(insertSql).run('cache-second', 'two'); + + expect(db().prepare(insertSql)).toBe(insert); + expect(db().prepare(selectSql)).toBe(select); + expect(select.get('cache-first')).toEqual({ value: 'one' }); + expect(select.get('cache-second')).toEqual({ value: 'two' }); + expect(select.get('cache-rollback')).toBeUndefined(); + }); + + it('prepares fresh statements when the connection is reopened', async () => { + const { db, closeDb } = await import('../../src/lib/server/db/index.js'); + const sql = 'SELECT value FROM settings WHERE key = ?'; + const oldStatement = db().prepare(sql); + db().prepare('INSERT INTO settings (key, value) VALUES (?, ?)').run('cache-persisted', 'saved'); + closeDb(); + + const statement = db().prepare(sql); + expect(statement).not.toBe(oldStatement); + expect(statement.database).not.toBe(oldStatement.database); + expect(statement.get('cache-persisted')).toEqual({ value: 'saved' }); + expect(db().prepare(sql)).toBe(statement); + }); +}); diff --git a/tests/unit/deployment-collections.test.ts b/tests/unit/deployment-collections.test.ts index a43da44..dad8cba 100644 --- a/tests/unit/deployment-collections.test.ts +++ b/tests/unit/deployment-collections.test.ts @@ -15,7 +15,11 @@ vi.mock('../../src/lib/server/integrations/komodo.js', () => ({ })); vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconGetPlayerCount: vi.fn(async () => 0), - rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null })), + rconPlutoniumVersion: vi.fn(async () => { + // Version evidence must be observed strictly after the launch timestamp. + await new Promise(resolve => setTimeout(resolve, 2)); + return { revision: 5354, error: null }; + }), rconStatusResult: vi.fn(async (server: string) => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: '', status: { players: 0, map: server.startsWith('t6_') ? 'zm_transit' : server.includes('_moon_') ? 'zombie_moon' : 'zombie_theater', gametype: null, roster: [] } })) }));