Storage
Package: dev.anchorlight.stonelib.storage
Most plugin data is a keyed collection: profiles by UUID, waypoints by name, arenas by id. StoneLib models that as a Repository, a cache in memory backed by a store on disk, with a RecordCodec that turns your objects into flat maps and back.
For state shared between servers, use MySqlRepository instead.
Repository
public interface Repository<K, V> {
void load(); // replace the cache with what is in the store
void save(); // write the cache to the store
V get(K key);
void put(K key, V value);
void remove(K key);
Collection<V> all();
}
get, put, remove, and all work on the in-memory cache only. Nothing reaches the store until save. all returns a live view of the cache, not a copy.
RecordCodec
public interface RecordCodec<V> {
Map<String, Object> toMap(V value);
V fromMap(Map<String, Object> map);
}
Keep the map flat: one entry per field, with simple values. A worked example for a record:
public record Waypoint(String name, Location location, String createdBy) {}
public final class WaypointCodec implements RecordCodec<Waypoint> {
@Override
public Map<String, Object> toMap(Waypoint waypoint) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", waypoint.name());
map.put("location", LocationCodec.serialize(waypoint.location()));
map.put("created_by", waypoint.createdBy());
return map;
}
@Override
public Waypoint fromMap(Map<String, Object> map) {
return new Waypoint(
String.valueOf(map.get("name")),
LocationCodec.deserialize(String.valueOf(map.get("location"))),
String.valueOf(map.get("created_by")));
}
}
If fromMap throws for a record, that record is logged as malformed and skipped, and the rest still load.
:::tip Read values as strings when you want to swap stores
The YAML repository gives fromMap the values YAML parsed, so a number arrives as an Integer. The SQLite and MySQL repositories store every value as text, so the same number arrives as a String. A codec that parses from String.valueOf(value) works with all three.
:::
YamlRepository
One YAML file, with every record under a records section.
new YamlRepository<>(JavaPlugin plugin, String fileName, RecordCodec<V> codec,
Function<V, K> keyExtractor, Function<String, K> keyParser)
| Parameter | Meaning |
|---|---|
fileName | Path inside the plugin's data folder. |
codec | Converts records to and from maps. |
keyExtractor | Gets a record's key. Required by the constructor, but not currently used by this repository. |
keyParser | Turns a YAML key back into your key type, such as UUID::fromString. |
YamlRepository<String, Waypoint> waypoints = new YamlRepository<>(this, "waypoints.yml",
new WaypointCodec(), Waypoint::name, Function.identity());
waypoints.load();
waypoints.put("spawn", new Waypoint("spawn", player.getLocation(), player.getName()));
waypoints.save();
The file it writes:
records:
spawn:
name: spawn
location: world:0.5:64.0:0.5:0.0:0.0
created_by: Steve
| Behaviour | Detail |
|---|---|
load with no file | Leaves the cache empty. Not an error. |
save | Rebuilds the whole file from the cache, creating parent folders as needed. A failure is logged and the file is left as it was. |
| Keys | Written with the key's toString(). A key containing . becomes nested YAML sections and will not load back correctly, so avoid dots in keys. |
| Comments | Not preserved. This file is data, not config. |
SqliteRepository
One SQLite database file, with one row per record in a single table.
new SqliteRepository<>(JavaPlugin plugin, String fileName, String tableName, RecordCodec<V> codec,
Function<V, K> keyExtractor, Function<String, K> keyParser)
The parameters match YamlRepository, plus tableName. As there, keyExtractor is required but not currently used.
SqliteRepository<UUID, Profile> profiles = new SqliteRepository<>(this, "data.db", "profiles",
new ProfileCodec(), Profile::id, UUID::fromString);
profiles.load();
How the table is shaped
- The table has a
key TEXT PRIMARY KEYcolumn, and oneTEXTcolumn per entry in your codec's map. loadcreates the table with only thekeycolumn if it does not exist.savetakes the columns from the first record in the cache, and adds any column the table does not already have. Columns are never removed.- Every value is written as text using
String.valueOf, so anullfield is stored as the textnull. - Every record should produce the same map keys. Columns are only added from the first record, so a later record with an extra key fails its insert. That failure is logged and ends the save, and since the table was already cleared, every row after it is missing until the next successful save.
How save works
save deletes every row in the table, then inserts one row per cached record. That keeps the table an exact copy of the cache, but:
- A file shared by two plugins or two servers will have one's writes discarded by the other's save. SQLite repositories are for one plugin on one server.
- The inserts are not wrapped in a transaction. A crash part-way through a save can leave the table with only some of its rows. Save regularly, and not only on shutdown.
- Every save rewrites every row, so very large tables are better suited to MySQL, which writes only what changed.
:::caution Table and column names are not escaped
The table name and your codec's map keys are placed directly into SQL. Use plain identifiers made of letters, digits, and underscores, and never build them from player input. MySqlRepository validates these; SqliteRepository does not.
:::
Dependency
SqliteRepository needs sqlite-jdbc, which StoneLib brings in transitively. If you do not use it, exclude that dependency to shrink your jar. See Installation.
LocationCodec
Converts a Bukkit Location to and from one string: world:x:y:z:yaw:pitch.
| Method | Meaning |
|---|---|
serialize(Location) | For example world:10.5:64.0:-3.5:90.0:0.0. A location with no world is written with the world name world. |
deserialize(String) | The location, or null if the text is blank, has fewer than four parts, has a number that does not parse, or names a world that is not loaded. Yaw and pitch are optional and default to 0. |
:::caution World names containing a colon
The format is split on :, so a world named event:2026 cannot be round-tripped. Keep world names to letters, digits, underscores, and hyphens.
:::
deserialize looks the world up at call time, so a location saved in a world that loads later returns null if it is decoded too early. Decode after worlds are loaded, or store the raw string and decode on use.