Skip to content

Caskara Documentation

Caskara is a data engine library for Hytale mods. It wraps SQLite with a JSON-NoSQL-style API, giving you schema-free persistence, ACID transactions, in-memory caching, AES encryption, schema migrations, real-time reactive observers, and performance metrics — all without writing SQL.


Table of Contents

  1. Project Structure
  2. Architecture Overview
  3. Getting Started
  4. Core Concepts: Shell & Core
  5. API Reference
  6. Caskara (Entry Point)
  7. Shell
  8. Core\<T>
  9. Query\<T>
  10. Transaction
  11. Pearl\<T>
  12. Stats
  13. Advanced Features
  14. AES-256 Encryption
  15. Schema Migrations
  16. TTL (Time To Live)
  17. Soft Delete & Restore
  18. Hooks & Validators
  19. Reactive Observers
  20. Automated Backups
  21. Export & Import
  22. Exception Hierarchy
  23. Data Types & Best Practices

Project Structure

Caskara/
├── build.gradle                  # Gradle build file; dependencies, deploy task, test config
├── gradle.properties             # Java version property
├── local.properties              # Local overrides (hytale.dir); not committed
├── libs/                         # Hytale server JAR (compile-only)
├── src/
│   ├── main/
│   │   ├── java/com/cookie/caskara/
│   │   │   ├── Caskara.java               # Main static API entry point
│   │   │   ├── MainPlugin.java            # Hytale plugin bootstrap
│   │   │   ├── db/
│   │   │   │   ├── Shell.java             # Database file/connection manager
│   │   │   │   ├── Core.java              # Type-scoped collection (table equivalent)
│   │   │   │   ├── Query.java             # Fluent query builder
│   │   │   │   ├── Transaction.java       # Atomic operation context
│   │   │   │   ├── Pearl.java             # Async result wrapper
│   │   │   │   ├── Stats.java             # Performance metrics
│   │   │   │   └── BackupManager.java     # Scheduled backup utility
│   │   │   ├── entities/
│   │   │   │   ├── User.java              # Example entity: simple user
│   │   │   │   ├── PlayerStats.java       # Example entity: nested stats object
│   │   │   │   └── FruitBasket.java       # Example entity: collection field
│   │   │   └── exceptions/
│   │   │       ├── CaskaraException.java  # Base unchecked exception
│   │   │       ├── DatabaseException.java # SQL/IO errors
│   │   │       └── ValidationException.java # Validator rejection
│   │   └── resources/                     # (placeholder for mod resources)
│   └── test/
│       └── java/com/cookie/caskara/
│           ├── ShellTest.java             # Shell connection & schema tests
│           ├── CoreCrudTest.java          # CRUD, TTL, soft-delete, validators
│           ├── CoreEncryptionTest.java    # AES encrypt/decrypt roundtrip
│           ├── CoreMigrationTest.java     # Schema migration pipeline
│           ├── QueryTest.java             # Query builder filter/sort/page
│           ├── TransactionTest.java       # ACID commit & rollback
│           ├── PearlTest.java             # Async result wrapper behavior
│           └── StatsTest.java             # Cache and query metrics
├── DOCS.md                       # This file
├── README.md                     # Quick-start overview
└── API.md                        # Concise API table reference

Architecture Overview

Caskara uses a Shell & Core paradigm:

  • Shell = one SQLite .db file. Manages the JDBC connection, locking, transactions, and exports.
  • Core\<T> = a typed "collection" inside a Shell. Handles serialization, caching, encryption, migrations, hooks, and observers for one specific Java class.
Plugin Code
Caskara (static API)
    ├─► Shell ("global/default.db")
    │       └─► Core<PlayerData>   ─► LRU Cache ─► Gson (JSON) ─► AES-256 ─► SQLite
    │       └─► Core<QuestData>    ─► LRU Cache ─► Gson (JSON) ──────────►  SQLite
    └─► Shell ("worlds/Orbis/spawn.db")
            └─► Core<ChunkData>   ─► LRU Cache ─► Gson (JSON) ──────────►  SQLite

All Shell operations are protected by a ReentrantLock. Async operations use Virtual Threads (Executors.newVirtualThreadPerTaskExecutor()).


Getting Started

1. Add Caskara to your mod

