db: sql.js/Drizzle connection + atomic persist (empty schema)

Flight-Ops plumbing minus game code: in-memory WASM DB, persist() via tmp+rename,
globalThis singleton. Schema is empty at scaffold stage; _meta plumbing table stamps
created_at on first open so the ./db volume holds a valid SQLite file from boot
(a brand-new sql.js database exports as zero bytes). instrumentation.ts opens it at
server start behind a runtime-guarded dynamic import (keeps node:fs out of the Edge
instrumentation bundle).
This commit is contained in:
2026-09-28 15:56:34 +00:00
parent 323b00d692
commit d0f52133f0
4 changed files with 142 additions and 0 deletions
+51
View File
@@ -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<ReturnType<typeof openGameDb>>, 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);
});
});
+75
View File
@@ -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<typeof schema>;
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<GameDb> {
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<void> {
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<GameDb> };
export function getGameDb(): Promise<GameDb> {
return (g.__spaceWandererGameDb ??= open(defaultDbPath()));
}
export async function openGameDb(file: string): Promise<GameDb> {
return open(file);
}
+6
View File
@@ -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 {};