Storage API
A platform module persists state through two controller-owned handles, both scoped to the module’s id so collection and key names cannot collide across modules:
ModuleDataStore— Mongo-backed document store, obtained fromModuleContext.requireMongoStorage().PlatformRedisStorage— Redis/Valkey key/value store, obtained fromModuleContext.requireRedisStorage().
Both handles are allocated by PlatformModuleStorageManager on the controller when the module declares storage: in its module.yaml. A module that calls requireMongoStorage() without mongo: true, or requireRedisStorage() without redis: true, gets an empty handle and the require-form throws IllegalStateException. Daemon-hosted contexts have no Mongo or Redis binding and return empty for both.
All types in this page live under me.prexorjustin.prexorcloud.api.module.data (the Mongo side) and me.prexorjustin.prexorcloud.api.module.platform (the Redis side and the storage request record).
Obtaining the handles
ModuleContext exposes four accessors:
Optional<ModuleDataStore> findMongoStorage();ModuleDataStore requireMongoStorage(); // throws if mongo not grantedOptional<PlatformRedisStorage> findRedisStorage();PlatformRedisStorage requireRedisStorage(); // throws if redis not grantedrequireMongoStorage() throws IllegalStateException: mongo storage is not available for module '<id>' when the module did not request Mongo (or Mongo is not configured on the controller). Use the find* form when storage is optional; use the require* form in onEnable for a module that cannot run without it.
@Overridepublic void onEnable(ModuleContext ctx) { ModuleDataStore mongo = ctx.requireMongoStorage(); this.repository = new PlaytimeRepository(mongo);
ctx.findRedisStorage().ifPresent(redis -> this.rateLimiter = new RateLimiter(redis));}ModuleDataStore — Mongo
me.prexorjustin.prexorcloud.api.module.data.ModuleDataStore. A document-oriented store scoped to one module’s collection namespace. Documents round-trip through Jackson — you pass and receive your own Java records, the implementation converts.
Namespacing
String collectionPrefix();void ensureCollection(String name);void createIndex(String collection, IndexSpec index);collectionPrefix() returns the per-module prefix the controller allocated — platform_<sanitized-id>_, where the id is lowercased and every character outside [a-z0-9_] becomes _. For module id example-playtime the prefix is platform_example_playtime_.
Every collection argument to find, insertOne, updateOne, and so on is qualified by this prefix. Calling insertOne("sessions", …) writes to platform_example_playtime_sessions. A module cannot name a collection outside its prefix and so cannot read another module’s data through this handle.
ensureCollection(name) creates the collection if it does not exist and is a no-op otherwise (it swallows Mongo error code 48, “collection already exists”). Safe to call on every onEnable.
createIndex(collection, index) creates the index described by an IndexSpec. createIndex is idempotent at the driver level — re-creating an identical index does nothing.
store.ensureCollection("sessions");store.createIndex("sessions", IndexSpec.asc("playerId"));store.createIndex("sessions", IndexSpec.asc("sessionId").asUnique());Insert
<T> String insertOne(String collection, T document);<T> int insertMany(String collection, List<T> documents);insertOne serialises the document via Jackson and returns the generated Mongo _id as a hex string. Any id/_id field on your record is dropped before insert so Mongo generates the ObjectId. insertMany returns the number of documents inserted; an empty list returns 0 without touching the database.
String id = store.insertOne("sessions", new Session(playerId, sessionId, Instant.now(), null, 0, "survival-lobby"));// id -> "665f1c3a9d4b2e0017a3c8f1"When you read a document back, the ObjectId _id is exposed to Jackson as a string field named id. Add an id field to your record (or ignore it) to capture it on deserialisation.
Read
<T> Optional<T> findOne(String collection, Query filter, Class<T> type);<T> List<T> find(String collection, Query filter, Sort sort, int limit, Class<T> type);<T> List<T> find(String collection, Query filter, Sort sort, int limit, int skip, Class<T> type);long count(String collection, Query filter);findOne returns the first matching document or Optional.empty(). find returns matches ordered by sort. limit and skip of 0 mean unlimited / no offset — pass Integer.MAX_VALUE to read an entire collection. The five-argument find is the two-argument-paginated form; the four-argument overload delegates to it with skip = 0.
Optional<TopEntry> total = store.findOne("totals", Query.where("playerId").eq(playerId.toString()), TopEntry.class);
List<Session> recent = store.find("sessions", Query.where("playerId").eq(playerId.toString()), Sort.desc("joinAt"), 20, Session.class);
long open = store.count("sessions", Query.where("quitAt").exists(false));Update
int updateOne(String collection, Query filter, Update update);int updateMany(String collection, Query filter, Update update);boolean upsertOne(String collection, Query filter, Update update);updateOne and updateMany return the modified-document count (matched-but-unchanged documents do not count). upsertOne updates the first match or inserts a new document if none matches; it returns true when a new document was created, false when an existing one was updated. An Update with no operations throws DataStoreException("Empty update — nothing to apply").
int closed = store.updateOne("sessions", Query.where("sessionId").eq(sessionId.toString()), Update.set("quitAt", quitAt).andSet("durationMs", durationMs));
boolean created = store.upsertOne("totals", Query.where("playerId").eq(playerId.toString()), Update.inc("totalMs", durationMs) .andSetOnInsert("playerId", playerId.toString()));Delete
boolean deleteOne(String collection, Query filter);int deleteMany(String collection, Query filter);deleteOne returns true when a document was deleted. deleteMany returns the count deleted.
int purged = store.deleteMany("sessions", Query.where("joinAt").lt(cutoff));Transaction
@FunctionalInterfaceinterface TransactionWork { void execute(ModuleDataStore txStore) throws Exception;}
void withTransaction(TransactionWork work);withTransaction runs work inside a single multi-document ACID transaction. The txStore passed to the lambda is a short-lived store bound to the transaction’s Mongo session — every operation you issue on it joins the transaction. Do not capture txStore past the lambda, and do not issue operations on the outer store inside the lambda (those run outside the transaction). A thrown exception aborts the transaction and is rethrown wrapped in DataStoreException("Transaction failed", e).
store.withTransaction(tx -> { tx.deleteMany("totals", Query.all()); tx.insertMany("totals", rebuiltLeaderboard);});Query builder
me.prexorjustin.prexorcloud.api.module.data.Query — a fluent, driver-agnostic filter. Start with where(field) or a composite (and/or/all), then terminate each field with an operator.
Entry points:
static Query where(String field); // start a field conditionstatic Query all(); // empty filter — matches every documentstatic Query and(Query... queries); // AND of sub-queriesstatic Query or(Query... queries); // OR of sub-queriesOperators (each terminates the pending field set by where/and):
| Method | Mongo operator | Argument |
|---|---|---|
eq(Object) | $eq | any value |
ne(Object) | $ne | any value |
gt(Object) | $gt | comparable value |
gte(Object) | $gte | comparable value |
lt(Object) | $lt | comparable value |
lte(Object) | $lte | comparable value |
in(Collection<?>) | $in | a collection (copied) |
exists(boolean) | $exists | presence flag |
regex(String) | $regex | pattern string |
Chaining: and(String field) adds another condition on the same query, ANDed together. A field with no operator (Query.where("x").and("y")) throws IllegalStateException, as does an operator with no pending field.
Query.where("status").eq("QUEUED").and("to_uuid").in(uuids);
Query.or( Query.where("from_uuid").eq(a).and("to_uuid").eq(b), Query.where("from_uuid").eq(b).and("to_uuid").eq(a));A condition on field _id whose value is a valid ObjectId hex string is coerced to an ObjectId automatically, so you can filter by the id returned from insertOne.
Update builder
me.prexorjustin.prexorcloud.api.module.data.Update — a fluent update spec. Start with a static factory, chain with the and* methods.
Static entry points:
static Update set(String field, Object value); // $setstatic Update setOnInsert(String field, Object value); // $setOnInsert (upsert only)static Update inc(String field, Number value); // $incstatic Update unset(String field); // $unsetInstance chaining:
Update andSet(String field, Object value);Update andSetOnInsert(String field, Object value);Update andInc(String field, Number value);Update andUnset(String field);setOnInsert fields apply only when an upsertOne inserts a new document — use them to seed immutable fields (creation timestamp, owning id) without overwriting them on later updates.
Update.set("status", "DELIVERED").andSet("delivered_at", Instant.now());Update.inc("counter", 1);Update.setOnInsert("created_at", now).andInc("hits", 1);Index builder
me.prexorjustin.prexorcloud.api.module.data.IndexSpec — an immutable index spec (a record of keys, unique, name).
static IndexSpec asc(String... fields); // single or compound, ascendingstatic IndexSpec desc(String... fields); // single or compound, descendingstatic IndexSpec compound(Map<String,Integer> keys); // explicit field -> direction (1 / -1)
IndexSpec asUnique(); // copy marked uniqueIndexSpec withName(String n); // copy with an explicit index nameasUnique() and withName() return copies — chain them off a factory.
store.createIndex("sessions", IndexSpec.asc("playerId"));store.createIndex("sessions", IndexSpec.asc("sessionId").asUnique());store.createIndex("totals", IndexSpec.desc("totalMs"));store.createIndex("messages", IndexSpec.compound(Map.of("to_uuid", 1, "status", 1)).withName("inbox_idx"));Sort builder
me.prexorjustin.prexorcloud.api.module.data.Sort — an immutable sort spec.
static Sort asc(String field);static Sort desc(String field);static Sort none(); // no ordering
Sort thenAsc(String field); // append a secondary keySort thenDesc(String field);thenAsc/thenDesc return new instances, so chain them.
Sort.desc("joinAt");Sort.desc("totalMs").thenAsc("playerId");Sort.none(); // pass with limit = Integer.MAX_VALUE to stream a whole collection unorderedErrors
DataStoreException— aRuntimeExceptionraised for store-level failures: collection creation failures, an emptyUpdate, an unsupported operator, or a transaction body that threw.IllegalStateException— raised by theQuerybuilder for a field without an operator, or an operator without a field; and byrequireMongoStorage()when Mongo was not granted.
PlatformRedisStorage — Redis / Valkey
me.prexorjustin.prexorcloud.api.module.platform.PlatformRedisStorage. A key/value store scoped to one module. Backed by Lettuce; available only when controller coordination (Redis/Valkey) is enabled.
String keyPrefix();default String qualify(String key); // keyPrefix() + keyOptional<String> get(String key);void set(String key, String value);void set(String key, String value, Duration ttl);long increment(String key);long decrement(String key);boolean delete(String key);keyPrefix() returns the per-module prefix the controller allocated: prexor:v1:platform:<sanitized-id>:. Same sanitisation as Mongo (lowercase, non-[a-z0-9_] → _). qualify(key) prepends the prefix; it rejects a null or blank key with IllegalArgumentException. Every read and write below qualifies the key, so a module’s keys never collide with another’s.
| Method | Behaviour | Returns |
|---|---|---|
get(key) | GET prefix+key | the value, or Optional.empty() if absent |
set(key, value) | SET prefix+key value | — |
set(key, value, ttl) | SETEX prefix+key ttl.toSeconds() value | — |
increment(key) | INCR prefix+key | the value after increment |
decrement(key) | DECR prefix+key | the value after decrement |
delete(key) | DEL prefix+key | true if a key was removed |
set with a Duration rejects a null ttl with NullPointerException; the ttl is applied at second granularity. increment/decrement create the key at 0 if absent (standard Redis INCR/DECR).
PlatformRedisStorage redis = ctx.requireRedisStorage();
long hits = redis.increment("ratelimit:" + playerId); // 1, 2, 3, …redis.set("session:" + sessionId, token, Duration.ofMinutes(30));Optional<String> tok = redis.get("session:" + sessionId);boolean dropped = redis.delete("session:" + sessionId);Use Redis for short-lived counters, rate limits, distributed locks, and per-instance caches; use Mongo for durable state.
Storage quotas
When the manifest declares limits.mongoDocuments or limits.redisKeys, the controller wraps the handle in a quota-enforcing decorator. These are soft limits checked before a write.
- Mongo: every
insertOne/insertMany, and anupsertOnethat will insert, counts the documents currently held under the module’s collection prefix. ExceedingmongoDocumentsthrowsStorageQuotaExceededExceptionand the write does not happen. Reads, deletes, and updates that do not grow the document count are never blocked. - Redis: every write to a key that does not already exist (
set,increment,decrementon a new key) counts the keys under the module’s prefix. ExceedingredisKeysthrowsStorageQuotaExceededException. Writes to an existing key,get, anddeleteare never blocked.
StorageQuotaExceededException is a RuntimeException (me.prexorjustin.prexorcloud.api.module.platform). A zero or absent limit means unlimited.
Manifest declaration
The storage: block in module.yaml maps to ModuleStorageRequest:
storage: mongo: true redis: true limits: mongoDocuments: 100000 redisKeys: 100000| Field | Type | Default | Meaning |
|---|---|---|---|
mongo | bool | false | request a Mongo ModuleDataStore handle |
redis | bool | false | request a PlatformRedisStorage handle |
limits.mongoDocuments | long | 0 (unlimited) | soft cap on documents under the module’s prefix |
limits.redisKeys | long | 0 (unlimited) | soft cap on keys under the module’s prefix |
Both limits must be >= 0. A mongoDocuments limit without mongo: true, or a redisKeys limit without redis: true, fails manifest validation (IllegalArgumentException). Requesting a backend the controller has not configured fails at load: module '<id>' requested Mongo storage but Mongo is not configured.
Worked example
The reference example-playtime module keeps all persistence in one repository, takes the store as a constructor argument (no field injection), and bootstraps its schema once on enable. Collection names are written bare — the store qualifies them to platform_example_playtime_sessions / …_totals.
public final class PlaytimeRepository {
public static final String SESSIONS = "sessions"; public static final String TOTALS = "totals";
private final ModuleDataStore store;
public PlaytimeRepository(ModuleDataStore store) { this.store = store; store.ensureCollection(SESSIONS); store.ensureCollection(TOTALS); store.createIndex(SESSIONS, IndexSpec.asc("playerId")); store.createIndex(SESSIONS, IndexSpec.asc("sessionId").asUnique()); store.createIndex(SESSIONS, IndexSpec.desc("joinAt")); store.createIndex(TOTALS, IndexSpec.asc("playerId").asUnique()); store.createIndex(TOTALS, IndexSpec.desc("totalMs")); }
public void openSession(Session session) { store.insertOne(SESSIONS, session); }
public void closeSession(UUID sessionId, Instant quitAt, long durationMs) { store.updateOne(SESSIONS, Query.where("sessionId").eq(sessionId.toString()), Update.set("quitAt", quitAt).andSet("durationMs", durationMs)); }
public List<Session> recentSessions(UUID playerId, int limit) { return store.find(SESSIONS, Query.where("playerId").eq(playerId.toString()), Sort.desc("joinAt"), limit, Session.class); }
public List<TopEntry> top(int limit) { return store.find(TOTALS, Query.all(), Sort.desc("totalMs"), limit, TopEntry.class); }
// Rebuild the totals cache atomically so a reader never sees a half-rebuilt leaderboard. public int rebuildTotals(List<TopEntry> rebuilt) { store.withTransaction(tx -> { tx.deleteMany(TOTALS, Query.all()); tx.insertMany(TOTALS, rebuilt); }); return rebuilt.size(); }
public int deleteOlderThan(Instant cutoff) { return store.deleteMany(SESSIONS, Query.where("joinAt").lt(cutoff)); }}Session is a plain Jackson-serialisable record — no manual mapping:
public record Session( UUID playerId, UUID sessionId, Instant joinAt, Instant quitAt, long durationMs, String serverName) {}See also
- ModuleContext — how the storage handles are obtained, alongside events, scheduler, and HTTP client.
- module.yaml — the full manifest schema including
storage:. - PlatformModule — the module lifecycle (
onEnable/onDisable) where storage is wired.