Skip to content

These docs describe Bloom 0.1.0, a pre-release. APIs may change before 1.0, and Bloom is not on Maven Central yet (see Installation).

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

JavaView source
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 work

SQLite 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:

JavaView source
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

JavaView source
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 ticks

Applied 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

JavaView source
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.

MethodReturns
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

JavaView source
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

JavaView source
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

JavaView source
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.