Persistence guide
TeaCore owns a shared javax.sql.DataSource (HikariCP pool over SQLite or MySQL depending on config.yml). Your game plugin gets at it via core.database().dataSource() and runs plain JDBC against it.
The DatabaseService interface
public interface DatabaseService {
DataSource dataSource();
void registerMigrations(Plugin plugin, String namespace, List<String> migrationFilenames);
}
dataSource() returns the shared pool. registerMigrations reads migrations bundled inside your plugin jar and applies any that haven't been applied yet, recording successful versions in tea_schema_versions(plugin_name, version).
Registering migrations
core.database().registerMigrations(this, "mygame", List.of(
"V001__init.sql",
"V002__add_leaderboard.sql"
));
Each file lives at resources/db/migrations/mygame/<name>.sql in your plugin jar. Filenames must match V<int>__<description>.sql - the runner parses the version number, applies files in ascending order, and records successful migrations. Already-applied versions are skipped.
Conventions
- Prefix every table with your plugin's namespace (
mygame_stats, notstats). This keeps you from stepping on another plugin in the shared DB. - Use
VARCHAR(36)+ UUID strings rather than BIGINT foreign keys - works with both SQLite and MySQL and matches the rest of Tea. - Reference
tea_player_profile(uuid)if you want a formal FK; SQLite doesn't enforce it by default but MySQL does. - Don't use MySQL-specific features (stored procedures, JSON operators, etc.) in migrations if you want your plugin to work on SQLite installs too. Stick to plain DDL/DML.
Example migration
V001__init.sql:
CREATE TABLE IF NOT EXISTS mygame_stats (
uuid VARCHAR(36) NOT NULL PRIMARY KEY,
kills INT NOT NULL DEFAULT 0,
deaths INT NOT NULL DEFAULT 0,
games_played INT NOT NULL DEFAULT 0,
games_won INT NOT NULL DEFAULT 0,
FOREIGN KEY (uuid) REFERENCES tea_player_profile(uuid) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_mygame_stats_kills ON mygame_stats (kills DESC);
Reading stats
String sql = "SELECT kills, deaths FROM mygame_stats WHERE uuid = ?";
try (Connection c = core.database().dataSource().getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, uuid.toString());
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
int kills = rs.getInt("kills");
int deaths = rs.getInt("deaths");
// ...
}
}
}
Player profile
Every first-seen UUID gets a row in tea_player_profile automatically on PlayerJoinEvent - you don't need to insert one. Access via core.playerProfiles().getOrCreate(uuid).
The shared profile is intentionally thin (uuid, first_seen, last_seen, locale, metadata_json). Per-game stats live in per-game tables so adding or removing a game doesn't require schema changes elsewhere.
Exposing stats to /tea stats
TeaCore's /tea stats [player] command aggregates results from every registered StatsProvider. Register yours on enable and the command will call back to your code whenever someone looks up a player:
core.stats().register(new StatsProvider() {
@Override public GameId gameId() { return MY_GAME; }
@Override public Map<String, String> fetch(UUID uuid) {
Map<String, String> out = StatsProvider.newEntries(); // LinkedHashMap, preserves order
// read from your per-game tables...
out.put("Kills", String.valueOf(kills));
out.put("Wins", String.valueOf(wins));
return out;
}
});
Return an empty map to indicate "no rows for this player". The core command will just skip your section in that case.
Connection pool notes
Both sqlite-jdbc and mysql-connector-j ship unrelocated inside the shadow jar so their native libraries and META-INF/services entries keep working. Don't try to shade them yourself if you depend on core as a shadow artifact - the classes are already there.