feat: v0.6.0 — data foundation: Drizzle ORM + SQLite (sql.js/WASM) persistence, first-run seed, read-only GET /api/state snapshot
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { openGameDb, persist } from './client';
|
||||
import * as schema from './schema';
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-db-'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('openGameDb', () => {
|
||||
it('seeds a fresh DB file on first open and persists it to disk', async () => {
|
||||
const file = path.join(dir, 'fresh.db');
|
||||
const inst = await openGameDb(file);
|
||||
|
||||
const planes = await inst.db.select().from(schema.planes);
|
||||
expect(planes).toHaveLength(1);
|
||||
expect(planes[0].id).toBe('plane-starter');
|
||||
expect(planes[0].status).toBe('available');
|
||||
expect(planes[0].locationIcao).toBe('EDDF'); // Frankfurt hub from the CSV
|
||||
expect(planes[0].fuel).toBe(planes[0].maxFuel);
|
||||
|
||||
const contracts = await inst.db.select().from(schema.contracts);
|
||||
expect(contracts.length).toBeGreaterThanOrEqual(8);
|
||||
expect(contracts.every((c) => c.status === 'open')).toBe(true);
|
||||
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
expect(account?.cash).toBeGreaterThan(0);
|
||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||
expect(sim?.paused).toBe(false);
|
||||
expect(sim?.timeScale).toBe(1);
|
||||
|
||||
// The seed was durably written: the file exists and is non-empty.
|
||||
const st = await fs.stat(file);
|
||||
expect(st.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not re-seed an existing DB — state survives reopen', async () => {
|
||||
const file = path.join(dir, 'reopen.db');
|
||||
const first = await openGameDb(file);
|
||||
const contracts1 = (await first.db.select().from(schema.contracts)).map((c) => c.id).sort();
|
||||
const account1 = (await first.db.select().from(schema.account))[0];
|
||||
|
||||
// Simulate a process restart: brand-new instance over the same file.
|
||||
const second = await openGameDb(file);
|
||||
const contracts2 = (await second.db.select().from(schema.contracts)).map((c) => c.id).sort();
|
||||
expect(contracts2).toEqual(contracts1);
|
||||
expect((await second.db.select().from(schema.account))[0]).toEqual(account1);
|
||||
});
|
||||
|
||||
it('persist() durably saves writes made after open', async () => {
|
||||
const file = path.join(dir, 'write.db');
|
||||
const inst = await openGameDb(file);
|
||||
|
||||
await inst.db.update(schema.account).set({ cash: 12345 });
|
||||
await persist(inst);
|
||||
|
||||
// A fresh instance over the same file sees the update.
|
||||
const reloaded = await openGameDb(file);
|
||||
expect((await reloaded.db.select().from(schema.account))[0]?.cash).toBe(12345);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
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 { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { buildSeed, mulberry32 } from '@/lib/game/seed';
|
||||
import * as schema from './schema';
|
||||
|
||||
// SQLite via sql.js (WASM) — no native compilation, so it builds and runs in the
|
||||
// alpine 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) and is called after each mutation.
|
||||
|
||||
export interface GameDb {
|
||||
db: SQLJsDatabase<typeof schema>;
|
||||
raw: RawSqlJsDatabase;
|
||||
file: string;
|
||||
}
|
||||
|
||||
// CREATE TABLE statements mirroring lib/db/schema.ts — keep in sync.
|
||||
const DDL = `
|
||||
CREATE TABLE IF NOT EXISTS planes (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
fuel REAL NOT NULL,
|
||||
max_fuel REAL NOT NULL,
|
||||
location_icao TEXT NOT NULL,
|
||||
contract_id TEXT,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS contracts (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
origin_icao TEXT NOT NULL,
|
||||
dest_icao TEXT NOT NULL,
|
||||
reward REAL NOT NULL,
|
||||
cargo_type TEXT NOT NULL,
|
||||
cargo_count INTEGER NOT NULL,
|
||||
deadline_sim_time INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
plane_id TEXT,
|
||||
created_sim_time INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS account (
|
||||
cash REAL NOT NULL,
|
||||
reputation REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sim_state (
|
||||
clock_ms INTEGER NOT NULL,
|
||||
time_scale REAL NOT NULL,
|
||||
paused INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
// FLIGHT_DB_PATH override exists so unit tests can point at a temp file instead
|
||||
// of the real ./db/flight.db.
|
||||
export function defaultDbPath(): string {
|
||||
return process.env.FLIGHT_DB_PATH ?? path.join(process.cwd(), 'db', 'flight.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);
|
||||
|
||||
const instance: GameDb = { db: drizzle(raw, { schema }), raw, file };
|
||||
|
||||
// First-run seed: only when the DB is empty (no account row yet).
|
||||
const res = raw.exec('SELECT COUNT(*) FROM account');
|
||||
if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) {
|
||||
await seedEmpty(instance);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
async function seedEmpty(instance: GameDb): Promise<void> {
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const airports = fc.features.map((f) => ({
|
||||
icao: f.properties.icao,
|
||||
name: f.properties.name,
|
||||
lat: f.properties.lat,
|
||||
lon: f.properties.lon,
|
||||
}));
|
||||
// Random per-install seed; tests call buildSeed directly with a fixed RNG.
|
||||
const rng = mulberry32(randomInt(0, 2 ** 31));
|
||||
const data = buildSeed({ airports, rng, nowMs: Date.now() });
|
||||
|
||||
await instance.db.insert(schema.planes).values(data.plane);
|
||||
await instance.db.insert(schema.contracts).values(data.contracts);
|
||||
await instance.db.insert(schema.account).values(data.account);
|
||||
await instance.db.insert(schema.simState).values(data.simState);
|
||||
await persist(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).
|
||||
let ready: Promise<GameDb> | null = null;
|
||||
|
||||
export function getGameDb(): Promise<GameDb> {
|
||||
if (!ready) ready = open(defaultDbPath());
|
||||
return ready;
|
||||
}
|
||||
|
||||
export async function openGameDb(file: string): Promise<GameDb> {
|
||||
return open(file);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
// Drizzle schema for the game state. Column names/types here are the source of
|
||||
// truth; the matching CREATE TABLE statements live in lib/db/client.ts (DDL) and
|
||||
// must stay in sync with this file.
|
||||
|
||||
export const planes = sqliteTable('planes', {
|
||||
id: text('id').primaryKey().notNull(),
|
||||
model: text('model').notNull(),
|
||||
status: text('status', { enum: ['available', 'flying'] }).notNull(),
|
||||
fuel: real('fuel').notNull(),
|
||||
maxFuel: real('max_fuel').notNull(),
|
||||
locationIcao: text('location_icao').notNull(),
|
||||
contractId: text('contract_id'),
|
||||
lat: real('lat').notNull(),
|
||||
lon: real('lon').notNull(),
|
||||
});
|
||||
|
||||
export const contracts = sqliteTable('contracts', {
|
||||
id: text('id').primaryKey().notNull(),
|
||||
originIcao: text('origin_icao').notNull(),
|
||||
destIcao: text('dest_icao').notNull(),
|
||||
reward: real('reward').notNull(),
|
||||
cargoType: text('cargo_type').notNull(),
|
||||
cargoCount: integer('cargo_count').notNull(),
|
||||
deadlineSimTime: integer('deadline_sim_time').notNull(),
|
||||
status: text('status', { enum: ['open', 'accepted', 'completed'] }).notNull(),
|
||||
planeId: text('plane_id'),
|
||||
createdSimTime: integer('created_sim_time').notNull(),
|
||||
});
|
||||
|
||||
export const account = sqliteTable('account', {
|
||||
cash: real('cash').notNull(),
|
||||
reputation: real('reputation').notNull(),
|
||||
});
|
||||
|
||||
export const simState = sqliteTable('sim_state', {
|
||||
clockMs: integer('clock_ms').notNull(),
|
||||
timeScale: real('time_scale').notNull(),
|
||||
paused: integer('paused', { mode: 'boolean' }).notNull(),
|
||||
});
|
||||
|
||||
export type PlaneRow = typeof planes.$inferSelect;
|
||||
export type ContractRow = typeof contracts.$inferSelect;
|
||||
export type AccountRow = typeof account.$inferSelect;
|
||||
export type SimStateRow = typeof simState.$inferSelect;
|
||||
Reference in New Issue
Block a user