Files
hexifyer/server/db.ts
Axel Meyer 367ba8af07 Phase 3: Express backend, SQLite persistence, auto-save
- server/db.ts: sql.js with migration system (hex_maps, hexes, hex_features)
- server/routes/maps.ts: CRUD for hex maps
- server/routes/hexes.ts: Bulk hex upsert, region load, sparse storage
- server/index.ts: Express 5, CORS, tile serving, SPA fallback
- src/data/api-client.ts: Frontend HTTP client for all API endpoints
- src/main.ts: Auto-save with 1s debounce, load map state on startup
- Port 3002 (Kiepenkerl uses 3001)
- Graceful fallback when API unavailable (works without server too)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:45:37 +00:00

114 lines
3.2 KiB
TypeScript

import initSqlJs, { type Database } from 'sql.js';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_PATH = process.env.DB_PATH || resolve(__dirname, '..', 'data', 'hexifyer.db');
let db: Database;
interface Migration {
name: string;
up: () => void;
}
const MIGRATIONS: Migration[] = [
{
name: '001-initial-schema',
up() {
db.run(`
CREATE TABLE IF NOT EXISTS hex_maps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
image_width INTEGER NOT NULL DEFAULT 8000,
image_height INTEGER NOT NULL DEFAULT 12000,
tile_url TEXT NOT NULL DEFAULT '/tiles/{z}/{x}/{y}.jpg',
min_zoom INTEGER NOT NULL DEFAULT 0,
max_zoom INTEGER NOT NULL DEFAULT 6,
hex_size REAL NOT NULL DEFAULT 48.0,
origin_x REAL NOT NULL DEFAULT 0.0,
origin_y REAL NOT NULL DEFAULT 0.0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS hexes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
map_id INTEGER NOT NULL,
q INTEGER NOT NULL,
r INTEGER NOT NULL,
base_terrain TEXT NOT NULL DEFAULT 'plains',
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (map_id) REFERENCES hex_maps(id) ON DELETE CASCADE,
UNIQUE(map_id, q, r)
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS hex_features (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hex_id INTEGER NOT NULL,
terrain_id TEXT NOT NULL,
edge_mask INTEGER NOT NULL,
FOREIGN KEY (hex_id) REFERENCES hexes(id) ON DELETE CASCADE
)
`);
db.run('CREATE INDEX IF NOT EXISTS idx_hexes_map_coord ON hexes(map_id, q, r)');
db.run('CREATE INDEX IF NOT EXISTS idx_hex_features_hex ON hex_features(hex_id)');
},
},
];
function runMigrations(): void {
db.run(`
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT DEFAULT (datetime('now'))
)
`);
const applied = new Set<string>();
const rows = db.exec('SELECT name FROM schema_migrations');
if (rows.length > 0) {
for (const row of rows[0].values) applied.add(row[0] as string);
}
for (const m of MIGRATIONS) {
if (applied.has(m.name)) continue;
console.log(`[db] Running migration: ${m.name}`);
m.up();
db.run('INSERT INTO schema_migrations (name) VALUES (?)', [m.name]);
}
}
export async function initDb(): Promise<Database> {
const SQL = await initSqlJs();
if (existsSync(DB_PATH)) {
const buf = readFileSync(DB_PATH);
db = new SQL.Database(buf);
console.log(`[db] Loaded existing database from ${DB_PATH}`);
} else {
db = new SQL.Database();
console.log('[db] Created new database');
}
runMigrations();
saveDb();
return db;
}
export function saveDb(): void {
mkdirSync(dirname(DB_PATH), { recursive: true });
const data = db.export();
writeFileSync(DB_PATH, Buffer.from(data));
}
export function getDb(): Database {
return db;
}