Data backends
The storage contract under DataDriver and the three backends that implement it, DataStore, Memory and Http, with their atomicity, version history and failure rules.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Data .Backends .DataStoreBackend - Since
- v0.1.0
- Source
- Backends/DataStoreBackend.luauBackends/MemoryBackend.luauBackends/HttpBackend.luau
DataDriver never talks to storage directly. Every load, save, lock, backup copy and trade journal goes through a backend: a small adapter with read and update, plus optional members for deletes, version history and request budgets. The locks, retries and checks all live in the driver. A backend’s job is to move a value in and out of a store honestly and atomically, and the one decision that matters is that update is a real compare-and-swap: without it, two servers can both pass the lock check inside the transform. Three backends ship, selected by name in Config.Backend: "DataStore" (the default), "Memory" and "Http".
The backend contract
Section titled “The backend contract”| Member | Description |
|---|---|
backend:read(key) -> (ok, value, err?) |
Required. A missing key is true, nil; absence is not an error. false means the store failed. Backup tooling reads through it. |
backend:update(key, transform) -> (ok, value, err?) |
Required. Atomic read-modify-write. transform(current) returns the value to store, or nil to abort, which returns true, nil with nothing written. |
backend.SupportsBuffers |
Optional. true when the store keeps buffer values; the driver then stores codec output as raw buffers (F = "Buf") instead of base64 (F = "B64"). |
backend:remove(key) -> (ok, err?) |
Optional. Deletes a settled trade journal. A key that is already gone counts as removed. Without it, settled journals stay in storage. |
backend.SupportsVersions |
Optional. true turns on the automatic version fallback. |
backend:versions(key, limit), backend:readVersion(key, version), backend:readVersionAt(key, unixMs) |
Optional. Version history reads. See Version history. |
backend:requestBudget() -> number? |
Optional. Requests left before throttling. Below 1, an ordinary autosave waits for the next pass. |
The abort path carries the fail-closed guarantees. It is how the driver refuses to write over another server’s lock, a malformed record or a newer schema, and transaction recovery also uses update with a transform that returns nil to read a record through the store’s authoritative path. A backend that writes anything when the transform returns nil breaks every one of those rules.
The driver supplies the retries, so a backend never retries internally. It returns false, nil, err with the store’s own error text, because the driver classifies errors by that text: a message containing “throttl” or starting with a code from 301 to 306 is throttling (wider jittered retries), and one containing a code from 101 to 105 is permanent (no retry).
Trade journals share the store with profiles. They are written under CKTxn_<GUID> keys as { Kind = "CKTransaction", Token, Id, State, Entries, Settled?, Meta = { UserIds } }, and hold both participants’ encoded data, so one journal must fit the store’s value limit.
DataStoreBackend
Section titled “DataStoreBackend”The production default. It stays thin because Roblox provides the two properties that matter. Writes go through UpdateAsync only, an atomic read-modify-write, which makes the driver’s in-transform lock check a hard guarantee on DataStores. And native versions come for free: the first write to a key in each UTC hour becomes a version, which the driver reads back for version history.
| Behavior | Description |
|---|---|
| Construction | DataStoreBackend.new(name, scope?, config?). The driver passes Config.Name and BackendConfig.Scope; BackendConfig.Store and BackendConfig.Service inject doubles for specs. |
| Reads | GetAsync, protected; errors come back as strings. |
| UserIds | Meta.UserIds of the new value is returned as UpdateAsync‘s second value, the list Roblox uses for GDPR erasure. When the new value names none, the key’s existing ids are passed back, because returning none clears them. Journal records carry both traders’ ids the same way. |
SupportsBuffers |
true: codec profiles store as raw buffers with no base64 overhead. |
SupportsVersions |
true. versions pages ListVersionsAsync newest first and skips deleted versions; readVersion is GetVersionAsync; readVersionAt is GetVersionAtTimeAsync and also returns the version’s write time. |
remove |
RemoveAsync. |
requestBudget |
The smaller of the StandardRead and StandardWrite budgets from GetRequestBudgetForRequestType, since one UpdateAsync spends one of each. nil when the service reports none. |
MemoryBackend
Section titled “MemoryBackend”An in-process table for specs and Studio. MemoryBackend.new() takes nothing and shares nothing across servers; the scaffold’s profile uses it in Studio so tests never write real records.
Records round-trip through JSONEncode and JSONDecode on purpose. A plain Lua table would hold functions, Instances, mixed tables and cycles, and specs would pass until a real DataStore rejected them. With the round-trip, an unencodable value fails the write (value is not serializable: ...) and an undecodable stored string fails the read (corrupt record), which is how the restore specs corrupt records on purpose.
update is atomic because nothing yields between the read and the write, so it models UpdateAsync faithfully, abort path included. It has remove, keeps no version history (its version reads return unsupported: the Memory backend keeps no version history) and stores codec output as base64. Lock handoff stays off on it.
HttpBackend
Section titled “HttpBackend”An adapter for keeping authoritative data in your own database behind a REST API. It is server-only, so auth headers never replicate to clients. The API it expects:
GET {BaseUrl}/{key} 200 with a JSON body and an ETag, or 404 when the key is newPUT {BaseUrl}/{key} JSON body; If-Match: <etag> for an existing record, If-None-Match: * for a new one; 2xx, or 412 when the precondition failsDELETE {BaseUrl}/{key} 2xx, or 404 when already gone (trade journal cleanup)| Config field | Default | Description |
|---|---|---|
BaseUrl |
required | Trailing slashes are trimmed; keys are URL-encoded onto the path. |
Headers |
{} |
Sent on every request. Put the API key here. |
Request |
HttpService:RequestAsync |
Injectable transport; the specs fake the API with it. |
A 404 means different things per method. On GET it means a new key and reads as true, nil. On DELETE it means already removed. On PUT it is an error: a misconfigured BaseUrl once made every save report success while writing nothing.
A GET must return a record or a 404. A successful GET with an empty or null body is an error (missing keys must return 404), so a broken endpoint can never read as a new player and hand out defaults. A body that is not JSON is an error too.
update is a compare-and-swap over two calls. It GETs, runs the transform, then PUTs with a precondition. An existing record must have come with an ETag, sent back as If-Match; without one the write is refused (existing record has no ETag; refusing a write without compare-and-swap). A new record is created with If-None-Match: *, so two servers racing to create the same key cannot both win. A concurrent writer makes the stale PUT fail with 412, and the driver’s retry ladder re-runs the read, transform and write against fresh data.
Everything travels as JSON. The backend does not claim buffer support, so codec payloads are base64 strings, and it keeps no version history.
Before 0.8.0 an API without ETags was accepted with a warning and last-write-wins semantics. Missing concurrency metadata now fails closed.
Writing your own backend
Section titled “Writing your own backend”Implement the contract and pass the instance as Config.Backend or Config.Backup.Backend:
--!strict-- RevisionBackend: a DataDriver backend over a key-value client with revision checks.
local RevisionBackend = {}RevisionBackend.__index = RevisionBackend
function RevisionBackend.new(client: any) return setmetatable({ Client = client, SupportsBuffers = false, -- claim true only if the store round-trips buffer values }, RevisionBackend)end
function RevisionBackend.read(self: any, key: string): (boolean, any, string?) local Ok, Value = pcall(function() return (self.Client:get(key)) end) if not Ok then return false, nil, tostring(Value) end return true, Value, nil -- a missing key is (true, nil)end
function RevisionBackend.update(self: any, key: string, transform: (any) -> any?): (boolean, any, string?) local ReadOk, Current, Revision = pcall(function() return self.Client:get(key) end) if not ReadOk then return false, nil, tostring(Current) end local New = transform(Current) if New == nil then return true, nil, nil -- abort: write nothing end -- Compare-and-swap against the revision read above local WriteOk, Swapped = pcall(function() return self.Client:put(key, New, Revision) end) if not WriteOk then return false, nil, tostring(Swapped) end if not Swapped then return false, nil, "revision changed" -- the driver's ladder re-runs the read and transform end return true, New, nilend
function RevisionBackend.remove(self: any, key: string): (boolean, string?) local Ok, Err = pcall(function() self.Client:delete(key) end) return Ok, if Ok then nil else tostring(Err)end
return RevisionBackendRules that keep the driver’s guarantees:
- Never write on abort. A transform that returns
nilmust leave the store unchanged. - Never report a failed write as success. The driver acknowledges purchases and releases locks based on your
ok. - Do not retry internally, and keep the store’s error text in
err. - Make
updateatomic: a compare-and-swap token, a conditional write or a transaction. If the store cannot do that, do not put player data behind it; the lock check inside the transform is only as strong as the write that follows it. - Set
SupportsBuffers = trueonly when the store really round-tripsbuffervalues. The driver picks the codec wire format per backend from this flag and re-encodes backup copies when the primary and backup disagree. - Implement
removeif the game uses Transactions, or every settled trade leaves a journal record behind.