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
- Project Structure
- Architecture Overview
- Getting Started
- Core Concepts: Shell & Core
- API Reference
- Caskara (Entry Point)
- Shell
- Core\<T>
- Query\<T>
- Transaction
- Pearl\<T>
- Stats
- Advanced Features
- AES-256 Encryption
- Schema Migrations
- TTL (Time To Live)
- Soft Delete & Restore
- Hooks & Validators
- Reactive Observers
- Automated Backups
- Export & Import
- Exception Hierarchy
- 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
.dbfile. 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.
Auto-Scan Package (Recommended)
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
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
Statsobject 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>
| 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:
@FullTextSearchis 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 runVACUUMon 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.bakfiles are saved in abackupsdirectory next to the.dbfiles./caskara autobackup <hours>: Changes the interval of the Auto-Backup system dynamically without needing to restart the server. Use0to disable it./caskara scan <package>: Manually triggers the Auto-Scanner for the given package name to detect and register@CaskaraEntityclasses 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:
Safety Guarantee: Caskara gracefully handles data if read with an incorrect key (returning
nulland logging a warning instead of crashing). However, rewriting data with the wrong key active will overwrite it. Always userotateKeyfor 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
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
transientare 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().
No-arg Constructor Required
Gson needs a default constructor to deserialize JSON back to your class:
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
- Use
createIndex()before querying JSON fields repeatedly. - Prefer
extractAll()+ in-memory filtering for small datasets rather than many individualload()calls. - Use
loadAsync()/fetchAsync()in event handlers to avoid blocking the server thread. - 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.