diff --git a/instrumentation.ts b/instrumentation.ts new file mode 100644 index 0000000..8e31b2e --- /dev/null +++ b/instrumentation.ts @@ -0,0 +1,10 @@ +// Open the game DB once at server boot (Next ≥15 instrumentation hook; runs under `next dev` +// and `next start`) so the ./db volume file exists from first start, before any request. +// Dynamic import inside the runtime guard: lib/db/client.ts uses node:fs + sql.js, which must +// never be pulled into the Edge Runtime instrumentation bundle (Next bundles register() for both). +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + const { getGameDb } = await import('@/lib/db/client'); + void getGameDb(); + } +} diff --git a/lib/db/client.test.ts b/lib/db/client.test.ts new file mode 100644 index 0000000..e5bd553 --- /dev/null +++ b/lib/db/client.test.ts @@ -0,0 +1,51 @@ +import { mkdtemp, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { openGameDb, persist } from './client'; + +const dir = await mkdtemp(path.join(tmpdir(), 'sw-db-test-')); +const file = path.join(dir, 'test.db'); + +afterAll(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +function metaValue(db: Awaited>, key: string): string | undefined { + const rows = db.raw.exec('SELECT value FROM _meta WHERE key = ?', [key]); + return rows[0]?.values[0][0] as string | undefined; +} + +describe('db client (sql.js/Drizzle plumbing)', () => { + it('creates a valid DB file on disk when opening a fresh path', async () => { + const db = await openGameDb(file); + expect(db.file).toBe(file); + const s = await stat(file); + expect(s.size).toBeGreaterThan(0); // persisted at first open, not left in-memory only + expect(metaValue(db, 'created_at')).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('preserves state across reopen (volume persistence)', async () => { + const created = metaValue(await openGameDb(file), 'created_at'); + expect(created).toBeDefined(); + const db = await openGameDb(file); // second process would see the same file + expect(db.raw.exec('PRAGMA integrity_check')[0].values[0][0]).toBe('ok'); + expect(metaValue(db, 'created_at')).toBe(created); // not re-stamped on reopen + }); + + it('persist() rewrites the file atomically (tmp + rename)', async () => { + const db = await openGameDb(file); + await persist(db); + const s = await stat(file); + expect(s.size).toBeGreaterThan(0); + // no leftover tmp file after a clean persist + let tmpGone = true; + try { + await stat(`${file}.tmp`); + tmpGone = false; + } catch { + /* expected */ + } + expect(tmpGone).toBe(true); + }); +}); diff --git a/lib/db/client.ts b/lib/db/client.ts new file mode 100644 index 0000000..54b2984 --- /dev/null +++ b/lib/db/client.ts @@ -0,0 +1,75 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { drizzle, type SQLJsDatabase } from 'drizzle-orm/sql-js'; +import initSqlJs, { type Database as RawSqlJsDatabase } from 'sql.js'; +import * as schema from './schema'; + +// SQLite via sql.js (WASM) — no native compilation, so it builds and runs in the +// Docker image without a toolchain. Trade-off: the DB lives in memory and every write +// must be exported to disk; persist() does that atomically (tmp file + rename). + +export interface GameDb { + db: SQLJsDatabase; + raw: RawSqlJsDatabase; + file: string; +} + +// CREATE TABLE statements mirroring lib/db/schema.ts — keep in sync. Game tables land with +// the turn engine; _meta is DB plumbing (not game state): a brand-new sql.js database exports +// as ZERO bytes, so without at least one real table there would be nothing valid to persist +// on the ./db volume from first boot. +const DDL = ` +CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT); +`; + +// SPACE_WANDERER_DB_PATH override exists so unit tests can point at a temp file instead +// of the real ./db/space-wanderer.db. +export function defaultDbPath(): string { + return process.env.SPACE_WANDERER_DB_PATH ?? path.join(process.cwd(), 'db', 'space-wanderer.db'); +} + +async function open(file: string): Promise { + const SQL = await initSqlJs(); + let bytes: Buffer | undefined; + try { + bytes = await fs.readFile(file); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database(); + raw.exec(DDL); // no-op for tables that already exist in an older file + + const instance: GameDb = { db: drizzle(raw, { schema }), raw, file }; + + // First run: stamp when this DB was created and land the file on disk so the ./db volume + // mount has a valid SQLite file to persist from boot (instrumentation.ts opens at server start). + if (!bytes) { + raw.run('INSERT OR IGNORE INTO _meta (key, value) VALUES (?, ?)', ['created_at', new Date().toISOString()]); + await persist(instance); + } + return instance; +} + +// Durably save the in-memory DB: export bytes, write to a temp file, rename over +// the target so a crash mid-write never leaves a truncated DB. +export async function persist(instance: GameDb): Promise { + const bytes = instance.raw.export(); + await fs.mkdir(path.dirname(instance.file), { recursive: true }); + const tmp = `${instance.file}.tmp`; + await fs.writeFile(tmp, Buffer.from(bytes)); + await fs.rename(tmp, instance.file); +} + +// Singleton for the app process; openGameDb() is the uncached variant (tests). +// Lives on globalThis because Next.js can load this module more than once per process — +// instrumentation.ts and route handlers get separate module instances, so a plain `let` +// would give each its own in-memory sql.js DB (both persisting to the same file). One world per process. +const g = globalThis as unknown as { __spaceWandererGameDb?: Promise }; + +export function getGameDb(): Promise { + return (g.__spaceWandererGameDb ??= open(defaultDbPath())); +} + +export async function openGameDb(file: string): Promise { + return open(file); +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts new file mode 100644 index 0000000..f7f7410 --- /dev/null +++ b/lib/db/schema.ts @@ -0,0 +1,6 @@ +// Drizzle schema for game state. EMPTY on purpose at scaffold stage (v0.1.0) — no game +// tables yet; the first turn-engine/ship/crew tables land with the first game systems. +// Column names/types here are the source of truth; matching CREATE TABLE statements live +// in lib/db/client.ts (DDL) and must stay in sync with this file. + +export {};