Skip to content

Swofty-Developments/SwoftyDataHandler

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

13 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SwoftyDataHandler

Discord

A reactive, type-safe data management library for Java applications. Built for Minecraft servers but usable anywhere you need per-entity data with shared/linked fields, expiration, validation, events, transactions, and bulk operations.

Installation

Gradle:

dependencies {
    implementation 'net.swofty:SwoftyDataHandler:<version>'

    // Only if using Redis storage
    implementation 'redis.clients:jedis:5.2.0'
}

Maven:

<dependency>
    <groupId>net.swofty</groupId>
    <artifactId>SwoftyDataHandler</artifactId>
    <version>VERSION</version>
</dependency>

<!-- Only if using Redis storage -->
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>5.2.0</version>
</dependency>

Quick Start

// 1. Pick a storage backend
DataStorage storage = new InMemoryDataStorage();          // testing / ephemeral
DataStorage storage = new FileDataStorage(path, new JsonFormat()); // single-server
DataStorage storage = new RedisDataStorage("localhost", 6379);     // multi-server

// 2. Create the API
DataAPI api = new DataAPIImpl(storage);

// 3. Define fields
PlayerField<Integer> COINS = PlayerField.create("economy", "coins", Codecs.INT, 0);

// 4. Use it
UUID player = UUID.randomUUID();
api.set(player, COINS, 500);
api.update(player, COINS, c -> c + 100);
int coins = api.get(player, COINS); // 600

Storage Backends

Backend Persistence Multi-server Event listeners
InMemoryDataStorage No No Yes
FileDataStorage Yes (local files) No No
RedisDataStorage Yes (Redis) Yes Yes

FileDataStorage does not support event listeners. Calling subscribe on a DataAPI backed by file storage throws UnsupportedOperationException.

// File storage with custom format and extension
new FileDataStorage(basePath, new JsonFormat(), ".json")
new FileDataStorage(basePath, new BinaryFormat(), ".dat")

// Redis with connection pool
JedisPool pool = new JedisPool("localhost", 6379);
new RedisDataStorage(pool)
new RedisDataStorage(pool, "myapp:data") // custom key prefix

Player Fields

Per-player data with type safety, default values, and optional validation.

PlayerField<Integer> COINS = PlayerField.create("economy", "coins", Codecs.INT, 0);
PlayerField<String> NAME  = PlayerField.create("profile", "name", Codecs.STRING, "");

// With validation
PlayerField<Integer> LEVEL = PlayerField.<Integer>builder("rpg", "level")
        .codec(Codecs.INT)
        .defaultValue(1)
        .validator(Validators.range(1, 100))
        .build();

api.set(player, COINS, 500);
api.update(player, COINS, c -> c + 100);
int coins = api.get(player, COINS);

All fields use a namespace:key format internally (e.g. economy:coins) to prevent collisions between systems.

Linked Fields (Shared Data)

Data shared across multiple players through a common key (guild bank, island level, party settings).

// 1. Define the player field that holds the link key
PlayerField<UUID> ISLAND_ID = PlayerField.create(
        "skyblock", "island_id", Codecs.nullable(Codecs.UUID), null);

// 2. Define the link type
LinkType<UUID> ISLAND = LinkType.create("island", Codecs.UUID, ISLAND_ID);

// 3. Define linked fields
// The first argument is the namespace (for storage key organization), not the link type.
// e.g. "island" + "level" -> storage key "island:level"
LinkedField<UUID, Integer> ISLAND_LEVEL = LinkedField.create(
        "island", "level", Codecs.INT, 1, ISLAND);
LinkedField<UUID, Long> ISLAND_BANK = LinkedField.create(
        "island", "bank", Codecs.LONG, 0L, ISLAND);

// 4. Link players and use shared data
UUID islandId = UUID.randomUUID();
api.link(player1, ISLAND, islandId);
api.link(player2, ISLAND, islandId);

api.set(player1, ISLAND_BANK, 1000L);
long bank = api.get(player2, ISLAND_BANK); // 1000 -- same data

// Direct access by link key -- useful when you have the key but not a player UUID,
// e.g. updating island data from a scheduled task or admin command
api.setDirect(islandId, ISLAND_LEVEL, 5);

Codecs

Built-in codecs for serialization:

Codec Type
Codecs.INT Integer
Codecs.LONG Long
Codecs.FLOAT Float
Codecs.DOUBLE Double
Codecs.BOOL Boolean
Codecs.STRING String
Codecs.UUID UUID
Codecs.INSTANT Instant

Compound codecs:

Codecs.list(Codecs.STRING)                   // List<String>
Codecs.set(Codecs.UUID)                      // Set<UUID>
Codecs.map(Codecs.STRING, Codecs.INT)        // Map<String, Integer>
Codecs.nullable(Codecs.UUID)                 // UUID (nullable)

Versioned Codecs

Handle schema changes with automatic migration chains:

