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:
2026-09-17 11:51:42 +00:00
parent 4d9a1e9fa9
commit 7fdc43bc7e
23 changed files with 1006 additions and 8 deletions
+2
View File
@@ -1,6 +1,8 @@
node_modules
.next
data/*.db
# game state DB (v0.6.0): host-side, volume-mounted at runtime — never baked in
/db
test-results
*.tsbuildinfo
.git
+5
View File
@@ -17,6 +17,11 @@ test-results/
data/*.db
data/*.sqlite*
# game state SQLite (v0.6.0): lives on the host, volume-mounted into the
# container as ./db → /app/db so it survives rebuilds — never tracked.
# Anchored to repo root: an unanchored "db/" would also ignore lib/db/ (source).
/db/
# generated map-asset pack (~100 MB) — produced by `npm run assets:download`,
# served via the docker-compose volume mount, never tracked (v0.4.1)
public/styles/
+11
View File
@@ -1,5 +1,16 @@
# Changelog
## v0.6.0 — 2026-09-17 11:49
- Data foundation (PLAN.md "Simulation engine" increment 1): Drizzle ORM + SQLite via **sql.js (WASM)** — chosen over better-sqlite3 because it needs zero native compilation, so the existing oven/bun-alpine install stages and node:22-alpine runtime work unchanged; deps `drizzle-orm@0.45`, `sql.js@1.14` (+ dev-only `@types/sql.js`)
- Schema (`lib/db/schema.ts`, DDL mirrored in `lib/db/client.ts`): `planes` (id, model, status available|flying, fuel/maxFuel, locationIcao, contractId?, lat/lon), `contracts` (id, origin/dest ICAO, reward, cargoType/cargoCount, deadlineSimTime, status open|accepted|completed, planeId?, createdSimTime), single-row `account` (cash, reputation) and `sim_state` (clockMs, timeScale 1–60, paused)
- First-run seed (`lib/game/seed.ts`) — pure `buildSeed({airports, rng, nowMs})`, fully deterministic with an injected RNG (mulberry32 PRNG exported for tests): starter plane **ATR 72-600** (in-code catalog constant in `lib/game/catalog.ts`: maxFuel 6300 L, burnPerKm 1.75 L/km, speedKmh 510 — structured so more types can be added) grounded full-tank at Frankfurt (**EDDF**; EDDM is Munich), 8–12 open contracts between random distinct CSV airports with reward `round(500 + 2.5 · haversineKm)` (documented simple formula; real revenue/cost modeling is a later increment), cargo flavor (Passengers ≤68 / pallets ≤50), deadline = now + 6–72h game time; account starts at cash 20 000 / reputation 10; simState clockMs = real now, timeScale 1, paused false
- Persistence: DB file `db/flight.db` on the host (gitignored) volume-mounted in docker-compose as `./db:/app/db` — a dedicated dir, NOT over `/app/data` (which would mask airports_eu.csv); sql.js keeps the DB in memory, so `persist()` exports the bytes and writes them atomically (tmp file + rename) after every mutation; seeding happens lazily on first access when the account table is empty; `FLIGHT_DB_PATH` env override lets tests point at temp files
- New read-only `GET /api/state` (`app/api/state/route.ts`): full snapshot `{account, simState, planes[], contracts[]}` for client polling — plane/contract shapes include airport names (locationName / originName / destName) resolved from the CSV; no tick loop yet, state is static after seed
- OpenAPI: `/api/state` documented with component schemas StateSnapshot/AccountState/SimState/PlaneState/ContractState (`lib/openapi.ts`)
- `next.config.ts`: `serverExternalPackages: ['sql.js']` — the WASM blob resolves relative to sql.js's dist dir, so it must not be bundled into a .next chunk; known drizzle-orm 0.45.x quirk worked around in code (commented): the sql-js driver drops the result mapper for relational `db.query.*` queries and returns raw snake_case rows, so all reads use plain `select()`
- Tests: unit — new `lib/geo.test.ts` (haversine), `lib/game/seed.test.ts` (determinism with fixed RNG, 8–12 count, distinct endpoints, reward formula vs haversine, deadline window, hub + fallback, account/simState values), `lib/db/client.test.ts` (first-run seed + on-disk file, no re-seed on reopen, persist() round-trip) and `app/api/state/route.test.ts` (200 + exact top-level shape against a temp DB); openapi tests updated for the third endpoint; e2e — new `e2e/state.spec.ts`: snapshot shape from the live origin, plus **restart-persistence** test that records cash/clockMs/ids, runs `docker compose restart flight`, waits for readiness and asserts everything unchanged (PLAN.md "sim clock persisted across sessions"); playwright.config gains `workers: 1` so the mid-run container restart can't race the map tests
- Verified: unit suite 47/47; `tsc --noEmit` clean; e2e 11/11 green against the rebuilt compose instance (incl. both new state tests); live curl `/api/state` → Frankfurt starter plane + 11 contracts, `/api/airports` still serves 242 features after the mount change; persistence across a full `docker compose up -d --build` verified manually (clockMs/cash/contract count identical before/after)
## v0.5.0 — 2026-09-17 10:15
- New `GET /api/version` (`app/api/version/route.ts`): returns `{ "version": "<semver>" }`, version read from package.json via a server-side import (same single-source-of-truth pattern as the header tag)
- OpenAPI 3.1 spec in code: `lib/openapi.ts` exports an object documenting exactly the two public endpoints — `GET /api/airports` (GeoJSON FeatureCollection, with component schemas for feature collection/feature/point geometry/properties) and `GET /api/version`; `info.version` is imported from package.json so it stays in sync automatically; `servers: [{ url: "" }]` means "current origin"
+59
View File
@@ -0,0 +1,59 @@
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 { GET } from './route';
let dir: string;
beforeAll(async () => {
// Point the app's DB singleton at a throwaway file so this test never
// touches ./db/flight.db. getGameDb() reads the env var on first call, and
// vitest gives each test file its own module registry (fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-state-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'state.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
describe('GET /api/state', () => {
it('returns 200 with the full snapshot shape', async () => {
const res = await GET();
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('application/json');
const body = (await res.json()) as Record<string, unknown>;
expect(Object.keys(body).sort()).toEqual(['account', 'contracts', 'planes', 'simState']);
const account = body.account as { cash: number; reputation: number };
expect(typeof account.cash).toBe('number');
expect(typeof account.reputation).toBe('number');
const sim = body.simState as { clockMs: number; timeScale: number; paused: boolean };
expect(typeof sim.clockMs).toBe('number');
expect(typeof sim.timeScale).toBe('number');
expect(sim.paused).toBe(false);
const planes = body.planes as Array<Record<string, unknown>>;
expect(planes.length).toBeGreaterThanOrEqual(1);
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'lat', 'lon']) {
expect(planes[0], `plane.${key}`).toHaveProperty(key);
}
expect(typeof planes[0].locationName).toBe('string');
const contracts = body.contracts as Array<Record<string, unknown>>;
expect(contracts.length).toBeGreaterThan(0);
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'deadlineSimTime', 'status']) {
expect(contracts[0], `contract.${key}`).toHaveProperty(key);
}
});
it('is stable across repeated calls (same seeded DB)', async () => {
const a = ((await GET()).json()) as Promise<{ account: { cash: number } }>;
const b = ((await GET()).json()) as Promise<{ account: { cash: number } }>;
expect((await b).account.cash).toBe((await a).account.cash);
});
});
+10
View File
@@ -0,0 +1,10 @@
import { getStateSnapshot } from '@/lib/game/state';
export async function GET() {
try {
return Response.json(await getStateSnapshot());
} catch (err) {
console.error('failed to load game state', err);
return Response.json({ error: 'failed to load game state' }, { status: 500 });
}
}
+11
View File
@@ -5,15 +5,18 @@
"": {
"name": "flight-ops",
"dependencies": {
"drizzle-orm": "^0.45.2",
"maplibre-gl": "^6.10.0",
"next": "^16.3.5",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"sql.js": "^1.14.2",
},
"devDependencies": {
"@types/node": "^22.20.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/sql.js": "^1.4.11",
"typescript": "^7.0.2",
"vitest": "^5.0.1",
},
@@ -158,6 +161,8 @@
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/emscripten": ["@types/emscripten@1.41.6", "", {}, "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
@@ -168,6 +173,8 @@
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
"@types/sql.js": ["@types/sql.js@1.4.11", "", { "dependencies": { "@types/emscripten": "*", "@types/node": "*" } }, "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ=="],
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
@@ -228,6 +235,8 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
"earcut": ["earcut@3.2.3", "", {}, "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q=="],
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
@@ -318,6 +327,8 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"sql.js": ["sql.js@1.14.2", "", {}, "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
+3
View File
@@ -5,6 +5,9 @@ services:
# populates ./public; absent/empty → app falls back to remote OFM styles.
volumes:
- "./public:/app/public"
# Game state SQLite (v0.6.0): seeded on first run, must survive rebuilds.
# Dedicated dir — do NOT mount over /app/data (would mask airports_eu.csv).
- "./db:/app/db"
networks:
- dev_net
labels:
+2 -2
View File
@@ -13,12 +13,12 @@ test('GET /api/version returns the app version from package.json', async ({ page
expect(body.version).toBe(pkg.version);
});
test('GET /api/openapi serves the spec with both endpoints and the package.json version', async ({ page }) => {
test('GET /api/openapi serves the spec with all three endpoints and the package.json version', async ({ page }) => {
const res = await page.request.get('/api/openapi');
expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('application/json');
const spec = (await res.json()) as { info: { title: string; version: string }; paths: Record<string, unknown> };
expect(spec.info.title).toBe('Flight Ops API');
expect(spec.info.version).toBe(pkg.version);
expect(Object.keys(spec.paths).sort()).toEqual(['/api/airports', '/api/version']);
expect(Object.keys(spec.paths).sort()).toEqual(['/api/airports', '/api/state', '/api/version']);
});
+75
View File
@@ -0,0 +1,75 @@
import { execSync } from 'node:child_process';
import path from 'node:path';
import { test, expect } from '@playwright/test';
// Repo root — docker-compose.yml lives here; `docker compose restart` runs on
// this host (the test runner), browsers stay remote.
const repoRoot = path.join(__dirname, '..');
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: Array<Record<string, unknown>>;
contracts: Array<Record<string, unknown>>;
}
test('GET /api/state returns a seeded snapshot', async ({ page }) => {
const res = await page.request.get('/api/state');
expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('application/json');
const body = (await res.json()) as StateBody;
expect(typeof body.account.cash).toBe('number');
expect(typeof body.account.reputation).toBe('number');
expect(typeof body.simState.clockMs).toBe('number');
expect(typeof body.simState.timeScale).toBe('number');
expect(body.simState.paused).toBe(false);
expect(Array.isArray(body.planes)).toBe(true);
expect(body.planes.length).toBeGreaterThanOrEqual(1);
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'lat', 'lon']) {
expect(body.planes[0], `plane.${key}`).toHaveProperty(key);
}
expect(Array.isArray(body.contracts)).toBe(true);
expect(body.contracts.length).toBeGreaterThan(0);
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'deadlineSimTime', 'status']) {
expect(body.contracts[0], `contract.${key}`).toHaveProperty(key);
}
});
test('state survives a container restart (sim clock + account persisted)', async ({ page }) => {
test.setTimeout(180_000);
const resBefore = await page.request.get('/api/state');
expect(resBefore.status()).toBe(200);
const before = (await resBefore.json()) as StateBody;
execSync('docker compose restart flight', { cwd: repoRoot, stdio: 'pipe' });
// Wait for the app to come back, capturing the post-restart snapshot.
let after: StateBody | undefined;
await expect
.poll(async () => {
try {
const res = await page.request.get('/api/state');
if (res.status() === 200) after = (await res.json()) as StateBody;
return !!after;
} catch {
return false; // connection refused / 502 while the container starts
}
}, { timeout: 120_000, message: 'waiting for /api/state after docker compose restart' })
.toBe(true);
expect(after).toBeDefined();
const a = after!;
// The sim clock and account must be byte-identical — the DB file on the
// volume is loaded, not re-seeded (PLAN.md: "sim clock persisted across sessions").
expect(a.simState.clockMs).toBe(before.simState.clockMs);
expect(a.account.cash).toBe(before.account.cash);
expect(a.account.reputation).toBe(before.account.reputation);
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
expect(a.contracts.map((c) => c.id).sort()).toEqual(before.contracts.map((c) => c.id).sort());
});
+69
View File
@@ -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);
});
});
+123
View File
@@ -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);
}
+46
View 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;
+26
View File
@@ -0,0 +1,26 @@
// Aircraft catalog — in-code constants for now (no DB table yet). One type is
// enough for the MVP; add more entries to PLANE_TYPES as they are designed.
export interface PlaneType {
id: string;
name: string;
// Fuel capacity, liters.
maxFuel: number;
// Fuel burn per km of flight, liters/km (cruise).
burnPerKm: number;
// Cruise speed, km/h — used by the sim tick to advance planes along routes.
speedKmh: number;
}
export const PLANE_TYPES: Record<string, PlaneType> = {
atr72: {
id: 'atr72',
name: 'ATR 72-600',
maxFuel: 6300,
burnPerKm: 1.75, // ~900 kg/h at 510 km/h cruise (Jet-A1 ≈ 0.8 kg/L)
speedKmh: 510,
},
};
// The plane the player starts with.
export const STARTER_MODEL = 'atr72';
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';
import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
import {
BASE_REWARD,
buildSeed,
CONTRACT_COUNT_MAX,
CONTRACT_COUNT_MIN,
HUB_ICAO,
mulberry32,
REWARD_PER_KM,
rewardForDistanceKm,
STARTING_CASH,
STARTING_REPUTATION,
type SeedAirport,
} from './seed';
const NOW = 1_750_000_000_000;
// Small fixed fixture — includes the hub (EDDF Frankfurt) so the happy path is covered.
const AIRPORTS: SeedAirport[] = [
{ icao: 'EDDF', name: 'Frankfurt Main', lat: 50.0333, lon: 8.5706 },
{ icao: 'EHAM', name: 'Amsterdam Schiphol', lat: 52.31, lon: 4.76 },
{ icao: 'EGLL', name: 'London Heathrow', lat: 51.47, lon: -0.454 },
{ icao: 'LEBC', name: 'Madrid Barajas', lat: 40.47, lon: -3.56 },
{ icao: 'LIRF', name: 'Rome Fiumicino', lat: 41.8, lon: 12.25 },
];
const seedWith = (rngSeed: number, airports: SeedAirport[] = AIRPORTS) =>
buildSeed({ airports, rng: mulberry32(rngSeed), nowMs: NOW });
describe('buildSeed', () => {
it('grounds the starter plane full of fuel at the hub airport', () => {
const seed = seedWith(42);
const type = PLANE_TYPES[STARTER_MODEL];
expect(seed.plane).toEqual({
id: 'plane-starter',
model: STARTER_MODEL,
status: 'available',
fuel: type.maxFuel,
maxFuel: type.maxFuel,
locationIcao: HUB_ICAO,
contractId: null,
lat: 50.0333,
lon: 8.5706,
});
});
it('falls back to the first airport when the hub ICAO is missing', () => {
const seed = buildSeed({ airports: AIRPORTS.slice(1), rng: mulberry32(42), nowMs: NOW });
expect(seed.plane.locationIcao).toBe(AIRPORTS[1].icao);
});
it('seeds 8–12 open contracts between distinct fixture airports', () => {
const seed = seedWith(42);
expect(seed.contracts.length).toBeGreaterThanOrEqual(CONTRACT_COUNT_MIN);
expect(seed.contracts.length).toBeLessThanOrEqual(CONTRACT_COUNT_MAX);
const icaoSet = new Set(AIRPORTS.map((a) => a.icao));
for (const c of seed.contracts) {
expect(icaoSet.has(c.originIcao)).toBe(true);
expect(icaoSet.has(c.destIcao)).toBe(true);
expect(c.originIcao).not.toBe(c.destIcao);
expect(c.status).toBe('open');
expect(c.planeId).toBeNull();
expect(c.createdSimTime).toBe(NOW);
}
expect(new Set(seed.contracts.map((c) => c.id)).size).toBe(seed.contracts.length);
});
it('scales reward by haversine distance: round(500 + 2.5 * km)', () => {
const seed = seedWith(42);
for (const c of seed.contracts) {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
}
});
it('sets deadlines inside the 6–72h game-time window after now', () => {
const seed = seedWith(42);
for (const c of seed.contracts) {
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
}
});
it('seeds the starting account and sim state', () => {
const seed = seedWith(42);
expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION });
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false });
});
it('is fully deterministic for a fixed RNG and inputs', () => {
expect(seedWith(42)).toEqual(seedWith(42));
});
it('produces different contracts for a different RNG seed', () => {
expect(seedWith(1).contracts).not.toEqual(seedWith(2).contracts);
});
it('rejects an airport list too small to form a route', () => {
expect(() => buildSeed({ airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
});
it('rewardForDistanceKm applies the documented formula', () => {
expect(rewardForDistanceKm(0)).toBe(BASE_REWARD);
expect(rewardForDistanceKm(1000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000));
});
});
+156
View File
@@ -0,0 +1,156 @@
import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
// First-run seed. Everything here is a pure function of (airports, rng, nowMs)
// so it is fully deterministic and unit-testable: the RNG is injected, never
// read internally. Production calls this with a crypto-random seed; tests use
// mulberry32(fixed).
export interface SeedAirport {
icao: string;
name: string;
lat: number;
lon: number;
}
export type Rng = () => number;
// Small deterministic PRNG (mulberry32) — good enough for game seeding, and it
// makes tests reproducible. Returns floats in [0, 1).
export function mulberry32(seed: number): Rng {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export interface SeedPlane {
id: string;
model: string;
status: 'available' | 'flying';
fuel: number;
maxFuel: number;
locationIcao: string;
contractId: null;
lat: number;
lon: number;
}
export interface SeedContract {
id: string;
originIcao: string;
destIcao: string;
reward: number;
cargoType: string;
cargoCount: number;
deadlineSimTime: number;
status: 'open';
planeId: null;
createdSimTime: number;
}
export interface SeedData {
plane: SeedPlane;
contracts: SeedContract[];
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: false };
}
// --- Tunables (documented so future revenue/cost work can build on them) ---
export const STARTING_CASH = 20_000;
export const STARTING_REPUTATION = 10;
// The starter plane grounds at a large central-European hub. If the ICAO is
// ever missing from the dataset we fall back to the first airport rather than
// failing first run. (EDDF = Frankfurt; EDDM is Munich — don't mix them up.)
export const HUB_ICAO = 'EDDF'; // Frankfurt Main
export const CONTRACT_COUNT_MIN = 8;
export const CONTRACT_COUNT_MAX = 12; // inclusive
// Reward formula (v0.6.0 — deliberately simple; real revenue/cost modeling is a
// later increment): reward = BASE_REWARD + REWARD_PER_KM * great-circle km.
export const BASE_REWARD = 500;
export const REWARD_PER_KM = 2.5;
// Contract deadlines: now + a plausible game-time window of 6–72 hours.
const DEADLINE_MIN_HOURS = 6;
const DEADLINE_MAX_HOURS = 72;
const CARGO_TYPES = ['Passengers', 'Mail', 'Perishables', 'Electronics', 'Auto parts'] as const;
export function rewardForDistanceKm(km: number): number {
return Math.round(BASE_REWARD + REWARD_PER_KM * km);
}
function pickHub(airports: SeedAirport[]): SeedAirport {
return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0];
}
export function buildSeed(input: {
airports: SeedAirport[];
rng: Rng;
nowMs: number;
}): SeedData {
const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('seed needs at least two airports');
const type = PLANE_TYPES[STARTER_MODEL];
const hub = pickHub(airports);
const plane: SeedPlane = {
id: 'plane-starter',
model: STARTER_MODEL,
status: 'available',
fuel: type.maxFuel, // starts full
maxFuel: type.maxFuel,
locationIcao: hub.icao,
contractId: null,
lat: hub.lat,
lon: hub.lon,
};
const count = CONTRACT_COUNT_MIN + Math.floor(rng() * (CONTRACT_COUNT_MAX - CONTRACT_COUNT_MIN + 1));
const contracts: SeedContract[] = [];
for (let i = 0; i < count; i++) {
// Stable index-based ids for the seed set; runtime-generated entities must
// use random UUIDs to avoid colliding with these.
const oi = Math.floor(rng() * airports.length);
let di = Math.floor(rng() * airports.length);
if (di === oi) di = (di + 1) % airports.length;
const origin = airports[oi];
const dest = airports[di];
const km = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
const cargoType = CARGO_TYPES[Math.floor(rng() * CARGO_TYPES.length)];
// Passengers up to the ATR 72's ~68 seats; other cargo in pallets.
const cargoCount =
cargoType === 'Passengers' ? 1 + Math.floor(rng() * 68) : 1 + Math.floor(rng() * 50);
const deadlineMs = (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
contracts.push({
id: `contract-${i + 1}`,
originIcao: origin.icao,
destIcao: dest.icao,
reward: rewardForDistanceKm(km),
cargoType,
cargoCount,
deadlineSimTime: Math.round(nowMs + deadlineMs),
status: 'open',
planeId: null,
createdSimTime: nowMs,
});
}
return {
plane,
contracts,
account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION },
simState: { clockMs: nowMs, timeScale: 1, paused: false },
};
}
+91
View File
@@ -0,0 +1,91 @@
import { loadAirportsGeoJson } from '@/lib/airports';
import { getGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
// Read-only game-state snapshot for client polling (GET /api/state).
export interface PlaneState {
id: string;
model: string;
status: 'available' | 'flying';
fuel: number;
maxFuel: number;
locationIcao: string;
locationName: string;
contractId: string | null;
lat: number;
lon: number;
}
export interface ContractState {
id: string;
originIcao: string;
originName: string;
destIcao: string;
destName: string;
reward: number;
cargoType: string;
cargoCount: number;
deadlineSimTime: number;
status: 'open' | 'accepted' | 'completed';
planeId: string | null;
createdSimTime: number;
}
export interface StateSnapshot {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: PlaneState[];
contracts: ContractState[];
}
export async function getStateSnapshot(): Promise<StateSnapshot> {
const { db } = await getGameDb();
// NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm
// 0.45.x the sql-js driver drops the result mapper for relational queries and
// returns raw rows keyed by SQL column names (snake_case, no type decoding).
const [accountRows, simRows, planeRows, contractRows] = await Promise.all([
db.select().from(schema.account),
db.select().from(schema.simState),
db.select().from(schema.planes),
db.select().from(schema.contracts),
]);
const accountRow = accountRows[0];
const simRow = simRows[0];
if (!accountRow || !simRow) throw new Error('game state missing account/sim_state row');
const fc = await loadAirportsGeoJson();
const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name]));
const nameOf = (icao: string) => nameByIcao.get(icao) ?? icao;
return {
account: { cash: accountRow.cash, reputation: accountRow.reputation },
simState: { clockMs: simRow.clockMs, timeScale: simRow.timeScale, paused: simRow.paused },
planes: planeRows.map((p) => ({
id: p.id,
model: p.model,
status: p.status,
fuel: p.fuel,
maxFuel: p.maxFuel,
locationIcao: p.locationIcao,
locationName: nameOf(p.locationIcao),
contractId: p.contractId,
lat: p.lat,
lon: p.lon,
})),
contracts: contractRows.map((c) => ({
id: c.id,
originIcao: c.originIcao,
originName: nameOf(c.originIcao),
destIcao: c.destIcao,
destName: nameOf(c.destIcao),
reward: c.reward,
cargoType: c.cargoType,
cargoCount: c.cargoCount,
deadlineSimTime: c.deadlineSimTime,
status: c.status,
planeId: c.planeId,
createdSimTime: c.createdSimTime,
})),
};
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { haversineKm } from './geo';
describe('haversineKm', () => {
it('returns 0 for identical points', () => {
expect(haversineKm(50.03, 8.57, 50.03, 8.57)).toBeCloseTo(0, 10);
});
it('computes the London → Paris great-circle distance (~343 km)', () => {
const km = haversineKm(51.47, -0.454, 49.0097, 2.5479); // LHR → CDG
expect(km).toBeGreaterThan(330);
expect(km).toBeLessThan(360);
});
it('is symmetric', () => {
const a = haversineKm(50.03, 8.57, 40.47, -3.56); // FRA → MAD
const b = haversineKm(40.47, -3.56, 50.03, 8.57);
expect(a).toBeCloseTo(b, 10);
});
});
+12
View File
@@ -0,0 +1,12 @@
const EARTH_RADIUS_KM = 6371;
// Great-circle distance between two WGS84 points (haversine), in km.
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
}
+51 -2
View File
@@ -3,8 +3,12 @@ import { describe, expect, it } from 'vitest';
import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => {
it('documents exactly the two public endpoints', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual(['/api/airports', '/api/version']);
it('documents exactly the three public endpoints', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
'/api/airports',
'/api/state',
'/api/version',
]);
});
it('keeps info.version in sync with package.json', () => {
@@ -46,4 +50,49 @@ describe('OpenAPI spec', () => {
expect(Object.keys(props)).toEqual(['version']);
expect(props.version.type).toBe('string');
});
it('describes /api/state as a StateSnapshot with account, simState, planes and contracts', () => {
const schema = openApiSpec.paths['/api/state'].get.responses[200].content['application/json'].schema;
expect(schema.$ref).toBe('#/components/schemas/StateSnapshot');
const snap = openApiSpec.components.schemas.StateSnapshot.properties;
expect(Object.keys(snap).sort()).toEqual(['account', 'contracts', 'planes', 'simState']);
expect(snap.account.$ref).toBe('#/components/schemas/AccountState');
expect(snap.simState.$ref).toBe('#/components/schemas/SimState');
expect(snap.planes.items.$ref).toBe('#/components/schemas/PlaneState');
expect(snap.contracts.items.$ref).toBe('#/components/schemas/ContractState');
const plane = openApiSpec.components.schemas.PlaneState.properties;
expect(Object.keys(plane).sort()).toEqual([
'contractId',
'fuel',
'id',
'lat',
'locationIcao',
'locationName',
'lon',
'maxFuel',
'model',
'status',
]);
const contract = openApiSpec.components.schemas.ContractState.properties;
expect(Object.keys(contract).sort()).toEqual([
'cargoCount',
'cargoType',
'createdSimTime',
'deadlineSimTime',
'destIcao',
'destName',
'id',
'originIcao',
'originName',
'planeId',
'reward',
'status',
]);
const sim = openApiSpec.components.schemas.SimState.properties;
expect(Object.keys(sim).sort()).toEqual(['clockMs', 'paused', 'timeScale']);
});
});
+110 -1
View File
@@ -8,7 +8,7 @@ export const openApiSpec = {
info: {
title: 'Flight Ops API',
description:
'JSON API for Flight Ops — EU airport data for the map, plus app metadata.',
'JSON API for Flight Ops — EU airport data for the map, the live game-state snapshot, and app metadata.',
// Sourced from package.json so it stays in sync with every version bump.
version: pkg.version,
},
@@ -33,6 +33,24 @@ export const openApiSpec = {
},
},
},
'/api/state': {
get: {
summary: 'Game state snapshot',
description:
'Full current game state for client polling — account, sim clock, planes and contracts. Read-only.',
operationId: 'getState',
responses: {
200: {
description: 'Current game state',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/StateSnapshot' },
},
},
},
},
},
},
'/api/version': {
get: {
summary: 'App version',
@@ -104,6 +122,97 @@ export const openApiSpec = {
},
required: ['name', 'icao', 'lat', 'lon'],
},
StateSnapshot: {
type: 'object',
properties: {
account: { $ref: '#/components/schemas/AccountState' },
simState: { $ref: '#/components/schemas/SimState' },
planes: {
type: 'array',
items: { $ref: '#/components/schemas/PlaneState' },
},
contracts: {
type: 'array',
items: { $ref: '#/components/schemas/ContractState' },
},
},
required: ['account', 'simState', 'planes', 'contracts'],
},
AccountState: {
type: 'object',
properties: {
cash: { type: 'number' },
reputation: { type: 'number' },
},
required: ['cash', 'reputation'],
},
SimState: {
type: 'object',
properties: {
clockMs: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
timeScale: { type: 'number', minimum: 1, maximum: 60 },
paused: { type: 'boolean' },
},
required: ['clockMs', 'timeScale', 'paused'],
},
PlaneState: {
type: 'object',
properties: {
id: { type: 'string' },
model: { type: 'string' },
status: { enum: ['available', 'flying'] },
fuel: { type: 'number' },
maxFuel: { type: 'number' },
locationIcao: { type: 'string' },
locationName: { type: 'string' },
contractId: { type: ['string', 'null'] },
lat: { type: 'number' },
lon: { type: 'number' },
},
required: [
'id',
'model',
'status',
'fuel',
'maxFuel',
'locationIcao',
'locationName',
'contractId',
'lat',
'lon',
],
},
ContractState: {
type: 'object',
properties: {
id: { type: 'string' },
originIcao: { type: 'string' },
originName: { type: 'string' },
destIcao: { type: 'string' },
destName: { type: 'string' },
reward: { type: 'number' },
cargoType: { type: 'string' },
cargoCount: { type: 'integer' },
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
status: { enum: ['open', 'accepted', 'completed'] },
planeId: { type: ['string', 'null'] },
createdSimTime: { type: 'integer' },
},
required: [
'id',
'originIcao',
'originName',
'destIcao',
'destName',
'reward',
'cargoType',
'cargoCount',
'deadlineSimTime',
'status',
'planeId',
'createdSimTime',
],
},
},
},
};
+6 -1
View File
@@ -1,5 +1,10 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {};
const nextConfig: NextConfig = {
// sql.js loads its SQLite WASM blob relative to its own dist/ dir at runtime;
// bundling it into a .next chunk would break that resolution. Keeping it
// external means require('sql.js') hits node_modules (kept in the image).
serverExternalPackages: ['sql.js'],
};
export default nextConfig;
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
@@ -11,15 +11,18 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"drizzle-orm": "^0.45.2",
"maplibre-gl": "^6.10.0",
"next": "^16.3.5",
"react": "^19.3.0",
"react-dom": "^19.3.0"
"react-dom": "^19.3.0",
"sql.js": "^1.14.2"
},
"devDependencies": {
"@types/node": "^22.20.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/sql.js": "^1.4.11",
"typescript": "^7.0.2",
"vitest": "^5.0.1"
}
+3
View File
@@ -12,6 +12,9 @@ if (!process.env.PW_TEST_CONNECT_WS_ENDPOINT) {
export default defineConfig({
testDir: './e2e',
// Serial execution: e2e/state.spec.ts restarts the compose container mid-run,
// which would break any map test running concurrently in another worker.
workers: 1,
timeout: 30_000,
retries: 1,
use: {