Place caskara-x.x.x.jar in your mod's classpath or the Hytale UserData/Mods folder (the deploy Gradle task does this automatically).

2. Initialize once on plugin startup

public class MyPlugin extends JavaPlugin {
    @Override
    protected void setup() {
        // All databases will be stored under this folder
        Caskara.init(new File("mods/MyMod/data"));
    }
}

Important: Caskara.init() must be called before any other Caskara method.

Instead of manually registering every single entity class using Caskara.register(MyClass.class), you can tell Caskara to scan your mod's package recursively. It will automatically find and register everything marked with @CaskaraEntity.

// Scans com.myhytalemod and registers all @CaskaraEntity classes automatically!
Caskara.scanPackage("com.myhytalemod");

3. Define your entity

You can use Caskara annotations to configure entities dynamically.

import com.cookie.caskara.annotations.*;

@CaskaraEntity(shell = "players") // Automatically stores in players.db
@Index("level")                   // Creates an SQL index for the level field
@TTL(minutes = 30)                // Automatically expires after 30 minutes
public class PlayerProfile {
    @Id
    public String profileId;      // @Id overrides the default "id" field name

    public String name;
    public int level;
    public boolean vip;

    public PlayerProfile() {}  // Required by Gson for deserialization
    public PlayerProfile(String name, int level) {
        this.name = name;
        this.level = level;
    }
}

4. Save, load, query, delete

// Save (auto-generates UUID as id)
PlayerProfile p = new PlayerProfile("Cookie", 10);
String id = Caskara.save(p);

// Load
PlayerProfile loaded = Caskara.load(id, PlayerProfile.class);

// List all
List<PlayerProfile> all = Caskara.list(PlayerProfile.class);

// Update (same id -> overwrites)
loaded.level = 11;
Caskara.save(id, loaded);

// Delete
Caskara.delete(id, PlayerProfile.class);

5. Build and deploy

# Compile, package, and deploy to Hytale mods folder
./gradlew jar

# Run tests
./gradlew test

Core Concepts: Shell & Core

Shell

A Shell maps to one SQLite database file. You can have multiple Shells:

Shell global   = Caskara.shell("global");     // → global/global.db
Shell players  = Caskara.shell("players");    // → global/players.db
Shell world    = Caskara.shell(world, "data");// → worlds/<name>/data.db

A Shell holds:

  • The JDBC SQLite connection (thread-safe via ReentrantLock)
  • A map of Core<?> instances (one per Java class)
  • A Stats object tracking performance
  • A scheduled cleanup task that purges expired (TTL) records every minute

Core\<T>

A Core<T> is scoped to a single entity type. Its typeName is derived from the simple class name (lowercased). Internally it manages:

  • LRU in-memory cache (max 500 entries)
  • Before/after save hooks
  • Validators
  • Encryption/decryption
  • Schema migration pipeline
  • Reactive observers

Access a Core via:

Core<PlayerProfile> core = Caskara.core(PlayerProfile.class);
// Or from a specific shell:
Core<PlayerProfile> core = Caskara.shell("players").core(PlayerProfile.class);

API Reference

1. Caskara (Entry Point)

All methods in this class are static. They operate on the default global shell unless otherwise noted.

Method Description
init(File folder) Required first call. Initializes the root data folder.
shell(String name) Opens (or retrieves) a named global Shell.
shell() Opens the default global Shell (global/default.db).
shell(World world, String name) Opens a world-scoped Shell.
core(Class<T> clazz) Returns the Core for clazz from the default Shell.
query(Class<T> clazz) Returns a Query builder for clazz.
save(T object) Saves with auto-generated UUID. Returns the assigned ID.
save(String id, T object) Saves with a specific ID.
save(T object, Duration ttl) Saves with a Time-To-Live. The record is auto-deleted after expiry.
save(T object, long ttlMillis) Same as above using milliseconds.
saveAsync(T object, long ttlMillis) Non-blocking save with TTL. Returns CompletableFuture<String>.
load(String id, Class<T> clazz) Loads by ID. Returns null if not found or expired.
loadAsync(String id, Class<T> clazz) Non-blocking load. Returns CompletableFuture<T>.
list(Class<T> clazz) Returns a List<T> of all active records of that type.
delete(String id, Class<T> clazz) Physically deletes the record.
softDelete(String id, Class<T> clazz) Hides the record without removing it from SQLite.
restore(String id, Class<T> clazz) Unhides a soft-deleted record.
transaction(Consumer<Transaction> action) Executes operations atomically on the default Shell.
stats() Returns the Stats object for the default Shell.
createIndex(Class<T> clazz, String jsonField) Creates a computed SQL index for faster JSON field queries.
enableAutoBackup(int intervalMinutes) Schedules periodic .bak file copies of the default Shell.
exportShell(File file) Dumps all data from the default Shell to a JSON file.
importShell(File file) Bulk-imports records from a JSON file into the default Shell.
encrypt(Class<T> clazz, String key) Enables AES-128 encryption for all records of clazz.
rotateKey(Class<T> clazz, String oldKey, String newKey) Re-encrypts all records of clazz from an old key to a new key.
migration(Class<T> clazz, int version, Function<JsonObject, JsonObject> fn) Registers a schema migration function.
getId(Object object) Reflectively reads the id, uuid, or uid field from any object.