VersionedCodec<PlayerStats> STATS_CODEC = VersionedCodec.builder(
        3,
        reader -> new PlayerStats(reader.readInt(), reader.readInt(), reader.readString()),
        (writer, stats) -> {
            writer.writeInt(stats.kills());
            writer.writeInt(stats.deaths());
            writer.writeString(stats.rank());
        }
)
    .legacyReader(1, reader -> new V1Stats(reader.readInt()))
    .legacyReader(2, reader -> new V2Stats(reader.readInt(), reader.readInt()))
    .migrate(1, 2, v1 -> new V2Stats(v1.kills(), 0))
    .migrate(2, 3, v2 -> new PlayerStats(v2.kills(), v2.deaths(), "unranked"))
    .build();

Reading v1 data automatically chains: v1 -> v2 -> v3.

Validation

Composable validators that throw ValidationException on failure:

PlayerField<Integer> SCORE = PlayerField.<Integer>builder("game", "score")
        .codec(Codecs.INT)
        .defaultValue(0)
        .validator(Validators.nonNegative())
        .build();

PlayerField<String> NAME = PlayerField.<String>builder("profile", "name")
        .codec(Codecs.STRING)
        .defaultValue("")
        .validator(Validators.maxLength(32))
        .build();

// Chain validators with .and()
Validator<Integer> strict = Validators.nonNegative().and(Validators.range(0, 10000));

Built-in validators: Validators.nonNegative(), Validators.range(min, max), Validators.maxLength(max).

Expiring Fields

Fields with automatic TTL:

ExpiringField<String> ACTIVE_BOOST = ExpiringField.<String>expiringBuilder("game", "boost")
        .codec(Codecs.STRING)
        .defaultValue(null)
        .defaultTtl(Duration.ofMinutes(30))
        .build();

api.set(player, ACTIVE_BOOST, "double_xp");                   // uses default 30min TTL
api.set(player, ACTIVE_BOOST, "double_xp", Duration.ofHours(1)); // custom TTL
api.extend(player, ACTIVE_BOOST, Duration.ofMinutes(15));      // add time
api.getTimeRemaining(player, ACTIVE_BOOST);                    // Optional<Duration>
api.isExpired(player, ACTIVE_BOOST);                           // boolean

// Expired fields return their default value on get()

Expiring linked fields work the same way:

ExpiringLinkedField<UUID, Integer> ISLAND_BUFF =
        ExpiringLinkedField.<UUID, Integer>expiringBuilder("island", "buff", ISLAND)
                .codec(Codecs.INT)
                .defaultValue(0)
                .defaultTtl(Duration.ofHours(2))
                .build();

Transactions

Atomic multi-field operations with rollback on abort:

// With return value
int newBalance = api.transaction(player, tx -> {
    int coins = tx.get(COINS);
    int price = 500;
    if (coins < price) {
        tx.abort(); // rolls back all changes
    }
    tx.set(COINS, coins - price);
    tx.update(ITEMS, items -> items + 1);
    return coins - price;
});

// Without return value
api.transaction(player, tx -> {
    tx.update(COINS, c -> c + 100);
    tx.set(NAME, "NewName");
});

// Direct transaction on linked data
api.transactionDirect(islandId, ISLAND, tx -> {
    tx.update(ISLAND_BANK, bank -> bank - 1000L);
    return null;
});

Event Listeners

Subscribe to data changes. Requires a storage backend that supports listeners (Redis or InMemory -- not File).

// Player field changes
api.subscribe(COINS, (player, oldValue, newValue) -> {
    System.out.println(player + ": " + oldValue + " -> " + newValue);
});

// Linked field changes (includes all affected players)
api.subscribe(ISLAND_LEVEL, (islandId, oldLevel, newLevel, affectedPlayers) -> {
    System.out.println("Island " + islandId + " leveled up, affecting " + affectedPlayers.size() + " players");
});

// Link/unlink events
api.subscribe(ISLAND, new LinkChangeListener<UUID>() {
    public void onLinked(UUID player, LinkType<UUID> type, UUID key) {
        System.out.println(player + " joined island " + key);
    }
    public void onUnlinked(UUID player, LinkType<UUID> type, UUID previousKey) {
        System.out.println(player + " left island " + previousKey);
    }
});

// Expiration events
api.subscribeExpiration(ACTIVE_BOOST, (player, field, expiredValue) -> {
    System.out.println(player + "'s boost expired: " + expiredValue);
});

Cross-Server Events (Redis)

When using RedisDataStorage, events are automatically distributed across all server instances via Redis Pub/Sub. A change on Server A fires listeners on Server B.

// Server A
DataAPI apiA = new DataAPIImpl(new RedisDataStorage("redis-host", 6379));
apiA.set(player, COINS, 1000);

// Server B -- listener fires automatically
DataAPI apiB = new DataAPIImpl(new RedisDataStorage("redis-host", 6379));
apiB.subscribe(COINS, (p, old, nw) -> {
    // This fires when Server A changes the value
});

Bulk Operations

Leaderboards

// Top 10 by natural ordering (descending)
List<LeaderboardEntry<Integer>> top = api.getTop(COINS, 10);
for (LeaderboardEntry<Integer> entry : top) {
    System.out.println("#" + entry.rank() + " " + entry.playerId() + ": " + entry.value());
}

