MySQL
Official compatibility
| Element | Reference |
|---|---|
TypeWriter | Required. Use the official TypeWriter release supported by the extension release. |
| Related extensions | Install the related extensions listed on this page before using entries that depend on them. |
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.
Architecture
Storage Tables
| Type | Table | Description |
|---|---|---|
| Fact | {prefix}facts | Key-value storage for game state (quest progress, player stats, flags) |
| Asset | {prefix}assets | Binary/serialized data (configs, skins, schematics) |
| Servers | servers | Server registry with heartbeat, TPS, player counts, memory |
| Extensions | {prefix}game_mode_extensions | Per-extension enabled/disabled, network mode, profile mode |
| Game Modes | {prefix}game_modes | Game mode definitions with extension bindings |
Features
Database Agnostic
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.yml
mysql:
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_"
Cross-Server Data Sync (Dual Mechanism)
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.
Engine-Level Dirty Tracking
The FactDatabase now includes full dirty tracking with debounced flush:
dirtyFacts— tracks facts modified since last persistencedeletedFacts— tracks facts that need to be removed from storage- Debounce 50ms — rapid sequential writes are batched into a single flush
flushDirtyFacts()— callsupsertFacts()/deleteFacts()on the storage
This ensures that every write to a fact is eventually flushed to MySQL without the 3-minute wait.
Non-Destructive Store & UPSERT
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).
Deadlock Protection (3 Layers)
MySqlFactStorage uses three-layer protection against MySQL deadlocks:
SELECT ... FOR UPDATE— acquires exclusive row locks immediately, preventing conflicting transactionswithRetryOnDeadlock()— up to 3 retries with exponential backoff (100ms → 200ms → 400ms)- Batch within a transaction — all writes committed atomically
private suspend fun <T> withRetryOnDeadlock(operation: String, block: () -> T): T {
for (attempt in 1..MAX_RETRIES) {
try {
return block()
} catch (e: SQLException) {
if (isDeadlock(e) && attempt < MAX_RETRIES) {
delay(RETRY_BASE_DELAY_MS * (1L shl (attempt - 1)))
} else {
throw e
}
}
}
}
Player Data Flush
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:
PlayerQuitEventandPlayerKickEventtrigger an immediate, async flush viaPlayerDataFlushListener- The flush runs on a
Dispatchers.IOcoroutine — 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
Shutdown Safety
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 itsshutdown()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É
Redis & Valkey Pub/Sub
Both Redis 7+ and Valkey 8+ are supported. Valkey uses the identical RESP protocol — the Jedis client works transparently with either backend.
redis:
enabled: true
type: redis # redis or valkey
host: localhost
port: 6379
username: "" # Optional (Valkey ACL)
password: ""
channel: "typewriter:events"
server-name: "survival" # Unique per server
Pub/Sub channels:
| Channel | Direction | Content |
|---|---|---|
typewriter:events/data_update | Server → All | Fact/asset change notification ("fact:quest_score") |
typewriter:events/config_update | Admin → All | Hot-reload config trigger |
typewriter:events/server_update | All → All | Player count + slots broadcast (5s interval) |
typewriter:events/player_tracking | Server ↔ Server | where_is, player_location, summon |
Auto-reconnect: If Redis disconnects, the subscriber thread retries every 5 seconds. The polling loop continues unaffected.
Server Discovery
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 table
CREATE 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
);
Extension Auto-Discovery
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).
Cross-Server Player Redirection
RedirectionManager enables cross-server player teleport via Redis pub/sub:
/locate <player>— finds which server a player is on, then teleports/summon <player>— pulls a player from another server- Load balancing —
redirectPlayer()picks the server with the lowest fill percentage
Config Hot-Reload
Sending a config_update message via Redis triggers an automatic config reload on all servers:
Redis: PUBLISH typewriter:events "config_update:profiles:enabled:true"
→ All servers reload MySqlExtensionConfig
→ ExtensionDiscoveryService re-registers extensions
Configuration Reference
Full mysql/config.yml:
mysql:
enabled: true
database-type: MYSQL # MYSQL or POSTGRESQL
host: localhost
port: 3306
database: typewriter_data
username: user
password: pass
use-ssl: false
allow-public-key-retrieval: true
table-prefix: "ty_"
migrate-from-files: true # Import local data on first start
sync-interval-seconds: 30 # Polling interval for cross-server sync
pool:
maximum-pool-size: 10
minimum-idle: 2
connection-timeout: 30000
max-lifetime: 1800000
validation-timeout: 5000
keepalive-time: 0
game-mode:
current: "default" # This server's game mode
extensions:
profiles:
enabled: true
profile-mode: per-profile # shared or per-profile
network-mode: shared # shared or isolated
cosmetics:
enabled: true
profile-mode: shared
network-mode: shared
rpgcore:
enabled: true
profile-mode: per-profile
network-mode: isolated
dungeon:
enabled: true
profile-mode: per-profile
network-mode: isolated
# ... all extensions configurable
redis:
enabled: false
type: redis # redis or valkey
host: localhost
port: 6379
username: ""
password: ""
channel: "typewriter:events"
server-name: "survival"
profiles:
enabled: true
max-profiles: 5
default-profile-name: "Principal"
save-statistics: true
save-inventory: true
save-ender-chest: true
require-permission: false
auto-create-default: true
Key Configuration Options
| Option | Default | Description |
|---|---|---|
sync-interval-seconds | 30 | Seconds between MySQL polling cycles |
redis.type | redis | redis or valkey |
redis.server-name | survival | Unique per-server identifier |
extensions.<key>.network-mode | isolated | shared = cross-game-mode, isolated = per-mode |
extensions.<key>.profile-mode | shared | shared = all profiles, per-profile = separated |
pool.maximum-pool-size | 10 | HikariCP max connections |
pool.keepalive-time | 0 | Keepalive in ms (0 = disabled) |
Web UI
Access the MySQL administration page via the Server Management sidebar tab in the Typewriter web editor.
Tab Overview
| Tab | Purpose |
|---|---|
| Servers | View registered servers, TPS, player counts, online/offline status |
| Game Modes | Create/edit/delete game modes, enable/disable extensions, configure network/profile modes |
| Health & Ops | Redis latency, database connection pool stats, MySQL logs |
| Topology | Visual graph of connected servers and their relationships |
| Entry Storage | Browse fact/asset tables, view individual entries, table schemas |
Socket.IO Events
| Event | Direction | Description |
|---|---|---|
fetchServerList | Client → Server | Get all registered servers with health data |
fetchGameModes | Client → Server | Get game mode definitions |
createGameMode / deleteGameMode | Client → Server | Manage game modes |
updateGameModeExtension | Client → Server | Toggle extension enabled/network/profile mode |
setExtensionEnabled | Client → Server | Quick enable/disable toggle |
fetchEntryFilters | Client → Server | Get per-extension entry filters |
fetchStorageOverview | Client → Server | Get all fact/asset tables |
fetchTableEntries | Client → Server | Browse entries in a table |
fetchTableSchema | Client → Server | View table column definitions |
serverHealthCheck | Client → Server | Check Redis + DB connectivity |
fetchMySqlLogs | Client → Server | Recent MySQL operation logs |
Permissions
| Permission | Description |
|---|---|
typewriter.admin.server.list | View servers, game modes, storage, logs |
typewriter.admin.server.health | View health status and logs |
typewriter.admin.server.deregister | Remove servers from registry |
typewriter.admin.gamemode.create | Create or delete game modes |
typewriter.admin.gamemode.configure | Configure extensions, entry filters, storage overrides |
Dependencies
- TypeWriter Engine (required)
- MySQL 8+ or PostgreSQL 14+ (required)
- Redis 7+ or Valkey 8+ (recommended for cross-server sync)
- HikariCP 6.x (connection pooling, bundled)
- Jedis 5.x (Redis/Valkey client, bundled)
- JDBC drivers — MySQL Connector/J + PostgreSQL JDBC (bundled)
Testing
The extension has a comprehensive test suite split into two pillars:
Pillar 1 — Unit Tests (71 tests)
Located in TypeWriter-MySqlExtension/src/test/kotlin/. No infrastructure needed.
| Test class | Coverage |
|---|---|
SqlDialectTest | UPSERT generation (MySQL + PostgreSQL), quoting, type mappings |
ConfigParsingTest | ProfileMode, NetworkMode, CacheBackend, DatabaseType parsing |
MessageParsingTest | Redis wire format: data_update, server_update, player_tracking |
ExtensionKeyTest | Key normalization, alias resolution, fallback chain, game mode isolation |
Run: .\gradlew.bat :TypeWriter-MySqlExtension:test --no-daemon
Pillar 2 — Integration Tests (16 scenarios)
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 |
RedisPubSubTest | Redis PING, Valkey PING, pub/sub delivery, instant cross-server sync, polling fallback |
DeadlockRetryTest | 4-thread concurrent writes, FOR UPDATE + retry, data integrity |
PlayerFlushTest | Quit flush completeness, async non-blocking behavior, sub-200ms persistence |
Run: .\gradlew.bat :mysql-integration-tests:test --no-daemon (requires Docker)
User guide
MySQL does not provide a Typewriter entry to create: it is a storage and synchronization extension.
- Install the extension and prepare the MySQL or PostgreSQL database, plus Redis/Valkey if instant synchronization is required.
- Enter the connection and synchronization settings in the configuration screen provided by your distribution. Do not create a Typewriter page for this extension.
- Start a test server and verify that Facts and Assets persist before adding a second server.
- Add Redis/Valkey and check synchronization, then test a player disconnect to verify the asynchronous flush.
- Keep the table prefix stable and verify database permissions before production deployment.
The tables on this page explain the storage mechanisms and limits; they do not replace the actual database configuration.
Troubleshooting
The extension is not visible
Check that the installed release matches the supported TypeWriter release and that the related extensions listed in the compatibility section are present.
An entry is missing
In the Typewriter Web panel, use the Search bar or the + button to find or create the entry with its exact identifier. Entry IDs and field names are case-sensitive; do not translate them.
The entry saves but nothing happens
Check that the entry is connected to the trigger, command, objective, event, or menu interaction required by its type. Test with optional permissions and criteria removed, then add them back one at a time.
An integration does not work
Verify the dependency and version shown in the extension card, then check the server log for the first error. Do not replace a missing dependency with a similarly named plugin unless the extension source explicitly supports it.