2. Shell

Shell is usually accessed indirectly via Caskara.shell(...). Direct access is useful for multi-shell setups.

Shell playersShell = Caskara.shell("players");
Core<PlayerProfile> core = playersShell.core(PlayerProfile.class);
playersShell.transaction(tx -> { ... });
Stats stats = playersShell.getStats();
playersShell.exportToJson(new File("backup.json"));
playersShell.close(); // important! shuts down connection and scheduler
Method Description
core(Class<T> clazz) Returns the Core for the specified class.
transaction(Consumer<Transaction> action) ACID transaction. See Transaction.
getStats() Returns the Shell's Stats tracker.
exportToJson(File file) Exports all records to JSON.
importFromJson(File file) Imports records from JSON.
getConnection() Returns the raw JDBC Connection (advanced use only).
runInLock(Supplier<R> action) Executes an action under the Shell's ReentrantLock.
close() Closes the JDBC connection. Always call this on plugin shutdown.

3. Core<T>

Core<PlayerProfile> core = Caskara.core(PlayerProfile.class);
Method Description
preserve(T element) Saves with auto-UUID. Returns the ID.
preserve(String id, T element) Saves with a specific ID.
preserve(String id, T element, Long expiresAtMs) Saves with an absolute expiry timestamp.
preserveAsync(String id, T element) Non-blocking save. Returns CompletableFuture<String>.
extract(String id) Loads by ID, returns Pearl<T>.
extractAll() Returns List<T> of all active, non-expired, non-deleted records.
discard(String id) Physically deletes the record.
softDelete(String id) Sets deleted_at timestamp; record is hidden from queries.
restore(String id) Clears deleted_at; record becomes visible again.
query() Returns a Query<T> builder for this Core.
createIndex(String jsonField) Creates a computed SQL index: idx_<type>_<field>.
setSecurityKey(String key) Enables AES-128 encryption on this Core.
addValidator(Predicate<T> validator) Throws ValidationException if the predicate returns false on save.
onBeforeSave(BiConsumer<String, T> hook) Fires before every preserve() call.
onAfterSave(BiConsumer<String, T> hook) Fires after every successful preserve() call.
onBeforeDelete(Consumer<String> hook) Fires before every discard() call.
observe(String id, BiConsumer<String, T> observer) Fires when a specific record is saved or updated.
observeAll(BiConsumer<String, T> observer) Fires when ANY record of this type is saved or updated.
registerMigration(int version, Function<JsonObject, JsonObject> fn) Registers a migration applied lazily on next read.

4. Query<T>

Fluent builder. Obtained via Caskara.query(Class) or core.query().

List<PlayerProfile> results = Caskara.query(PlayerProfile.class)
    .field("vip", true)
    .fieldGreaterThan("level", 5)
    .orderBy("level", Query.Order.DESC)
    .page(1, 20)
    .fetch();
Method Description
field(String name, Object value) Exact match: json_extract(json, '$.name') = value.
fieldGreaterThan(String name, Object value) Greater-than comparison on a JSON field.
fieldLessThan(String name, Object value) Less-than comparison on a JSON field.
fieldIn(String name, List<Object> values) Matches any value in the list (SQL IN).
fieldContains(String name, String text) SQL LIKE '%text%' on a JSON string field.
orderBy(String field, Order direction) Sort by a JSON field. Use Query.Order.ASC or DESC.
limit(int n) Restrict result count.
offset(int n) Skip the first n results.
page(int page, int size) Convenience pagination. page(1, 20) → first 20 results.
fetch() Executes and returns List<T> (blocking).
fetchAsync() Non-blocking; returns CompletableFuture<List<T>>.
fetchFirst() Returns Pearl<T> with the first result.
search(String text) SQLite FTS5 instant text match across the entire JSON.

