Backend: - Complete FastAPI backend with 42+ REST endpoints (auth, servers, config, players, bans, missions, mods, games, system) - Game adapter architecture with Arma 3 as first-class adapter - WebSocket real-time events for status, metrics, logs, players - Background thread system (process monitor, metrics, log tail, RCon poller) - Fernet encryption for sensitive config fields at rest - JWT auth with admin/viewer roles, bcrypt password hashing - SQLite with WAL mode, parameterized queries, migration system - APScheduler cleanup jobs for logs, metrics, events Frontend: - Server Detail page with 7 tabs (overview, config, players, bans, missions, mods, logs) - Settings page with password change and admin user management - Create Server wizard (4-step; known bug: silent validation failure) - New hooks: useServerDetail, useAuth, useGames - New components: ServerHeader, ConfigEditor, PlayerTable, BanTable, MissionList, ModList, LogViewer, PasswordChange, UserManager - WebSocket onEvent callback for real-time log accumulation - 120 unit tests passing (Vitest + React Testing Library) Docs: - Added .gitignore, CLAUDE.md, README.md - Updated FRONTEND.md, ARCHITECTURE.md with current implementation state - Added .env.example for backend configuration Known issues: - Create Server form: "Next" buttons don't validate before advancing, causing silent submit failure when fields are invalid - Config sub-tabs need UX redesign for non-technical users
896 lines
37 KiB
Markdown
896 lines
37 KiB
Markdown
# 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:<base64-token>"` 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:<current_version>"`
|
|
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/<game_type>/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;
|
|
``` |