125 lines
4.5 KiB
TypeScript
125 lines
4.5 KiB
TypeScript
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. _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);
|
|
|
|
CREATE TABLE IF NOT EXISTS saves (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
seed TEXT NOT NULL,
|
|
turn INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS solar_systems (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
save_id INTEGER NOT NULL REFERENCES saves(id),
|
|
name TEXT NOT NULL,
|
|
x REAL NOT NULL,
|
|
y REAL NOT NULL,
|
|
discovered INTEGER NOT NULL DEFAULT 0,
|
|
is_current INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS jump_routes (
|
|
from_system INTEGER NOT NULL REFERENCES solar_systems(id),
|
|
to_system INTEGER NOT NULL REFERENCES solar_systems(id),
|
|
PRIMARY KEY (from_system, to_system)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS planets (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
system_id INTEGER NOT NULL REFERENCES solar_systems(id),
|
|
parent_id INTEGER, -- NULL for a planet, set to the parent planet's id for a moon
|
|
name TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
hazard INTEGER NOT NULL DEFAULT 0,
|
|
explored INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS inventory_items (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
save_id INTEGER NOT NULL REFERENCES saves(id),
|
|
kind TEXT NOT NULL, -- 'resource' | 'part'
|
|
name TEXT NOT NULL,
|
|
qty INTEGER NOT NULL DEFAULT 1
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
save_id INTEGER NOT NULL REFERENCES saves(id),
|
|
turn INTEGER NOT NULL,
|
|
type TEXT NOT NULL,
|
|
message TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
`;
|
|
|
|
// 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);
|
|
}
|