From ed45a84e5a4b93e5d2caca0f8cb36948d0d6e270 Mon Sep 17 00:00:00 2001 From: Aaron Wood Date: Fri, 10 Apr 2026 23:56:00 -0400 Subject: [PATCH] feat: add MariaDB schema migration and runner --- server/migrations/001_initial_schema.sql | 133 +++++++++++++++++++++++ server/src/migrate.ts | 43 ++++++++ 2 files changed, 176 insertions(+) create mode 100644 server/migrations/001_initial_schema.sql create mode 100644 server/src/migrate.ts diff --git a/server/migrations/001_initial_schema.sql b/server/migrations/001_initial_schema.sql new file mode 100644 index 0000000..1931120 --- /dev/null +++ b/server/migrations/001_initial_schema.sql @@ -0,0 +1,133 @@ +CREATE TABLE IF NOT EXISTS users ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL, + username VARCHAR(100) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + avatar_url VARCHAR(500) DEFAULT NULL, + created_at DATETIME DEFAULT NOW(), + UNIQUE KEY uq_users_email (email) +); + +CREATE TABLE IF NOT EXISTS campaigns ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + created_at DATETIME DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS campaign_members ( + campaign_id INT UNSIGNED NOT NULL, + user_id INT UNSIGNED NOT NULL, + role ENUM('dm', 'player') NOT NULL, + joined_at DATETIME DEFAULT NOW(), + PRIMARY KEY (campaign_id, user_id), + FOREIGN KEY (campaign_id) REFERENCES campaigns(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS campaign_invites ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + campaign_id INT UNSIGNED NOT NULL, + token VARCHAR(64) NOT NULL, + created_by INT UNSIGNED NOT NULL, + expires_at DATETIME DEFAULT NULL, + created_at DATETIME DEFAULT NOW(), + UNIQUE KEY uq_invites_token (token), + FOREIGN KEY (campaign_id) REFERENCES campaigns(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS characters ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + campaign_id INT UNSIGNED NOT NULL, + user_id INT UNSIGNED DEFAULT NULL, + name VARCHAR(255) NOT NULL, + class VARCHAR(100) NOT NULL DEFAULT 'Fighter', + ancestry VARCHAR(100) NOT NULL DEFAULT 'Human', + level INT NOT NULL DEFAULT 1, + xp INT NOT NULL DEFAULT 0, + hp_current INT NOT NULL DEFAULT 0, + hp_max INT NOT NULL DEFAULT 0, + ac INT NOT NULL DEFAULT 10, + alignment VARCHAR(50) NOT NULL DEFAULT 'Neutral', + title VARCHAR(255) DEFAULT '', + notes TEXT DEFAULT '', + background VARCHAR(255) DEFAULT '', + deity VARCHAR(255) DEFAULT '', + languages VARCHAR(500) DEFAULT '', + gp INT NOT NULL DEFAULT 0, + sp INT NOT NULL DEFAULT 0, + cp INT NOT NULL DEFAULT 0, + gear_slots_max INT NOT NULL DEFAULT 10, + overrides TEXT DEFAULT '{}', + color VARCHAR(100) DEFAULT '', + luck_token TINYINT NOT NULL DEFAULT 1, + torch_lit_at DATETIME DEFAULT NULL, + FOREIGN KEY (campaign_id) REFERENCES campaigns(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS character_stats ( + character_id INT UNSIGNED NOT NULL, + stat_name VARCHAR(10) NOT NULL, + value INT NOT NULL DEFAULT 10, + PRIMARY KEY (character_id, stat_name), + FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS character_gear ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + character_id INT UNSIGNED NOT NULL, + name VARCHAR(255) NOT NULL, + type VARCHAR(50) NOT NULL DEFAULT 'gear', + slot_count INT NOT NULL DEFAULT 1, + properties TEXT DEFAULT '{}', + effects TEXT DEFAULT '{}', + game_item_id INT UNSIGNED DEFAULT NULL, + FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS character_talents ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + character_id INT UNSIGNED NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + effect TEXT DEFAULT '{}', + game_talent_id INT UNSIGNED DEFAULT NULL, + FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS game_items ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + type VARCHAR(50) NOT NULL, + slot_count INT NOT NULL DEFAULT 1, + effects TEXT DEFAULT '{}', + properties TEXT DEFAULT '{}' +); + +CREATE TABLE IF NOT EXISTS game_talents ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + source VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + effect TEXT DEFAULT '{}' +); + +CREATE TABLE IF NOT EXISTS roll_log ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + campaign_id INT UNSIGNED NOT NULL, + character_id INT UNSIGNED DEFAULT NULL, + character_name VARCHAR(255) NOT NULL DEFAULT 'Roll', + type VARCHAR(50) NOT NULL DEFAULT 'custom', + label VARCHAR(255) NOT NULL, + dice_expression VARCHAR(255) NOT NULL, + rolls TEXT NOT NULL DEFAULT '[]', + modifier INT NOT NULL DEFAULT 0, + total INT NOT NULL DEFAULT 0, + advantage TINYINT NOT NULL DEFAULT 0, + disadvantage TINYINT NOT NULL DEFAULT 0, + nat20 TINYINT NOT NULL DEFAULT 0, + character_color VARCHAR(100) DEFAULT '', + created_at DATETIME DEFAULT NOW(), + FOREIGN KEY (campaign_id) REFERENCES campaigns(id) ON DELETE CASCADE +); diff --git a/server/src/migrate.ts b/server/src/migrate.ts new file mode 100644 index 0000000..3772bba --- /dev/null +++ b/server/src/migrate.ts @@ -0,0 +1,43 @@ +import type { Pool } from "mysql2/promise"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MIGRATIONS_DIR = path.join(__dirname, "..", "..", "migrations"); + +export async function runMigrations(pool: Pool): Promise { + await pool.execute(` + CREATE TABLE IF NOT EXISTS _migrations ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + filename VARCHAR(255) NOT NULL UNIQUE, + run_at DATETIME DEFAULT NOW() + ) + `); + + const files = fs + .readdirSync(MIGRATIONS_DIR) + .filter((f) => f.endsWith(".sql")) + .sort(); + + for (const file of files) { + const [rows] = await pool.execute( + "SELECT id FROM _migrations WHERE filename = ?", + [file] + ); + if (rows.length > 0) continue; + + const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), "utf-8"); + const statements = sql + .split(";") + .map((s) => s.trim()) + .filter((s) => s.length > 0 && !s.startsWith("--")); + + for (const stmt of statements) { + await pool.execute(stmt); + } + + await pool.execute("INSERT INTO _migrations (filename) VALUES (?)", [file]); + console.log(`Migration applied: ${file}`); + } +}