Database & storage#
VonixGuardian stores its audit log in a relational database. SQLite is the default and needs no setup; MySQL, MariaDB, and PostgreSQL are supported for larger servers.
Choosing a backend#
| Backend | Best for | Trade-off |
|---|---|---|
| SQLite | Default, small to mid servers, single-node setups | One writer at a time; WAL helps but concurrent writes still serialise. |
| MySQL / MariaDB | Busy servers already running a database | Network round-trips on every batch; needs credentials in config. |
| PostgreSQL | Busy servers wanting a serious database | Same as MySQL, plus PostgreSQL's own maintenance tooling. |
Rough guidance: if /vg status shows a rising queue depth with a healthy backend,
storage is your bottleneck and a remote database will help. If the queue is flat and
drops are zero, SQLite is already sufficient regardless of player count.
The bundled SQLite driver ships nested inside the jar on every loader, so a SQLite install needs nothing beyond the jar. MySQL and PostgreSQL drivers are also nested — no separate library install is required.
Where the data lives#
| Backend | Location |
|---|---|
| SQLite | vonixguardian.db in the server root (configurable via database.file). |
| MySQL / PostgreSQL | The database named in database.jdbcUrl. |
Also on disk, beside the database:
<server root>/
├── vonixguardian.db audit data (SQLite)
├── config/vonixguardian/config.json configuration
├── config/vonixguardian/worlds/ per-world overrides
├── config/vonixguardian/queue-quarantine.bin durable quarantine outbox
├── logs/vonixguardian/audit-YYYY-MM-DD.log.jsonl rolling log file
└── blacklist.txt optional suppression rules
Schema#
Current schema version: 9. Migrations are additive and idempotent — they run at boot, detect the existing version, and apply only what is missing.
Tables#
| Table | Purpose |
|---|---|
vg_users |
Actor registry: UUID, name, first seen, last seen. |
vg_worlds |
World key registry. |
vg_actions |
The audit log itself. One row per event. |
vg_rollback_batches |
One row per rollback or restore operation. |
vg_rollback_batch_actions |
Which actions each batch touched. |
vg_repair_required |
Rows a mutation could neither apply nor compensate. |
vg_sink_outbox |
Durable quarantine for admissions that could not be written. |
vg_schema_version |
Applied migration versions. |
vg_actions#
The core table. Each row is one event.
| Column | Type | Meaning |
|---|---|---|
id |
integer PK | Row identity. |
ts |
bigint | Epoch milliseconds. |
type |
smallint | Action type id — see Action types. |
user_id |
integer | FK to vg_users. |
world_id |
integer | FK to vg_worlds. |
x, y, z |
integer | Block coordinate. |
target |
varchar(4096) | Block, item, or entity id. |
meta |
text | Free-form payload (chat text, command, sign content). |
amount |
integer | Quantity, default 1. |
rolled_back |
tinyint | 1 when this row has been undone. |
source_tag |
varchar(64) | Attribution sentinel or source hint. |
sign_side |
varchar(8) | front / back on 1.20+. |
sign_dye_color |
varchar(16) | Sign dye colour on 1.20+. |
sign_waxed |
boolean | Sign waxed state on 1.20+. |
old_block_state |
text | Previous block state, for rollback fidelity. |
new_block_state |
text | New block state. |
block_entity_nbt |
blob | Block entity NBT, when storage.persistNbt is on. |
item_nbt |
blob | Item NBT. |
entity_nbt |
blob | Entity NBT. |
inventory_slot |
integer | Slot index for inventory rows. |
pair_id |
bigint | Links the two halves of a paired event (container in/out). |
Indexing is on (world, x, z, y, time) for spatial queries plus a composite
(ts, id) index added in v9 for keyset pagination.
Why the (ts, id) index exists
Paging past the first page used to run a deep OFFSET. The (ts, id) index
lets page 2 and beyond continue from the last row's keyset instead, so a lookup
on page 40 costs about the same as page 2.
Backup and restore#
SQLite#
Stop the server, then copy the file. SQLite in WAL mode leaves -wal and -shm
sidecars, so copying a live database without the sidecars captures an inconsistent
snapshot.
# Server stopped
cp vonixguardian.db vonixguardian.db.backup-$(date +%F)
For a live backup without stopping, use SQLite's own tooling:
sqlite3 vonixguardian.db ".backup '/backup/vg-$(date +%F).db'"
Restore by putting the file back with the server stopped and the sidecars removed:
rm -f vonixguardian.db-wal vonixguardian.db-shm
cp vonixguardian.db.backup-2026-11-01 vonixguardian.db
MySQL / MariaDB#
mysqldump --single-transaction --quick vonixguardian > vg-$(date +%F).sql
mysql vonixguardian < vg-2026-11-01.sql
--single-transaction gives a consistent snapshot without locking the tables.
PostgreSQL#
pg_dump -Fc vonixguardian > vg-$(date +%F).dump
pg_restore -d vonixguardian --clean vg-2026-11-01.dump
Back up before a large purge
purge is irreversible. There is no preview and no undo. If you are about to
delete a range you might later want, take a backup first.
Direct SQL#
Read-only queries against the audit log are useful for reporting and for verifying that a rollback did what it claimed.
-- Row counts by action type, last 7 days
SELECT a.type, COUNT(*) AS n
FROM vg_actions a
WHERE a.ts > (EXTRACT(EPOCH FROM now()) * 1000 - 604800000)
GROUP BY a.type
ORDER BY n DESC;
-- The 20 most recent block breaks in the overworld
SELECT u.name, a.x, a.y, a.z, a.target, a.ts
FROM vg_actions a
JOIN vg_users u ON u.id = a.user_id
JOIN vg_worlds w ON w.id = a.world_id
WHERE w.world_key = 'minecraft:overworld'
AND a.type = 2
ORDER BY a.ts DESC
LIMIT 20;
-- Database size by table (PostgreSQL)
SELECT relname, pg_size_pretty(pg_total_relation_size(oid))
FROM pg_class
WHERE relname LIKE 'vg_%'
ORDER BY pg_total_relation_size(oid) DESC;
Action type ids are stable: 1 place, 2 break, 3 container deposit, 4
container withdraw, and so on. The full list is on
Action types.
Never write to vg_actions directly
Inserting or updating rows by hand bypasses the batch bookkeeping in
vg_rollback_batches and vg_rollback_batch_actions. A rollback can then
reference rows that do not exist, or miss rows that do. Read directly only.
Quarantine and drops#
When the writer cannot keep up, events are not silently lost. The admission path tries, in order:
- Enqueue normally.
- Journal to the durable quarantine outbox (
vg_sink_outboxplusqueue-quarantine.bin) if the queue is full and the quarantine has capacity. - Count and log the drop at ERROR with the action type, id, world, and coordinates if the quarantine is also full.
/vg status exposes the counters:
| Field | Meaning |
|---|---|
dropped |
Events that never reached the database. |
quarantined |
Events currently held in the quarantine outbox. |
recovered |
Quarantined events since reclaimed. |
qOverflow |
Events lost because the quarantine was full. |
qWriteFail |
Failures writing to the quarantine itself. |
A non-zero dropped with zero quarantined means the durable path is disabled or
full — check qOverflow and qWriteFail. Rows recovered from quarantine are
replayed; rows in qOverflow are loss records, not replayable.
For a rejected explosion handoff, the journal records entries=<n> at the blast
centre only. The per-block list is not retained, so that row is a loss record and
not a rollback source.
Repair-required rows#
When a mutation cannot be applied and cannot be compensated, the row is recorded
in vg_repair_required with the batch id and a reason. This is the engine refusing
to guess.
/vg status lists them:
§ Rollback recovery
open batches 1 — inspect with /vg lookup before re-running rollback or restore
batch 17 mode=ROLLBACK affected=318
repair required 2
repair action 4412 batch=17 reason=target block entity type mismatch
Inspect the referenced rows with /vg lookup and #verbose, then decide whether to
re-run the rollback for that region. There is no automatic repair — a wrong
assumption is worse than an outstanding record.
Cache and maintenance#
Each backend benefits from its own maintenance, and /vg purge ... #optimize runs
the right one:
| Backend | Statement |
|---|---|
| SQLite | VACUUM |
| MySQL / MariaDB | OPTIMIZE TABLE |
| PostgreSQL | VACUUM ANALYZE |
The optimize step is capped at five minutes and its failure never fails the purge.
Run it after a large deletion, not routinely — VACUUM on a multi-gigabyte SQLite
file rewrites the whole database.
Migrating to another backend#
/vg migrate-db copies the catalog and is described in full on
Backend migration. The short version: configure
database.migrationTarget, run /vg migrate-db <type> CONFIRM from the console,
then change database.type and restart.