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>
This commit is contained in:
Axel Meyer
2026-04-07 10:45:37 +00:00
parent 0e2903b789
commit 367ba8af07
8 changed files with 523 additions and 6 deletions

113
server/db.ts Normal file
View File

@@ -0,0 +1,113 @@
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;
}

52
server/index.ts Normal file
View File

@@ -0,0 +1,52 @@
import express from 'express';
import cors from 'cors';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { existsSync } from 'fs';
import { initDb } from './db.js';
import mapsRouter from './routes/maps.js';
import hexesRouter from './routes/hexes.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT) || 3002;
const DIST_DIR = resolve(__dirname, '..', 'dist');
const TILES_DIR = resolve(__dirname, '..', 'tiles');
const PUBLIC_TILES_DIR = resolve(__dirname, '..', 'public', 'tiles');
async function main() {
await initDb();
const app = express();
app.use(cors());
app.use(express.json({ limit: '10mb' }));
// API routes
app.use('/api/v1/maps', mapsRouter);
app.use('/api/v1/maps', hexesRouter);
// Serve tiles (check multiple locations)
const tilesPath = existsSync(TILES_DIR) ? TILES_DIR : PUBLIC_TILES_DIR;
if (existsSync(tilesPath)) {
app.use('/tiles', express.static(tilesPath, { maxAge: '30d', immutable: true }));
console.log(`[server] Serving tiles from ${tilesPath}`);
}
// Serve static frontend
if (existsSync(DIST_DIR)) {
app.use(express.static(DIST_DIR));
// SPA fallback (Express 5 syntax)
app.get('/{*splat}', (_req, res) => {
res.sendFile(resolve(DIST_DIR, 'index.html'));
});
console.log(`[server] Serving frontend from ${DIST_DIR}`);
}
app.listen(PORT, () => {
console.log(`[server] Hexifyer running on http://localhost:${PORT}`);
});
}
main().catch(err => {
console.error('Failed to start:', err);
process.exit(1);
});

141
server/routes/hexes.ts Normal file
View File

@@ -0,0 +1,141 @@
import { Router } from 'express';
import { getDb, saveDb } from '../db.js';
const router = Router();
// Get all hexes for a map (bulk load)
router.get('/:mapId/hexes', (req, res) => {
const db = getDb();
const mapId = req.params.mapId;
const hexRows = db.exec(
'SELECT id, q, r, base_terrain FROM hexes WHERE map_id = ?',
[mapId],
);
if (hexRows.length === 0) {
res.json([]);
return;
}
const hexIds = hexRows[0].values.map(row => row[0] as number);
const hexes: any[] = [];
// Fetch features for all hexes
const featureMap = new Map<number, any[]>();
if (hexIds.length > 0) {
// Batch query — SQLite doesn't have great IN with params for large sets,
// so we query all features for this map's hexes
const featureRows = db.exec(
`SELECT hf.hex_id, hf.terrain_id, hf.edge_mask
FROM hex_features hf
JOIN hexes h ON hf.hex_id = h.id
WHERE h.map_id = ?`,
[mapId],
);
if (featureRows.length > 0) {
for (const row of featureRows[0].values) {
const hexId = row[0] as number;
if (!featureMap.has(hexId)) featureMap.set(hexId, []);
featureMap.get(hexId)!.push({
terrainId: row[1],
edgeMask: row[2],
});
}
}
}
for (const row of hexRows[0].values) {
const hexId = row[0] as number;
hexes.push({
q: row[1],
r: row[2],
base: row[3],
features: featureMap.get(hexId) ?? [],
});
}
res.json(hexes);
});
// Bulk upsert hexes
router.put('/:mapId/hexes', (req, res) => {
const db = getDb();
const mapId = req.params.mapId;
const hexes: Array<{
q: number;
r: number;
base: string;
features: Array<{ terrainId: string; edgeMask: number }>;
}> = req.body;
if (!Array.isArray(hexes)) {
res.status(400).json({ error: 'Expected array of hex updates' });
return;
}
db.run('BEGIN TRANSACTION');
try {
for (const hex of hexes) {
// Upsert the hex
db.run(
`INSERT INTO hexes (map_id, q, r, base_terrain, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(map_id, q, r)
DO UPDATE SET base_terrain = excluded.base_terrain, updated_at = datetime('now')`,
[mapId, hex.q, hex.r, hex.base],
);
// Get the hex id
const idRows = db.exec(
'SELECT id FROM hexes WHERE map_id = ? AND q = ? AND r = ?',
[mapId, hex.q, hex.r],
);
const hexId = idRows[0].values[0][0] as number;
// Replace features
db.run('DELETE FROM hex_features WHERE hex_id = ?', [hexId]);
for (const feature of hex.features) {
if (feature.edgeMask === 0) continue;
db.run(
'INSERT INTO hex_features (hex_id, terrain_id, edge_mask) VALUES (?, ?, ?)',
[hexId, feature.terrainId, feature.edgeMask],
);
}
}
// Update map timestamp
db.run("UPDATE hex_maps SET updated_at = datetime('now') WHERE id = ?", [mapId]);
db.run('COMMIT');
saveDb();
res.json({ ok: true, count: hexes.length });
} catch (err) {
db.run('ROLLBACK');
res.status(500).json({ error: String(err) });
}
});
// Delete a hex (reset to default)
router.delete('/:mapId/hexes/:q/:r', (req, res) => {
const db = getDb();
const { mapId, q, r } = req.params;
const idRows = db.exec(
'SELECT id FROM hexes WHERE map_id = ? AND q = ? AND r = ?',
[mapId, q, r],
);
if (idRows.length > 0 && idRows[0].values.length > 0) {
const hexId = idRows[0].values[0][0];
db.run('DELETE FROM hex_features WHERE hex_id = ?', [hexId]);
db.run('DELETE FROM hexes WHERE id = ?', [hexId]);
saveDb();
}
res.json({ ok: true });
});
export default router;

