From 4b4d105009e011d50047607573ae6be491c66907 Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Thu, 19 Feb 2026 16:16:01 +0100 Subject: [PATCH] feat(server): add SQLite connection and migration system Adds better-sqlite3 with WAL mode, auto-migration runner that tracks applied migrations, and initial schema for ships and weapons tables with constraints. Co-Authored-By: Claude Opus 4.6 --- server/src/db/connection.ts | 16 +++++++ server/src/db/migrate.ts | 43 +++++++++++++++++++ server/src/db/migrations/001_create_ships.sql | 15 +++++++ .../src/db/migrations/002_create_weapons.sql | 17 ++++++++ server/src/index.ts | 3 ++ 5 files changed, 94 insertions(+) create mode 100644 server/src/db/connection.ts create mode 100644 server/src/db/migrate.ts create mode 100644 server/src/db/migrations/001_create_ships.sql create mode 100644 server/src/db/migrations/002_create_weapons.sql diff --git a/server/src/db/connection.ts b/server/src/db/connection.ts new file mode 100644 index 0000000..d2eb27a --- /dev/null +++ b/server/src/db/connection.ts @@ -0,0 +1,16 @@ +import Database, { type Database as DatabaseType } from 'better-sqlite3'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { DB_PATH } from '../config.js'; + +// Ensure the data directory exists +mkdirSync(dirname(DB_PATH), { recursive: true }); + +const db: DatabaseType = new Database(DB_PATH); + +// Enable WAL mode for better concurrent read performance +db.pragma('journal_mode = WAL'); +// Enable foreign key enforcement +db.pragma('foreign_keys = ON'); + +export default db; diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts new file mode 100644 index 0000000..395f1b5 --- /dev/null +++ b/server/src/db/migrate.ts @@ -0,0 +1,43 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import db from './connection.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const MIGRATIONS_DIR = join(__dirname, 'migrations'); + +export function runMigrations(): void { + // Create the migrations tracking table if it doesn't exist + db.exec(` + CREATE TABLE IF NOT EXISTS _migrations ( + name TEXT PRIMARY KEY, + applied_at INTEGER NOT NULL DEFAULT (unixepoch('now') * 1000) + ) + `); + + // Read all .sql files, sorted by name + const files = readdirSync(MIGRATIONS_DIR) + .filter((f) => f.endsWith('.sql')) + .sort(); + + const applied = new Set( + db + .prepare('SELECT name FROM _migrations') + .all() + .map((row: any) => row.name), + ); + + for (const file of files) { + if (applied.has(file)) continue; + + const sql = readFileSync(join(MIGRATIONS_DIR, file), 'utf-8'); + + const migrate = db.transaction(() => { + db.exec(sql); + db.prepare('INSERT INTO _migrations (name) VALUES (?)').run(file); + }); + + migrate(); + console.log(`Migration applied: ${file}`); + } +} diff --git a/server/src/db/migrations/001_create_ships.sql b/server/src/db/migrations/001_create_ships.sql new file mode 100644 index 0000000..d414ecf --- /dev/null +++ b/server/src/db/migrations/001_create_ships.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS ships ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT 'Unnamed Ship', + hull_current INTEGER NOT NULL DEFAULT 0, + hull_max INTEGER NOT NULL DEFAULT 0, + armor_current INTEGER NOT NULL DEFAULT 0, + armor_max INTEGER NOT NULL DEFAULT 0, + ac INTEGER NOT NULL DEFAULT 10, + con_save INTEGER, + speed INTEGER, + maneuver_class TEXT CHECK(maneuver_class IS NULL OR maneuver_class IN ('A','B','C','D','E','F','S')), + size_category TEXT, + notes TEXT, + updated_at INTEGER NOT NULL DEFAULT (unixepoch('now') * 1000) +); diff --git a/server/src/db/migrations/002_create_weapons.sql b/server/src/db/migrations/002_create_weapons.sql new file mode 100644 index 0000000..b46ec47 --- /dev/null +++ b/server/src/db/migrations/002_create_weapons.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS weapons ( + id TEXT PRIMARY KEY, + ship_id TEXT NOT NULL REFERENCES ships(id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT 'Unnamed Weapon', + type TEXT, + attack_mod INTEGER, + damage TEXT, + range TEXT, + ammo_current INTEGER, + ammo_max INTEGER, + status TEXT DEFAULT 'ok' CHECK(status IS NULL OR status IN ('ok','damaged','disabled')), + notes TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT (unixepoch('now') * 1000) +); + +CREATE INDEX IF NOT EXISTS idx_weapons_ship_id ON weapons(ship_id); diff --git a/server/src/index.ts b/server/src/index.ts index 706fe9c..4c2df9a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,5 +1,6 @@ import Fastify from 'fastify'; import { PORT } from './config.js'; +import { runMigrations } from './db/migrate.js'; const app = Fastify({ logger: true }); @@ -9,6 +10,8 @@ app.get('/', async () => { const start = async () => { try { + runMigrations(); + await app.listen({ port: PORT, host: '0.0.0.0' }); console.log(`Server running on port ${PORT}`); } catch (err) {