Database bridge powering cross-server Fact, Asset, and player data synchronization with Redis/Valkey pub/sub.
The MySQL Extension replaces the default local file storage with a robust remote database system supporting MySQL, PostgreSQL, Redis, and Valkey. It enables real-time synchronization of Facts, Assets, and player data across multi-server networks via dual-mechanism sync (polling + optional Redis/Valkey pub/sub).
Dual Database Support
MySQL and PostgreSQL with automatic dialect switching.
Redis & Valkey
Instant cross-server data sync and cache invalidation.
Supports MySQL 8+ and PostgreSQL 14+ with automatic dialect detection. SQL syntax adapts automatically — ON DUPLICATE KEY UPDATE for MySQL, ON CONFLICT DO UPDATE for PostgreSQL. JDBC URL is constructed dynamically.
# config.ymlmysql: database-type: MYSQL # or POSTGRESQL host: localhost port: 3306 # 5432 for PostgreSQL database: typewriter_data username: user password: pass use-ssl: false allow-public-key-retrieval: true table-prefix: "ty_"
The engine’s FactDatabase now includes native cross-server sync via loadFactsSince() and syncFactsFromStorage() called every 5 seconds. The MySQL Extension complements this with:
Mechanism
Trigger
Latency
Fallback
Engine sync (always active)
Every 5 seconds, via FactDatabase.syncFactsFromStorage()
5s
N/A — always on
Extension polling (always active)
Every sync-interval-seconds (default 30s)
30s
N/A — always on
Redis/Valkey Pub/Sub (optional)
Instant on write
< 500ms
Falls back to polling if Redis down
Race condition protection: Both the engine’s syncFactsFromStorage() and the extension’s updateFactCache() use lastUpdate timestamp comparison — a server never overwrites its local cache with stale data. Dirty facts (modified but not yet flushed) are also protected from being overwritten.
The FactStorage interface now exposes three distinct operations instead of one:
Method
Behavior
Destructive?
storeFacts()
Full sync — upserts all given facts (no DELETE)
❌ No
upsertFacts()
Inserts or updates — only writes given facts
❌ No
deleteFacts()
Deletes specific facts by (entry_id, group_id, game_mode)
✅ Yes (intentional)
The old storeFacts() pre-deleted facts not present in the input, causing data loss in cross-server scenarios. It now behaves as a non-destructive upsert. deleteFacts() is reserved for explicit deletions (expired facts, cache cleanup).
The engine only persists fact caches to storage every 3 minutes (FACT_STORAGE_DELAY=180). Between cycles, player data lives only in memory. The MySQL Extension guarantees no data loss on player disconnect:
PlayerQuitEvent and PlayerKickEvent trigger an immediate, async flush via PlayerDataFlushListener
The flush runs on a Dispatchers.IO coroutine — never blocks the main thread
Data written at T0 is in MySQL by T+200ms (not T+180s)
Uses factDatabase.getCacheSnapshot() (public API) — no reflection
Player writes fact → cache updated → Player quits ↓ PlayerDataFlushListener (fire-and-forget coroutine) ↓ DataSyncService.forceFlushToMySql() (uses getCacheSnapshot() + upsertFacts) ↓ MySQL ← data persisted immediately
During server reload or plugin disable, the extension follows a strict shutdown order to prevent HikariDataSource has been closed errors:
Set isShuttingDown = true — immediately, before any cleanup
Unregister PlayerDataFlushListener — via its shutdown() callback
Stop DataSyncService — cancels polling coroutine
Stop discovery services — removes heartbeat
Unload Koin module — releases DI bindings
Close DataSource — last step, no one is using it anymore
All database operations (forceFlushToMySql, syncFactsFromMySql, flushBeforeDisconnect) check isShuttingDown before proceeding. If the flag is set, they return immediately with a FINE-level log message instead of crashing.
[18:42:13 WARN]: DataSync: forceFlush failed: HikariDataSource has been closed. ← ÉLIMINÉ[18:42:13 ERROR]: Could not pass event PlayerQuitEvent to Typewriter ← ÉLIMINÉ
Each server sends a heartbeat every 30 seconds to the servers table. Stale servers (60s without heartbeat) are automatically cleaned up. On JVM shutdown, the server deregisters itself.
-- Auto-created tableCREATE TABLE IF NOT EXISTS servers ( server_id VARCHAR(191) PRIMARY KEY, ip VARCHAR(255) NOT NULL, port INT NOT NULL, game_mode VARCHAR(64) NOT NULL, last_heartbeat DATETIME(6) NOT NULL, tps DOUBLE, version VARCHAR(64), player_count INT, max_players INT, free_memory BIGINT, max_memory BIGINT, extensions TEXT);
On startup, the extension scans all loaded TypeWriter extensions via the engine’s ExtensionLoader and registers them in the database. Each extension has configurable:
Setting
Options
Description
enabled
true / false
Whether this extension uses MySQL storage
profileMode
SHARED / PER_PROFILE
Data shared or per-profile?
networkMode
SHARED / ISOLATED
Data shared across game modes or isolated?
Key normalization handles all variants: TypeWriter-ProfilesExtension → profiles, RPGCore → rpgcore, friends → party (alias).
Located in mysql-integration-tests/. Uses TestContainers (real MySQL 8.4, PostgreSQL 16, Redis 7, Valkey 8) and simulated servers — no Minecraft required.
Scenario file
What it validates
CrossServerSyncTest
Polling sync, game mode isolation, global visibility, race condition guard