Storage
SQLite, MySQL and MariaDB with connection pooling, asynchronous queries, transactions, migrations and repositories.
bloom-storage runs SQL on its own threads and hands you CompletableFutures. It does not depend on Bukkit, and it is
not an ORM: you write SQL, Bloom takes care of connections, threads, parameters and shutdown.
Opening a database
Database db = Database.builder()
.sqlite(plugin.getDataFolder().toPath().resolve("data.db"))
.poolName(plugin.getName())
.migrationTable("myplugin_schema_history")
.mainThreadCheck(bloom.scheduler()::isPrimaryThread)
.logger(plugin.getLogger())
.build();
bloom.services().register(Database.class, db); // Bloom closes it on shutdown, after queued workSQLite uses one connection by default, which serialises writes and avoids "database is locked" errors, plus WAL mode, foreign keys and a busy timeout. For MySQL:
Database db = Database.builder()
.mysql("localhost", 3306, "minecraft", "bloom", "change-me")
.poolSize(8)
.property("useSSL", "true")
.poolName(plugin.getName())
.migrationTable("myplugin_schema_history")
.mainThreadCheck(bloom.scheduler()::isPrimaryThread)
.build();Bloom picks the JDBC driver the server provides: SQLite and MySQL drivers ship with Spigot and Paper (on 1.8, Bloom
falls back to the old com.mysql.jdbc.Driver). MariaDB's driver must be shaded into your plugin. If the database is
unreachable, build() fails immediately with the address and a hint.
Migrations
List<Migration> migrations = Arrays.asList(
Migration.of(
1,
"create balances",
"CREATE TABLE IF NOT EXISTS balances (uuid VARCHAR(36) PRIMARY KEY, coins BIGINT NOT NULL)"),
Migration.of(
2,
"add last seen",
tx -> tx.update("ALTER TABLE balances ADD COLUMN last_seen BIGINT NOT NULL DEFAULT 0")));
db.migrate(migrations).join(); // blocking is fine in onEnable, before the server ticksApplied versions are recorded in the history table, and each migration runs in its own transaction. MySQL and
MariaDB commit schema changes immediately, so keep each migration to one logical change. If the database has a newer
version than your plugin's migrations, migrate fails instead of continuing with an unknown schema.
Queries
UUID id = player.getUniqueId();
db.update("INSERT INTO balances (uuid, coins) VALUES (?, ?)", id, 100L);
db.queryOne("SELECT coins FROM balances WHERE uuid = ?", rs -> rs.getLong("coins"), id)
.thenAcceptAsync(
coins -> player.sendMessage("Coins: " + coins.orElse(0L)), // main thread
bloom.scheduler().mainThreadExecutor())
.exceptionally(error -> {
bloom.plugin().getLogger().warning("Balance lookup failed: " + error.getMessage());
return null;
});Results arrive on a database thread. Switch to the main thread with mainThreadExecutor() before touching players.
Parameters are bound in order. Besides strings, numbers and booleans you can pass UUID (stored as text), enums
(stored by name), Instant (epoch milliseconds), Duration (milliseconds), byte[] and null.
| Method | Returns |
|---|---|
update(sql, params...) | rows affected |
insert(sql, params...) | generated key, if any |
batch(sql, rows) | rows affected per parameter row |
query(sql, mapper, params...) | all rows |
queryOne(sql, mapper, params...) | first row, or empty |
Transactions
db.transaction(tx -> {
Optional<Long> balance =
tx.queryOne("SELECT coins FROM balances WHERE uuid = ?", rs -> rs.getLong(1), from);
if (!balance.isPresent() || balance.get() < amount) {
throw new IllegalStateException("Not enough coins"); // rolls back
}
tx.update("UPDATE balances SET coins = coins - ? WHERE uuid = ?", amount, from);
tx.update("UPDATE balances SET coins = coins + ? WHERE uuid = ?", amount, to);
return balance.get() - amount;
});The transaction commits when the callback returns and rolls back when it throws. Use inTransaction(tx -> ...) when
there is no result. The Transaction object is only valid inside the callback.
Repositories
final class BalanceRepository extends SqlRepository<UUID, Balance> {
BalanceRepository(Database db) {
super(
db,
"balances",
"uuid",
Arrays.asList("uuid", "coins"),
rs -> new Balance(UUID.fromString(rs.getString("uuid")), rs.getLong("coins")));
}
@Override
protected UUID keyOf(Balance balance) {
return balance.uuid;
}
@Override
protected Object[] valuesOf(Balance balance) {
return new Object[] {balance.uuid, balance.coins}; // same order as the column list
}
}
final class Balance {
final UUID uuid;
final long coins;
Balance(UUID uuid, long coins) {
this.uuid = uuid;
this.coins = coins;
}
}
// Usage: repository.find(id), repository.save(balance), repository.delete(id), repository.findAll()SqlRepository generates find, findAll, save (an upsert) and delete. Table and column names are validated as
plain identifiers. On SQLite the upsert is INSERT OR REPLACE, which works on the old SQLite bundled with 1.8 servers
but deletes and re-inserts the row, so ON DELETE CASCADE children are removed; MySQL uses
ON DUPLICATE KEY UPDATE.
Blocking calls off the main thread
bloom.scheduler().async(() -> {
// Already off the main thread, so synchronous calls are fine here:
int rows = db.blocking().update("DELETE FROM balances WHERE coins = 0");
bloom.plugin().getLogger().info("Removed " + rows + " empty accounts");
});db.blocking() throws IllegalStateException when called on the main thread, as reported by mainThreadCheck.
Limits and shutdown
- Operations wait in a bounded queue (1,000 by default,
queueCapacity). When it is full, new operations fail at once with "The database queue is full" instead of piling up memory while the database is down. close()stops accepting work, waits for queued operations (10 seconds by default,shutdownTimeout), fails anything that could not run, and closes the pool. Register the database as a Bloom service and Bloom does this on shutdown.- Error messages include the SQL statement but never parameter values, which may contain personal data.