Ultra-Fast Full-Text Search (FTS5)

If you need to search text inside your entities (like chat logs, item names, etc), use the @FullTextSearch annotation on your class. This creates a synchronized FTS5 virtual table under the hood, making .search("text") queries up to 10,000x faster than using .fieldContains().

@FullTextSearch
@CaskaraEntity(shell = "chat_logs")
public class ChatMessage {
    @Id
    public String id;
    public String text;
}

// Searching millions of rows in milliseconds:
List<ChatMessage> results = Caskara.query(ChatMessage.class).search("diamond sword").fetch();

Warning: @FullTextSearch is incompatible with @Encrypted. FTS5 requires plaintext JSON to build its dictionary index. Caskara will throw an error if both are used.

Performance tip: Call Caskara.createIndex(MyClass.class, "fieldName") before querying a field repeatedly to get O(1) lookups via a computed SQL index.


5. Transaction

If you have multiple operations that must succeed or fail together, wrap them in a transaction:

Caskara.transaction(tx -> {
    Wallet sender   = tx.load("player_1", Wallet.class);
    Wallet receiver = tx.load("player_2", Wallet.class);
    sender.balance   -= 500;
    receiver.balance += 500;
    tx.save("player_1", sender);
    tx.save("player_2", receiver);
});
// If any exception is thrown inside the lambda, ALL changes are rolled back automatically!
Method Description
save(T object) Atomically preserves with auto-UUID.
save(String id, T object) Atomically preserves with given ID.
delete(String id, Class<T> clazz) Atomically deletes a record.
load(String id, Class<T> clazz) Loads within the transaction lock.

Bulk Operations (High Performance)

If you need to save or delete thousands of entities at once, use the saveAll and deleteAll helper methods. These automatically wrap your operations in a highly optimized SQL transaction, making mass inserts up to 1000x faster.

List<Player> massivePlayerList = getThousandsOfPlayers();
Caskara.saveAll(massivePlayerList);

// Deleting many records simultaneously
Caskara.deleteAll(Arrays.asList("id1", "id2", "id3"), Player.class);

6. Admin Commands (In-Game / Console)

Caskara comes with a built-in set of commands to manage databases directly from the Hytale game chat or server console. To make these commands available, you simply need to register them during your plugin's setup() method:

@Override
protected void setup() {
    Caskara.init(new File("mods/MyMod/data"));
    Caskara.registerCommands(this.getCommandRegistry());
}

This registers the /caskara command, restricted to the "admin" permission group.

Usage

  • /caskara stats: Prints an aggregated summary of cache hits/misses, queries, and active shell counts across the entire Caskara instance.
  • /caskara vacuum: Forces SQLite to run VACUUM on all active database shells. This reclaims raw disk space left over by deleted or TTL-expired records.
  • /caskara backup: Manually triggers an instant backup of all active database shells. The .bak files are saved in a backups directory next to the .db files.
  • /caskara autobackup <hours>: Changes the interval of the Auto-Backup system dynamically without needing to restart the server. Use 0 to disable it.
  • /caskara scan <package>: Manually triggers the Auto-Scanner for the given package name to detect and register @CaskaraEntity classes on-the-fly.
  • /caskara dump <entity_id>: Searches the global database for an exact ID match. Since JSON outputs can be massive, the raw data is formatted and printed to the Server Console (via standard output logs) to avoid flooding your chat window.

Background Auto-Tasks (Vacuum & Backup)

By default, when you call Caskara.init(), two automated systems are silently activated in the background: 1. Auto-Vacuum: Runs every 12 hours to reclaim unused disk space. 2. Auto-Backup: Runs every 1 hour to safely copy all database files (using SQLite locks to prevent corruption).

If your server has different needs, you can customize their frequencies or disable them entirely directly via Java:

// Change to run every 24 hours
Caskara.enableAutoVacuum(24);