// Custom comparator
api.getTop(COINS, 10, Comparator.naturalOrder()); // ascending

// Paginated (1-indexed pages)
Page<LeaderboardEntry<Integer>> page = api.getTopPaged(COINS, 1, 50);
page.content();       // entries for this page
page.page();          // current page number
page.totalPages();    // total pages
page.totalElements(); // total entries

// Linked leaderboards
api.getTopLinked(ISLAND_LEVEL, 10);

Queries

// Find players matching a condition
List<UUID> rich = api.query(COINS, coins -> coins > 10000);

// Count matching players
int count = api.count(COINS, coins -> coins > 10000);

// Query linked data
List<UUID> activeIslands = api.queryLinked(ISLAND_LEVEL, level -> level > 5);

Bulk Updates

// Update all players
int updated = api.updateAll(COINS, c -> c + 100); // daily bonus

// Update matching players
int reset = api.updateWhere(COINS, c -> c < 0, c -> 0); // fix negative balances

Data Formats

Two serialization formats are included:

new JsonFormat()   // human-readable, good for debugging
new BinaryFormat() // compact, good for production

Both implement DataFormat and can be used with any storage backend.

Multi-Server Lifecycle

Across a fleet of servers a player (or a shared entity such as an island) is authoritative on exactly one node at a time. The lifecycle API lets a node warm that data into its cache before use and evict it afterwards, so a later visit to any node is never served a stale value.

// Warm a player's whole document into this node in a single storage read.
api.load(player);                       // synchronous
api.loadAsync(player, executor);        // CompletableFuture<Void>
api.isLoaded(player);                   // boolean

// Persist pending changes and drop the player from this node's cache.
api.unload(player);                     // flush + evict
api.flush(player);                      // flush without evicting

// Shared/linked entities have the same lifecycle.
api.loadLink(ISLAND, islandId);
api.unloadLink(ISLAND, islandId);

This is the primitive a proxy uses to implement "load the player's data on the target server before moving them there": the proxy asks the destination to load(player), waits for the ack, then connects the player. Because the origin calls unload(player) on disconnect, the destination always starts from fresh storage.

Deferred persistence. By default every write is flushed immediately. Pass autoPersist = false to buffer a whole play session in the cache and write it back once, on flush/unload (shutdown flushes everything so nothing is lost):

DataAPI api = new DataAPIImpl(storage, new JsonFormat(), pubSub, /* autoPersist */ false);

Cache coherency. With a distributed event bus, a change made on another node to an entity that is currently loaded here updates the local view in place, so subscribed fields never go stale while a player is online. Eviction on unload handles the general case.

Distributed Locking

Transactions guard a JVM-local lock by default, which only serialises threads within one process. Supply a DistributedLock to get true cross-node mutual exclusion — required when the same shared entity can be mutated from more than one server (e.g. a coop bank):

DistributedLock lock = new RedisDistributedLock(jedisPool);   // or InMemoryDistributedLock for one node
DataAPI api = new DataAPIImpl(storage, new JsonFormat(), pubSub, true, lock);

// Transactions now take a cross-node lock keyed by the entity, in addition to the local monitor.
api.transactionDirect(coopId, COOP, tx -> { tx.update(BANK, b -> b - 1000L); return null; });

// The same primitive is available for app-level critical sections:
try (var handle = ((DataAPIImpl) api).lock("coop-transfer:" + coopId, Duration.ofSeconds(5))) {
    // ... multi-entity critical section ...
}

The Redis implementation uses SET NX PX with a compare-and-delete release, so a lock is only released by its owner and a lease bounds a crashed holder.

Indexed Leaderboards

Leaderboards are index-backed and self-registering — no setup for numeric fields. The first time you rank a field its index is built from existing data in one scan; from then on every node maintains it on write, and ranking reads only the requested slice. This requires a LeaderboardIndex-capable storage (RedisDataStorage, backed by sorted sets, or InMemoryDataStorage for single-node/tests):

api.getTop(COINS, 10);         // just works — O(log N + page), no registration
api.getTopPaged(COINS, 1, 50);

Only a non-numeric field needs a score function, since a sorted set ranks by a number:

api.trackLeaderboard(NAME, String::length); // rank players by name length

rebuildLeaderboard(field) forces a rebuild from stored data if you ever need it. Storage backends that don't maintain an index (e.g. FileDataStorage) throw on ranking rather than silently scanning; getTop(field, limit, comparator) remains as the explicit scan-based escape hatch for ad-hoc custom orderings.

Lifecycle

Always shut down the API when done:

api.shutdown(); // flushes deferred writes, stops expiration timers, closes Pub/Sub subscribers

For Redis storage, also close the storage:

RedisDataStorage storage = new RedisDataStorage("localhost", 6379);
DataAPI api = new DataAPIImpl(storage);
// ...
api.shutdown();
storage.close();

License

See LICENSE for details.

About

A reactive, type-safe data management library for Java applications with shared/linked fields, expiration, validation, events, transactions, and bulk operations.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages