# Languard Servers Manager -- Database Schema ## Engine Configuration | Setting | Value | |---------|-------| | Engine | SQLite via SQLAlchemy Core (sync) | | File | `languard.db` (configurable via `LANGUARD_DB_PATH`) | | Journal mode | WAL (`PRAGMA journal_mode=WAL`) | | Foreign keys | ON (`PRAGMA foreign_keys=ON`) | | Busy timeout | 5000ms (`PRAGMA busy_timeout=5000`) | | Query style | Raw SQL through `sqlalchemy.text()` -- no ORM | | Concurrent access | WAL allows concurrent reads during writes; background threads use thread-local connections via `get_thread_db()` | | Connection model | API requests use `get_db()` (connect-commit/rollback per request); background threads use `get_thread_db()` (thread-local, manually closed) | ### Why WAL Mode SQLite in WAL mode permits simultaneous readers while a writer holds an exclusive lock. This is essential for the server manager architecture where a metrics collector thread, log tail thread, and API request handler may all access the database concurrently. The 5-second busy timeout prevents immediate "database is locked" failures under contention; if exceeded, the caller retries with exponential backoff. --- ## Design Philosophy The schema uses a **hybrid approach**: core tables are fully normalized and game-agnostic, while game-specific configuration is stored as JSON blobs in a generic `game_configs` table. Adapter-provided Pydantic models validate the JSON at the application layer. **Why this works for Languard:** - Config is always read and written as a complete section; no one queries individual keys across servers. - Each adapter section maps to a Pydantic model, so validation is enforced at the application boundary. - The JSON is opaque to the core -- meaningful only to the adapter that owns it. - Adding a new game requires **zero database migration** -- just a new adapter. - Core queries across all games (player counts, server status, logs) work naturally through normalized columns. --- ## Schema ### Table: `users` Web UI authentication accounts. ```sql CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'viewer', created_at TEXT NOT NULL DEFAULT (datetime('now')), last_login TEXT, CHECK (role IN ('admin', 'viewer')) ); ``` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | | `username` | TEXT | NOT NULL, UNIQUE | Login username | | `password_hash` | TEXT | NOT NULL | bcrypt hash of the user's password | | `role` | TEXT | NOT NULL, DEFAULT `'viewer'` | Authorization role. Allowed values: `admin`, `viewer` | | `created_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Account creation timestamp (ISO 8601) | | `last_login` | TEXT | nullable | Most recent successful login timestamp | **Notes:** - Only two roles exist: `admin` (full access) and `viewer` (read-only). The `CHECK` constraint enforces this at the database level. - `password_hash` stores bcrypt hashes, never plaintext. - No indexes beyond the implicit UNIQUE index on `username`. --- ### Table: `servers` One row per managed server instance. Game-agnostic; the `game_type` column determines which adapter handles the server. ```sql CREATE TABLE IF NOT EXISTS servers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT, game_type TEXT NOT NULL DEFAULT 'arma3', status TEXT NOT NULL DEFAULT 'stopped', pid INTEGER, exe_path TEXT NOT NULL, started_at TEXT, stopped_at TEXT, game_port INTEGER NOT NULL, rcon_port INTEGER, auto_restart INTEGER NOT NULL DEFAULT 0, max_restarts INTEGER NOT NULL DEFAULT 3, restart_window_seconds INTEGER NOT NULL DEFAULT 300, restart_count INTEGER NOT NULL DEFAULT 0, last_restart_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), CHECK (status IN ('stopped','starting','running','stopping','crashed','error')), CHECK (game_port BETWEEN 1024 AND 65535), CHECK (rcon_port IS NULL OR (rcon_port BETWEEN 1024 AND 65535)) ); CREATE INDEX IF NOT EXISTS idx_servers_status ON servers(status); CREATE INDEX IF NOT EXISTS idx_servers_game_type ON servers(game_type); CREATE INDEX IF NOT EXISTS idx_servers_game_port ON servers(game_port); ``` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | | `name` | TEXT | NOT NULL | Display name in the UI | | `description` | TEXT | nullable | Optional free-text description | | `game_type` | TEXT | NOT NULL, DEFAULT `'arma3'` | Adapter lookup key. Determines which game adapter handles this server | | `status` | TEXT | NOT NULL, DEFAULT `'stopped'` | Current server state. Allowed: `stopped`, `starting`, `running`, `stopping`, `crashed`, `error` | | `pid` | INTEGER | nullable | OS process ID when the server is running. NULL when stopped | | `exe_path` | TEXT | NOT NULL | Absolute path to the server executable | | `started_at` | TEXT | nullable | ISO datetime when the server last entered `running` status | | `stopped_at` | TEXT | nullable | ISO datetime when the server last left `running` status | | `game_port` | INTEGER | NOT NULL, 1024-65535 | Primary game port (the port players connect to) | | `rcon_port` | INTEGER | nullable, 1024-65535 | Remote console port. NULL if the game has no remote admin protocol | | `auto_restart` | INTEGER | NOT NULL, DEFAULT `0` | 1 = restart automatically on crash, 0 = no auto-restart | | `max_restarts` | INTEGER | NOT NULL, DEFAULT `3` | Maximum crash restarts allowed within `restart_window_seconds` | | `restart_window_seconds` | INTEGER | NOT NULL, DEFAULT `300` | Rolling window (in seconds) for counting consecutive restarts | | `restart_count` | INTEGER | NOT NULL, DEFAULT `0` | Current number of restarts within the window. Reset when the window expires | | `last_restart_at` | TEXT | nullable | ISO datetime of the most recent auto-restart | | `created_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Server creation timestamp | | `updated_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Last modification timestamp | **Indexes:** | Index | Columns | Purpose | |-------|---------|---------| | `idx_servers_status` | `status` | Filter servers by status (e.g., all running servers) | | `idx_servers_game_type` | `game_type` | Filter servers by game type | | `idx_servers_game_port` | `game_port` | Prevent port collisions; fast lookup by port | **Auto-restart behavior:** When a server crashes and `auto_restart = 1`, the system increments `restart_count`. If `restart_count` exceeds `max_restarts` within the last `restart_window_seconds`, auto-restart is disabled and a `max_restarts_exceeded` event is logged. The count resets when the server stays up for longer than `restart_window_seconds`. --- ### Table: `game_configs` Stores all game-specific configuration as JSON blobs, keyed by section. Replaces what would otherwise be multiple per-game config tables. ```sql CREATE TABLE IF NOT EXISTS game_configs ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, game_type TEXT NOT NULL, section TEXT NOT NULL, config_json TEXT NOT NULL DEFAULT '{}', config_version INTEGER NOT NULL DEFAULT 1, schema_version TEXT NOT NULL DEFAULT '1.0.0', updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(server_id, section) ); CREATE INDEX IF NOT EXISTS idx_game_configs_server ON game_configs(server_id); CREATE INDEX IF NOT EXISTS idx_game_configs_type_section ON game_configs(game_type, section); ``` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | | `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Owning server. Deleting the server removes all its config sections | | `game_type` | TEXT | NOT NULL | Redundant with `servers.game_type`, stored here for indexing without a JOIN. Must match the parent server's `game_type` | | `section` | TEXT | NOT NULL | Config section name (e.g., `server`, `basic`, `profile`, `launch`, `rcon` for Arma 3) | | `config_json` | TEXT | NOT NULL, DEFAULT `'{}'` | JSON blob validated by the adapter's Pydantic model. Sensitive fields are Fernet-encrypted before serialization | | `config_version` | INTEGER | NOT NULL, DEFAULT `1` | Optimistic locking version counter. Incremented on every write | | `schema_version` | TEXT | NOT NULL, DEFAULT `'1.0.0'` | Adapter schema version at the time of last write. Used for automatic config migration | | `updated_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Last write timestamp | **Unique constraint:** `(server_id, section)` -- each server can have at most one config section of each type. **Indexes:** | Index | Columns | Purpose | |-------|---------|---------| | `idx_game_configs_server` | `server_id` | Load all config for a server | | `idx_game_configs_type_section` | `game_type, section` | Look up all configs of a given game type and section (e.g., all Arma 3 `rcon` sections) | #### Fernet Encryption of Sensitive Fields Sensitive fields within `config_json` (passwords, RCON passwords, admin passwords) are encrypted at the application layer before JSON serialization and storage. The `core.utils.crypto` module handles this: - **Algorithm:** Fernet (AES-256-CBC with HMAC-SHA256 for authentication) - **Key source:** `LANGUARD_ENCRYPTION_KEY` environment variable (Fernet base64 key) - **Format:** Encrypted values are stored as `"encrypted:"` within the JSON blob - **Transparency:** `ConfigRepository._encrypt_sensitive()` and `_decrypt_sensitive()` handle encryption/decryption around JSON serialization. The adapter layer sees only plaintext. The adapter declares which fields are sensitive via `ConfigGenerator.get_sensitive_fields(section) -> list[str]`. For Arma 3: - Section `server`: `password`, `password_admin`, `server_command_password` - Section `rcon`: `rcon_password` On write, `ConfigRepository.upsert_section()` calls `_encrypt_sensitive()` which replaces declared fields with `"encrypted:..."` tokens. On read, `ConfigRepository.get_section()` calls `_decrypt_sensitive()` which restores plaintext. The `is_encrypted()` check (`value.startswith("encrypted:")`) ensures that already-encrypted values are not double-encrypted, and plaintext values pass through unchanged for backward compatibility. #### Optimistic Locking The `config_version` column prevents lost updates when two admins edit simultaneously: 1. Client reads config section, receives `_meta.config_version` (e.g., `3`) 2. Client sends PUT with `config_version: 3` in the request body 3. Server checks: if `expected_config_version` is provided and differs from the stored version, raise `ValueError` with `"CONFIG_VERSION_CONFLICT:"` 4. On conflict: return **409 Conflict** with the current config so the client can merge 5. On success: increment `config_version`, write new JSON, return the new version This is implemented in `ConfigRepository.upsert_section()`. #### Config Schema Migration When the adapter is updated and `get_config_version()` returns a newer version than `game_configs.schema_version`, the core automatically migrates: 1. On read, detect `stored_schema_version != adapter.get_config_version()` 2. Call `adapter.migrate_config(stored_schema_version, config_json)` -- returns migrated dict 3. Update the row: `SET config_json = ?, schema_version = ? WHERE id = ?` 4. On `ConfigMigrationError`: keep original config, log warning, server runs with the old schema Migration is per-section -- each section can have a different stored version. #### Arma 3 Config Sections Section `server` -- maps to `server.cfg` parameters: ```json { "hostname": "My Arma 3 Server", "password": "encrypted:...", "password_admin": "encrypted:...", "server_command_password": "encrypted:...", "max_players": 40, "kick_duplicate": 1, "persistent": 1, "vote_threshold": 0.33, "vote_mission_players": 1, "vote_timeout": 60, "role_timeout": 90, "briefing_timeout": 60, "debriefing_timeout": 45, "lobby_idle_timeout": 300, "disable_von": 0, "von_codec": 1, "von_codec_quality": 20, "max_ping": 250, "max_packet_loss": 50, "max_desync": 200, "disconnect_timeout": 15, "kick_on_ping": 1, "kick_on_packet_loss": 1, "kick_on_desync": 1, "kick_on_timeout": 1, "battleye": 1, "verify_signatures": 2, "allowed_file_patching": 0, "forced_difficulty": "Regular", "timestamp_format": "short", "auto_select_mission": 0, "random_mission_order": 0, "missions_to_restart": 0, "missions_to_shutdown": 0, "log_file": "server_console.log", "skip_lobby": 0, "drawing_in_map": 1, "upnp": 0, "loopback": 0, "statistics_enabled": 1, "force_rotor_lib": 0, "required_build": 0, "steam_protocol_max_data_size": 1024, "motd_lines": ["Welcome!", "Have fun"], "motd_interval": 5.0, "on_user_connected": "", "on_user_disconnected": "", "on_unsigned_data": "kick (_this select 0)", "on_hacked_data": "kick (_this select 0)", "double_id_detected": "", "headless_clients": [], "local_clients": [], "admin_uids": [], "allowed_load_extensions": ["hpp","sqs","sqf","fsm","cpp","paa","txt","xml","inc","ext","sqm","ods","fxy","lip","csv","kb","bik","bikb","html","htm","biedi"], "allowed_preprocess_extensions": ["hpp","sqs","sqf","fsm","cpp","paa","txt","xml","inc","ext","sqm","ods","fxy","lip","csv","kb","bik","bikb","html","htm","biedi"], "allowed_html_extensions": ["htm","html","xml","txt"] } ``` Section `basic` -- maps to `basic.cfg` parameters: ```json { "min_bandwidth": 800000, "max_bandwidth": 25000000, "max_msg_send": 384, "max_size_guaranteed": 512, "max_size_non_guaranteed": 256, "min_error_to_send": 0.003, "max_custom_file_size": 100000 } ``` Section `profile` -- maps to `server.Arma3Profile` difficulty: ```json { "reduced_damage": 0, "group_indicators": 0, "friendly_tags": 0, "enemy_tags": 0, "detected_mines": 0, "commands": 1, "waypoints": 1, "tactical_ping": 0, "weapon_info": 2, "stance_indicator": 2, "stamina_bar": 0, "weapon_crosshair": 0, "vision_aid": 0, "third_person_view": 0, "camera_shake": 1, "score_table": 1, "death_messages": 1, "von_id": 1, "map_content_friendly": 0, "map_content_enemy": 0, "map_content_mines": 0, "auto_report": 0, "multiple_saves": 0, "ai_level_preset": 3, "skill_ai": 0.5, "precision_ai": 0.5 } ``` Section `launch` -- maps to CLI launch parameters: ```json { "world": "empty", "extra_params": "", "limit_fps": 50, "auto_init": 0, "load_mission_to_memory": 0, "bandwidth_alg": null, "enable_ht": 0, "huge_pages": 0, "cpu_count": null, "ex_threads": 7, "max_mem": null, "no_logs": 0, "netlog": 0 } ``` Section `rcon` -- BattlEye RCon settings: ```json { "rcon_password": "encrypted:...", "max_ping": 200, "enabled": 1 } ``` --- ### Table: `mods` Registered mods. Scoped by `game_type` to keep the mod catalog per-game. ```sql CREATE TABLE IF NOT EXISTS mods ( id INTEGER PRIMARY KEY AUTOINCREMENT, game_type TEXT NOT NULL, name TEXT NOT NULL, folder_path TEXT NOT NULL, workshop_id TEXT, description TEXT, game_data TEXT DEFAULT '{}', created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE (game_type, folder_path) ); ``` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | | `game_type` | TEXT | NOT NULL | Game this mod belongs to (e.g., `arma3`). Prevents mod name collisions across games | | `name` | TEXT | NOT NULL | Human-readable mod name | | `folder_path` | TEXT | NOT NULL | Relative or absolute path to the mod folder (e.g., `@ace`) | | `workshop_id` | TEXT | nullable | Steam Workshop ID, if the mod was installed from Steam | | `description` | TEXT | nullable | Free-text description of the mod | | `game_data` | TEXT | DEFAULT `'{}'` | JSON blob for game-specific mod metadata. For Arma 3 this is typically `{}` | | `created_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Registration timestamp | **Unique constraint:** `(game_type, folder_path)` -- a mod folder path is unique within a game type. **No separate index beyond the UNIQUE constraint** -- lookups are by `game_type` or by `id` (via `server_mods`). --- ### Table: `server_mods` Junction table for the many-to-many relationship between servers and mods. ```sql CREATE TABLE IF NOT EXISTS server_mods ( server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, mod_id INTEGER NOT NULL REFERENCES mods(id) ON DELETE CASCADE, is_server_mod INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, game_data TEXT DEFAULT '{}', PRIMARY KEY (server_id, mod_id) ); CREATE INDEX IF NOT EXISTS idx_server_mods_server ON server_mods(server_id); ``` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Owning server | | `mod_id` | INTEGER | NOT NULL, FK -> `mods(id)` CASCADE DELETE | Referenced mod | | `is_server_mod` | INTEGER | NOT NULL, DEFAULT `0` | 1 = server-side only mod (not broadcast to clients); 0 = regular mod loaded by all | | `sort_order` | INTEGER | NOT NULL, DEFAULT `0` | Load order position. Lower values load first | | `game_data` | TEXT | DEFAULT `'{}'` | JSON blob for per-server mod overrides | **Composite primary key:** `(server_id, mod_id)` -- each mod can only be added once to a given server. **Cascade deletes:** Removing a server deletes its `server_mods` rows. Removing a mod deletes all `server_mods` rows referencing it. --- ### Table: `missions` Mission/scenario files tracked per server. ```sql CREATE TABLE IF NOT EXISTS missions ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, filename TEXT NOT NULL, mission_name TEXT NOT NULL, terrain TEXT, file_size INTEGER, game_data TEXT DEFAULT '{}', uploaded_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE (server_id, filename) ); CREATE INDEX IF NOT EXISTS idx_missions_server ON missions(server_id); ``` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | | `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Owning server | | `filename` | TEXT | NOT NULL | Mission file name (e.g., `MyMission.Altis.pbo`) | | `mission_name` | TEXT | NOT NULL | Parsed mission display name (extracted by the adapter) | | `terrain` | TEXT | nullable | Map/terrain name (e.g., `Altis`). NULL for games that do not use the mission/terrain naming convention | | `file_size` | INTEGER | nullable | File size in bytes | | `game_data` | TEXT | DEFAULT `'{}'` | JSON for game-specific mission metadata | | `uploaded_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Upload timestamp | **Unique constraint:** `(server_id, filename)` -- each filename is unique within a server's mission pool. --- ### Table: `mission_rotation` Ordered mission/scenario cycle for a server. Defines which missions are played and in what order. ```sql CREATE TABLE IF NOT EXISTS mission_rotation ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, mission_id INTEGER NOT NULL REFERENCES missions(id) ON DELETE CASCADE, sort_order INTEGER NOT NULL DEFAULT 0, difficulty TEXT, params_json TEXT NOT NULL DEFAULT '{}', game_data TEXT DEFAULT '{}', UNIQUE (server_id, sort_order) ); CREATE INDEX IF NOT EXISTS idx_mission_rotation_server ON mission_rotation(server_id); ``` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | | `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Owning server | | `mission_id` | INTEGER | NOT NULL, FK -> `missions(id)` CASCADE DELETE | Referenced mission | | `sort_order` | INTEGER | NOT NULL, DEFAULT `0` | Position in the rotation. Lower values come first | | `difficulty` | TEXT | nullable | Game-specific difficulty setting. NULL for games without difficulty levels | | `params_json` | TEXT | NOT NULL, DEFAULT `'{}'` | Mission parameters as JSON | | `game_data` | TEXT | DEFAULT `'{}'` | Adapter-specific rotation metadata | **Unique constraint:** `(server_id, sort_order)` -- prevents two missions from occupying the same position in the rotation. **Cascade deletes:** Removing a server deletes all its rotation entries. Removing a mission deletes all rotation entries referencing it. --- ### Table: `players` Currently connected players. This is live state, refreshed by the adapter's remote admin poller. ```sql CREATE TABLE IF NOT EXISTS players ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, slot_id TEXT NOT NULL, name TEXT NOT NULL, guid TEXT, ip TEXT, ping INTEGER, game_data TEXT DEFAULT '{}', joined_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE (server_id, slot_id) ); CREATE INDEX IF NOT EXISTS idx_players_server ON players(server_id); ``` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | | `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Server the player is connected to | | `slot_id` | TEXT | NOT NULL | Game-specific slot identifier (e.g., player number for Arma 3, Steam ID for other games) | | `name` | TEXT | NOT NULL | In-game display name | | `guid` | TEXT | nullable | Game-specific identifier (BattlEye GUID, Steam ID, etc.) | | `ip` | TEXT | nullable | Player IP address | | `ping` | INTEGER | nullable | Current ping in milliseconds | | `game_data` | TEXT | DEFAULT `'{}'` | JSON for game-specific player metadata (e.g., `{"verified": true, "steam_uid": "..."}` for Arma 3) | | `joined_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Connection timestamp | | `updated_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Last refresh timestamp | **Unique constraint:** `(server_id, slot_id)` -- each slot on a server can only hold one player at a time. **Lifecycle:** When a server stops, all rows for that `server_id` are deleted (clearing the live player list). Disconnection events create entries in `player_history`. --- ### Table: `player_history` Historical record of player sessions. Rows are inserted when a player disconnects. ```sql CREATE TABLE IF NOT EXISTS player_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, name TEXT NOT NULL, guid TEXT, ip TEXT, game_data TEXT DEFAULT '{}', joined_at TEXT NOT NULL, left_at TEXT NOT NULL DEFAULT (datetime('now')), session_duration_seconds INTEGER ); CREATE INDEX IF NOT EXISTS idx_player_history_server ON player_history(server_id); CREATE INDEX IF NOT EXISTS idx_player_history_guid ON player_history(guid); ``` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | | `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Server the player was connected to | | `name` | TEXT | NOT NULL | Player name at time of session | | `guid` | TEXT | nullable | Game-specific identifier. Indexed for looking up a player's connection history | | `ip` | TEXT | nullable | Player IP address at time of session | | `game_data` | TEXT | DEFAULT `'{}'` | JSON for game-specific historical data | | `joined_at` | TEXT | NOT NULL | Session start timestamp | | `left_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Session end timestamp | | `session_duration_seconds` | INTEGER | nullable | Calculated session length in seconds. NULL if the duration could not be determined | **Indexes:** | Index | Columns | Purpose | |-------|---------|---------| | `idx_player_history_server` | `server_id` | Query history for a specific server | | `idx_player_history_guid` | `guid` | Look up all sessions for a specific player across servers | **Note:** The `config.py` defines `player_history_retention_days: int = 90` but no automated cleanup job is currently registered for this table. A future scheduler job should delete rows where `left_at < datetime('now', '-90 days')`. --- ### Table: `bans` Ban records. Core concept is game-agnostic; ban file synchronization is handled by the adapter's `BanManager`. ```sql CREATE TABLE IF NOT EXISTS bans ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, guid TEXT, name TEXT, reason TEXT, banned_by TEXT, banned_at TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT, is_active INTEGER NOT NULL DEFAULT 1, game_data TEXT DEFAULT '{}', CHECK (is_active IN (0, 1)) ); CREATE INDEX IF NOT EXISTS idx_bans_server ON bans(server_id); CREATE INDEX IF NOT EXISTS idx_bans_guid ON bans(guid); CREATE INDEX IF NOT EXISTS idx_bans_active ON bans(is_active); ``` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | | `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Server the ban applies to | | `guid` | TEXT | nullable | Game-specific identifier of the banned player. Indexed for fast lookups | | `name` | TEXT | nullable | Player name at time of ban | | `reason` | TEXT | nullable | Ban reason entered by the admin | | `banned_by` | TEXT | nullable | Username of the admin who issued the ban | | `banned_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Timestamp when the ban was created | | `expires_at` | TEXT | nullable | Expiration timestamp. NULL means permanent ban | | `is_active` | INTEGER | NOT NULL, DEFAULT `1`, CHECK `(0, 1)` | 1 = active ban, 0 = lifted/expired ban | | `game_data` | TEXT | DEFAULT `'{}'` | JSON for game-specific ban data (e.g., `{"steam_uid": "...", "ip": "..."}`) | **Indexes:** | Index | Columns | Purpose | |-------|---------|---------| | `idx_bans_server` | `server_id` | Look up all bans for a server | | `idx_bans_guid` | `guid` | Fast lookup by player identifier | | `idx_bans_active` | `is_active` | Filter active vs. inactive bans | **Note:** Bans are not physically deleted when lifted; `is_active` is set to `0` to preserve the audit trail. --- ### Table: `logs` Parsed log lines from server log files. Rolling retention (7 days default). ```sql CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, timestamp TEXT NOT NULL, level TEXT NOT NULL DEFAULT 'info', message TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), CHECK (level IN ('info', 'warning', 'error')) ); CREATE INDEX IF NOT EXISTS idx_logs_server_ts ON logs(server_id, timestamp); CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level); CREATE INDEX IF NOT EXISTS idx_logs_created ON logs(created_at); ``` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | | `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Server that produced the log | | `timestamp` | TEXT | NOT NULL | Original log timestamp (from the log file, not insertion time) | | `level` | TEXT | NOT NULL, DEFAULT `'info'` | Severity. Allowed: `info`, `warning`, `error` | | `message` | TEXT | NOT NULL | Log message content | | `created_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Database insertion timestamp | **Indexes:** | Index | Columns | Purpose | |-------|---------|---------| | `idx_logs_server_ts` | `server_id, timestamp` | Time-range queries for a specific server | | `idx_logs_level` | `level` | Filter logs by severity | | `idx_logs_created` | `created_at` | Retention cleanup: delete rows older than N days | **Retention:** 7 days. Automated cleanup runs daily at 03:00 via APScheduler (`core.jobs.cleanup_jobs`). Deletes rows where `created_at < datetime('now', '-7 days')`. --- ### Table: `metrics` Time-series CPU, RAM, and player count snapshots. ```sql CREATE TABLE IF NOT EXISTS metrics ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, timestamp TEXT NOT NULL DEFAULT (datetime('now')), cpu_percent REAL, ram_mb REAL, player_count INTEGER ); CREATE INDEX IF NOT EXISTS idx_metrics_server_ts ON metrics(server_id, timestamp); ``` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | | `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Server being measured | | `timestamp` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Measurement timestamp | | `cpu_percent` | REAL | nullable | CPU usage percentage. NULL if unavailable | | `ram_mb` | REAL | nullable | RAM usage in megabytes. NULL if unavailable | | `player_count` | INTEGER | nullable | Number of connected players at this point in time | **Index:** | Index | Columns | Purpose | |-------|---------|---------| | `idx_metrics_server_ts` | `server_id, timestamp` | Time-range queries for a specific server's metrics | **Retention:** 1 day. Automated cleanup runs every 6 hours via APScheduler. Deletes rows where `timestamp < datetime('now', '-1 day')`. --- ### Table: `server_events` Audit trail of all significant events across the system. ```sql CREATE TABLE IF NOT EXISTS server_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, event_type TEXT NOT NULL, actor TEXT, detail TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_events_server ON server_events(server_id, created_at); ``` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | | `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Server the event relates to | | `event_type` | TEXT | NOT NULL | Event category. Core types: `started`, `stopped`, `crashed`, `restarted`, `config_updated`, `player_kicked`, `player_banned`, `admin_login`, `auto_restarted`, `max_restarts_exceeded`. Adapters may define additional event types | | `actor` | TEXT | nullable | Username or `'system'` for automated actions | | `detail` | TEXT | nullable | JSON string with event-specific data | | `created_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Event timestamp | **Index:** | Index | Columns | Purpose | |-------|---------|---------| | `idx_events_server` | `server_id, created_at` | Time-ordered event queries for a specific server | **Retention:** 30 days. Automated cleanup runs weekly on Sundays at 04:00 via APScheduler. Deletes rows where `created_at < datetime('now', '-30 days')`. --- ## Relationships Diagram ``` users (1) ────────────────────────────────────── (ref) server_events.actor servers (1) ──┬── (many) game_configs [JSON sections replace per-game config tables] ├── (many) server_mods ──── (many) mods [scoped by game_type] ├── (many) missions ├── (many) mission_rotation ──> missions ├── (many) players [live state, cleared on server stop] ├── (many) player_history [historical sessions] ├── (many) bans ├── (many) logs ├── (many) metrics └── (many) server_events Foreign key cascade: ON DELETE CASCADE on all server_id foreign keys. Deleting a server removes all associated data. ``` --- ## Data Access Layer The application uses **raw SQL** through SQLAlchemy's `text()` construct. There is no ORM. All queries are written explicitly in repository classes that inherit from `BaseRepository`. ### BaseRepository `core.dal.base_repository.BaseRepository` provides common query helpers: | Method | Description | |--------|-------------| | `_execute(query, params)` | Execute a parameterized SQL statement | | `_fetchone(query, params)` | Execute and return a single row as `dict` or `None` | | `_fetchall(query, params)` | Execute and return all rows as `list[dict]` | | `_lastrowid(query, params)` | Execute and return the last inserted row ID | All methods wrap queries in `sqlalchemy.text()` with named parameter binding (`:param_name` style). ### Repository Classes | Repository | File | Tables | |------------|------|--------| | `ConfigRepository` | `core/dal/config_repository.py` | `game_configs` | | `BanRepository` | `core/dal/ban_repository.py` | `bans` | | `PlayerRepository` | `core/dal/player_repository.py` | `players`, `player_history` | | `LogRepository` | `core/dal/log_repository.py` | `logs` | | `MetricsRepository` | `core/dal/metrics_repository.py` | `metrics` | | `EventRepository` | `core/dal/event_repository.py` | `server_events` | --- ## Migration Strategy Migrations are plain `.sql` files in `backend/core/migrations/`, applied in order at application startup. ### Naming Convention Files are named `{NNN}_{description}.sql` where `NNN` is a zero-padded version number: ``` 001_initial_schema.sql 002_add_game_type.sql 003_add_player_history_indexes.sql ``` ### Tracking Table ```sql CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')) ); ``` The migration runner in `database.py:run_migrations()`: 1. Creates the `schema_migrations` table if it does not exist. 2. Reads all `*.sql` files from `backend/core/migrations/`, sorted by filename. 3. Extracts the version number from the filename prefix (e.g., `001` -> version `1`). 4. Skips any version already recorded in `schema_migrations`. 5. Executes each statement in the file separately (split on `;`), since SQLite does not support `executescript` inside transactions. 6. Records the version in `schema_migrations` and commits. ### Adapter-Specific Migrations If an adapter requires tables beyond `game_configs`, adapter-specific migrations go in `adapters//migrations/` and are applied by the adapter's initialization logic. --- ## Retention Policies Automated cleanup is managed by APScheduler jobs registered in `core/jobs/cleanup_jobs.py`: | Table | Retention | Cleanup Schedule | Cleanup Column | |-------|-----------|------------------|----------------| | `logs` | 7 days | Daily at 03:00 | `created_at` | | `metrics` | 1 day | Every 6 hours | `timestamp` | | `server_events` | 30 days | Weekly (Sunday 04:00) | `created_at` | **Player history** has a configurable retention of 90 days (`LANGUARD_PLAYER_HISTORY_RETENTION_DAYS`) but no cleanup job is currently registered. Cleanup queries follow the pattern: ```sql DELETE FROM {table} WHERE {column} < datetime('now', '-{N} days'); ``` --- ## `game_data` JSON Columns Several tables include a `game_data` column (TEXT, default `'{}'`) for adapter-specific metadata that does not fit the common schema. This is the extensibility mechanism that allows new game adapters to store game-specific data without altering the database schema. | Table | Column | Example (Arma 3) | |-------|--------|-------------------| | `players.game_data` | Player metadata | `{"verified": true, "steam_uid": "76561198012345678"}` | | `missions.game_data` | Mission metadata | `{"terrain": "Altis"}` | | `mods.game_data` | Mod metadata | `{}` (empty for Arma 3) | | `server_mods.game_data` | Per-server mod overrides | `{}` | | `mission_rotation.game_data` | Rotation metadata | `{}` | | `bans.game_data` | Ban metadata | `{"steam_uid": "76561198012345678", "ip": "192.168.1.100"}` | | `player_history.game_data` | Historical metadata | `{}` | Each adapter's capability protocols optionally provide a Pydantic schema for their `game_data` fields. If an adapter does not define a schema, the field accepts any valid JSON object (no validation). --- ## Maintenance Queries ### Clear disconnected players on server stop ```sql DELETE FROM players WHERE server_id = :server_id; ``` ### Find active bans for a player by GUID ```sql SELECT * FROM bans WHERE server_id = :server_id AND guid = :guid AND is_active = 1 AND (expires_at IS NULL OR expires_at > datetime('now')); ``` ### Get current player count for a server ```sql SELECT COUNT(*) FROM players WHERE server_id = :server_id; ``` ### Vacuum (run weekly during low traffic) ```sql VACUUM; ```