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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {};
|
||||
Reference in New Issue
Block a user