// Change backups to every 6 hours
Caskara.enableAutoBackup(6);

// Disable completely (0 or negative)
Caskara.enableAutoVacuum(0);
Caskara.enableAutoBackup(0);

Note: Don't forget to call Caskara.shutdown() when your Hytale server stops to cleanly terminate the background executor thread.

// Bad: takes several seconds for (Player p : massivePlayerList) { Caskara.save(p); }

// Good: takes milliseconds! Caskara.saveAll(massivePlayerList);

// You can also bulk delete by IDs: Caskara.deleteAll(Player.class, List.of("id1", "id2", "id3"));

---

### 6. `Pearl<T>`

Wraps the result of an async or sync data operation. Think of it as a more ergonomic `Optional<CompletableFuture<T>>`.

```java
Pearl<PlayerProfile> pearl = Caskara.core(PlayerProfile.class).extract("player-123");

// Blocking retrieval (5s timeout)
Optional<PlayerProfile> opt = pearl.sync();

// Non-blocking
pearl.async().thenAccept(optProfile -> { ... });

// Inline action
pearl.ifFound(profile -> System.out.println("Found: " + profile.name));

// Transform result type
Pearl<String> namePearl = pearl.map(p -> p.name);

Method Description
sync() Blocks until result is ready (max 5s). Returns Optional<T>. Throws DatabaseException on failure.
async() Non-blocking. Returns CompletableFuture<Optional<T>>.
ifFound(Consumer<T> action) Executes action synchronously if value is present.
map(Function<T, R> mapper) Transforms the wrapped type. Returns Pearl<R>.

7. Stats

Tracks internal performance metrics for a Shell.

Stats stats = Caskara.stats();
System.out.println("Cache hit rate: " + stats.getCacheHitRate() * 100 + "%");
System.out.println("Avg query time: " + stats.getAverageQueryTimeMs() + "ms");
System.out.println("Total queries:  " + stats.getTotalQueries());
Method Return Type Description
getCacheHitRate() double Fraction of extract() calls served from LRU cache (0.0–1.0).
getAverageQueryTimeMs() double Mean SQL query execution time in milliseconds.
getTotalQueries() long Total number of fetch() calls executed since shell init.
getCacheHits() long Raw count of cache hits.
getCacheMisses() long Raw count of cache misses.

Advanced Features

AES-256 Encryption

Caskara can store data as AES-encrypted Base64 blobs. The key is derived using SHA-256.

// Call before any save operations for that class
Caskara.encrypt(SecretToken.class, "my-super-secret-password");

// From this point, all saves are encrypted, all loads are decrypted automatically
Caskara.save(new SecretToken("discord-bot-token", "xyzABC123"));
SecretToken loaded = Caskara.load("my-token", SecretToken.class); // decrypted

Key Rotation

If you need to change your encryption key safely without losing data, use the rotateKey method. It loads all existing data with the old key, and rewrites them using the new key:

Caskara.rotateKey(SecretToken.class, "old-password", "new-stronger-password");

Safety Guarantee: Caskara gracefully handles data if read with an incorrect key (returning null and logging a warning instead of crashing). However, rewriting data with the wrong key active will overwrite it. Always use rotateKey for smooth transitions.


Schema Migrations

Register migration functions that transform JSON from old schema versions on the fly, the first time an old record is read.

// PlayerProfile v1 had no "rank" field. Add it as default "BRONZE" for v1 records.
Caskara.migration(PlayerProfile.class, 2, json -> {
    if (!json.has("rank")) {
        json.addProperty("rank", "BRONZE");
    }
    return json;
});

// Chain multiple migrations:
Caskara.migration(PlayerProfile.class, 3, json -> {
    // rename field "vip" → "premium"
    if (json.has("vip")) {
        json.addProperty("premium", json.get("vip").getAsBoolean());
        json.remove("vip");
    }
    return json;
});

Migrations are applied lazily — only when a record with an older version is read. The record is then re-saved at the current schema version automatically.


TTL (Time To Live)

Records can auto-expire after a set duration. A background worker cleans them up every minute.

// Expire after 30 minutes
Caskara.save(new TempBuff("fire_resistance"), Duration.ofMinutes(30));

// Or in milliseconds
Caskara.save(new TempBuff("speed"), 60_000L);

A record past its expiry is invisible to all queries and load() calls, even if not yet physically deleted. The cleanup task physically removes them within ~1 minute.


Soft Delete & Restore

Non-destructive deletion. The data stays in the DB but is filtered out of all results.

Caskara.softDelete("player-123", PlayerProfile.class);
// Now: load() returns null, list() excludes it, queries ignore it

Caskara.restore("player-123", PlayerProfile.class);
// Now: record is fully visible again

Hooks & Validators

Core<PlayerProfile> core = Caskara.core(PlayerProfile.class);

// Reject invalid data before it reaches the DB
core.addValidator(p -> p.level > 0 && p.name != null && !p.name.isBlank());

// Fire custom logic before every save
core.onBeforeSave((id, p) -> p.name = p.name.trim());

// Fire custom logic after every successful save
core.onAfterSave((id, p) -> AuditLog.record("Saved player: " + id));

// Fire custom logic before a physical delete
core.onBeforeDelete(id -> AuditLog.record("Deleted player: " + id));

ValidationException is thrown if any validator returns false, preventing the save.


Reactive Observers

Subscribe to changes on specific records or all records of a type.

Core<PlayerProfile> core = Caskara.core(PlayerProfile.class);

// Watch a specific player
core.observe("player-123", (id, p) -> {
    System.out.println("Player " + id + " was updated! New level: " + p.level);
});

// Watch all players
core.observeAll((id, p) -> {
    Websocket.broadcast("player_updated", id);
});

Observers fire synchronously after every successful preserve().


Automated Backups

// Back up every 15 minutes
Caskara.enableAutoBackup(15);

Backups are stored as <shellname>.db.<timestamp>.bak in <dataFolder>/backups/. The Shell is locked during the copy to ensure consistency.


Export & Import

// Export the entire default shell to JSON
Caskara.exportShell(new File("export.json"));

// Import records from a JSON file (uses INSERT OR REPLACE)
Caskara.importShell(new File("export.json"));

// Per-shell:
Caskara.shell("players").exportToJson(new File("players_backup.json"));

The JSON format is an array of {id, type, json} objects.


Exception Hierarchy

All exceptions are unchecked (extend RuntimeException) so they don't pollute your method signatures. Catch them in critical areas.

RuntimeException
└── CaskaraException          ← Base for all Caskara errors
    ├── DatabaseException     ← SQL errors, I/O failures, transaction rollbacks
    └── ValidationException   ← Thrown when an addValidator() predicate returns false
try {
    Caskara.transaction(tx -> { ... });
} catch (DatabaseException e) {
    logger.error("Transaction failed: " + e.getMessage(), e);
} catch (ValidationException e) {
    player.sendMessage("Invalid data: " + e.getMessage());
}

Data Types & Best Practices

Supported Types (via Gson)

  • Primitives: int, long, double, boolean, float, etc.
  • Strings: String
  • Collections: List<T>, Set<T>, Map<K, V> — serialized automatically
  • Nested objects: Any Java class. Serialized as JSON sub-trees.
  • Transient fields: Fields marked transient are excluded from JSON.

Primary Keys

Caskara auto-discovers the ID by looking for fields named id, uuid, or uid in order. The ID is synced back to the field after preserve().

// Best practice: always include this field
public String id;

No-arg Constructor Required

Gson needs a default constructor to deserialize JSON back to your class:

public PlayerProfile() {}   // ← mandatory!
public PlayerProfile(String name) { this.name = name; }

Threading

  • Shells are thread-safe via ReentrantLock. Safe to call from multiple threads.
  • Async paths use Java 21 Virtual Threads (lightweight; no manual thread pool tuning needed).
  • Transactions hold the lock for their full duration — keep them fast and focused.

Performance Tips

  1. Use createIndex() before querying JSON fields repeatedly.
  2. Prefer extractAll() + in-memory filtering for small datasets rather than many individual load() calls.
  3. Use loadAsync() / fetchAsync() in event handlers to avoid blocking the server thread.
  4. Monitor stats().getCacheHitRate() — below 50% may indicate the cache is too small for your dataset.

When NOT to Use Caskara

  • Large BLOBs (images, audio): use Hytale's asset pipeline.
  • Relational data with many joins: use raw SQL or a dedicated ORM.
  • Multi-server shared state: use Redis or a dedicated external DB; SQLite is single-writer.