85
server/routes/maps.ts Normal file
View File

@@ -0,0 +1,85 @@
import { Router } from 'express';
import { getDb, saveDb } from '../db.js';
const router = Router();
// List all maps
router.get('/', (_req, res) => {
const db = getDb();
const rows = db.exec('SELECT * FROM hex_maps ORDER BY updated_at DESC');
if (rows.length === 0) {
res.json([]);
return;
}
const maps = rows[0].values.map(row => rowToMap(rows[0].columns, row));
res.json(maps);
});
// Get single map
router.get('/:id', (req, res) => {
const db = getDb();
const rows = db.exec('SELECT * FROM hex_maps WHERE id = ?', [req.params.id]);
if (rows.length === 0 || rows[0].values.length === 0) {
res.status(404).json({ error: 'Map not found' });
return;
}
res.json(rowToMap(rows[0].columns, rows[0].values[0]));
});
// Create map
router.post('/', (req, res) => {
const db = getDb();
const { name, image_width, image_height, tile_url, min_zoom, max_zoom, hex_size, origin_x, origin_y } = req.body;
db.run(
`INSERT INTO hex_maps (name, image_width, image_height, tile_url, min_zoom, max_zoom, hex_size, origin_x, origin_y)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[name ?? 'Untitled', image_width ?? 8000, image_height ?? 12000,
tile_url ?? '/tiles/{z}/{x}/{y}.jpg', min_zoom ?? 0, max_zoom ?? 6,
hex_size ?? 48, origin_x ?? 0, origin_y ?? 0],
);
const idRows = db.exec('SELECT last_insert_rowid()');
const id = idRows[0].values[0][0];
saveDb();
res.status(201).json({ id });
});
// Update map
router.put('/:id', (req, res) => {
const db = getDb();
const { name, hex_size, origin_x, origin_y } = req.body;
const sets: string[] = [];
const params: any[] = [];
if (name !== undefined) { sets.push('name = ?'); params.push(name); }
if (hex_size !== undefined) { sets.push('hex_size = ?'); params.push(hex_size); }
if (origin_x !== undefined) { sets.push('origin_x = ?'); params.push(origin_x); }
if (origin_y !== undefined) { sets.push('origin_y = ?'); params.push(origin_y); }
if (sets.length === 0) {
res.status(400).json({ error: 'No fields to update' });
return;
}
sets.push("updated_at = datetime('now')");
params.push(req.params.id);
db.run(`UPDATE hex_maps SET ${sets.join(', ')} WHERE id = ?`, params);
saveDb();
res.json({ ok: true });
});
// Delete map
router.delete('/:id', (req, res) => {
const db = getDb();
db.run('DELETE FROM hex_maps WHERE id = ?', [req.params.id]);
saveDb();
res.json({ ok: true });
});
function rowToMap(columns: string[], values: any[]) {
const obj: any = {};
columns.forEach((col, i) => { obj[col] = values[i]; });
return obj;
}
export default router;