|
VoltMod
C++23 framework for CS2 server plugins
|
VoltMod::Database is async access to Postgres, MariaDB or SQLite, chosen at runtime from config. One worker thread owns the connection, jobs run FIFO on it, and completions replay on the game thread each frame, so a callback may touch players, menus and plugin managers.
Ask for it in CMake; the package always ships it, but a plugin links it and the sqlpp23 connectors only on request:
Connect parses the driver, spawns the worker and verifies connectivity with a ping. It returns false on an invalid config or an unreachable database, so the plugin can degrade instead of queueing work that cannot run.
Call Stop from the App destructor, after the managers owning its callbacks are gone. It lets queued jobs finish within stopDeadline (5s by default) so a ban written just before unload still lands, then drops the rest, releases blocked waiters with a failure, and destroys undispatched completions unrun. It is idempotent and the destructor calls it.
VoltMod::DatabaseConfig fields are lowercase, so a JSON section maps onto them with no mapper:
| Field | Default | Notes |
|---|---|---|
driver | "postgres" | "postgres", "mariadb" or "sqlite"; anything else fails Connect |
host | "localhost" | ignored by sqlite |
port | 0 | 0 uses the driver default (5432 Postgres, 3306 MariaDB); ignored by sqlite |
database | "voltmod_server" | |
username | "voltmod_plugin" | |
password | "" | |
sslMode | "prefer" | Postgres: disable, allow, prefer, require, verify-ca, verify-full; MariaDB turns TLS on for require and the verify modes; ignored by sqlite |
connectTimeoutSec | 5 | bounds every (re)connect attempt |
path | "" | sqlite file, relative to the game dir; ":memory:" is allowed |
A job is a callable taking auto& conn, compiled once per connection type. It must return the same type on every driver, so dialect differences go in if constexpr branches on IsPostgres, IsMariaDb and IsSqlite. A bare name blocks, an Async name returns first.
| Call | Behavior |
|---|---|
RunAsync(name, fn, onDone) | the gameplay path: enqueue and return; onDone runs on the game thread when the worker finishes |
Run(name, fn) | enqueue and wait. Load time only - Plugin::Load, migrations, an admin reload - never per frame or per event |
RunOr(name, fn, fallback) | Run, folding a failure into a value |
name is a log label. An onDone taking the value alone fires only on success, the failure having already been logged; taking the whole Result<T> shows it. Run returns VoltMod::Result<T>: a job that never ran because the database is stopping or down carries ErrorCode::NotReady, and one that threw carries ErrorCode::Failed with the driver's message in Error::Detail.
Capture by value - the job outlives the call that enqueued it.
MariaDB has neither RETURNING nor ON CONFLICT, so a portable upsert is update-then-insert: VoltMod::Upsert(conn, update, insert) runs the update and inserts only when nothing matched. It is not atomic, so back the natural key with a UNIQUE constraint and a race fails one job rather than duplicating a row.
conn("...") runs one raw statement and is for DDL and DML only - never a raw SELECT on MariaDB, which leaves an unread result set on the connection.
Name the migrations, the header and its namespace in the plugin's plugin.json:
Then generate the header from the migrations:
It renders the migrations for Postgres, runs sqlpp23-ddl2cpp, and writes the header, skipping the indexes and inserts its grammar cannot parse - the migrations stay the one source of truth. Commit the header and run the same command with --check in your lint task, so a schema change that skips the generator fails the build.
Hand-write a spec only where there is no DDL to read, using VOLTMOD_COLUMN from <VoltMod/Database/Table.hpp>:
Wrap the data type in std::optional for a nullable column. The last argument is VoltMod::HasDefault when an insert may leave the column out and VoltMod::Required when it must not.
Migration files live at <dir>/NNNN_name.sql, one statement per ;. Procedure bodies are not supported: MariaDB and SQLite run raw SQL one statement at a time.
Write each file once, in dialect-free SQL. The runner substitutes a placeholder wherever the three drivers disagree, a closed set of five:
| Placeholder | Postgres | MariaDB | SQLite | ||
|---|---|---|---|---|---|
@ID@ | BIGSERIAL PRIMARY KEY | BIGINT AUTO_INCREMENT PRIMARY KEY | INTEGER PRIMARY KEY AUTOINCREMENT | ||
@NOW@ | EXTRACT(EPOCH FROM NOW())::BIGINT | (UNIX_TIMESTAMP()) | ‘(strftime(’s','now'))\ilinebr </td> </tr> <tr class="markdownTableRowOdd"> <td class="markdownTableBodyNone">@TRUE<tt>/@FALSE<tt>\ilinebr </td> <td class="markdownTableBodyNone">TRUE/FALSE\ilinebr </td> <td class="markdownTableBodyNone">TRUE/FALSE\ilinebr </td> <td class="markdownTableBodyNone">1/0\ilinebr </td> </tr> <tr class="markdownTableRowEven"> <td class="markdownTableBodyNone">@INSERT_IF_ABSENT<tt>\ilinebr </td> <td class="markdownTableBodyNone">INSERT INTO\ilinebr </td> <td class="markdownTableBodyNone">INSERT IGNORE INTO\ilinebr </td> <td class="markdownTableBodyNone">INSERT OR IGNORE INTO\ilinebr </td> </tr> <tr class="markdownTableRowOdd"> <td class="markdownTableBodyNone">@ON_CONFLICT(cols)<tt>\ilinebr </td> <td class="markdownTableBodyNone">ON CONFLICT (cols) DO NOTHING` | *(nothing)* | *(nothing)* |
The last two go together: Postgres puts its clause at the end of the statement and the other two carry the same meaning in their INSERT verb.
An unknown @TOKEN@ fails the migration rather than applying a statement with a hole in it. Anything the set does not cover belongs in a driver-specific migration. voltmod database sql <file-or-dir> --driver <name> prints what a driver will actually run, which is also how an operator applies a hand-run seed file.
Each file runs in its own transaction; MariaDB DDL auto-commits regardless, so a failed file there leaves the tables it already created with the version row as the source of truth. The history table records applied versions and is validated against [A-Za-z_][A-Za-z0-9_]* before being interpolated into SQL. A session lock - a Postgres advisory lock, a MariaDB named lock, SQLite's own write lock - serializes two plugin loads racing on the same database. Plugins sharing a database need distinct history tables and distinct lock keys.
RunMigrations returns VoltMod::MigrationResult (Success, Applied, CurrentVersion), contextually convertible to bool. A missing directory is a logged no-op, not a failure.
Linking all three connectors statically carries their upstream licenses: libpq is the PostgreSQL licence (permissive), sqlite3 is public domain, and mariadb-connector-c is LGPL - static linking obliges you to let users relink against a different connector version. See conanfile.py.