Data Backends
DataDriver never talks to storage directly. Everything — loads, saves, locks, backups — goes through a backend: a small adapter implementing one two-method contract. All the hard guarantees (locks, retries, validation, migrations) live in the driver; a backend’s only job is to move an envelope in and out of a store honestly. Three ship with the kernel, selected by name in Config.Backend: "DataStore" (the default), "Memory", and "Http".
The backend contract
Section titled “The backend contract”A backend is any table with these two methods:
| Member | Description |
|---|---|
backend:read(key) → (ok, value, err?) |
Fetch the stored value. A missing key is true, nil — absence is not an error. false means the store itself failed. |
backend:update(key, transform) → (ok, value, err?) |
Read-modify-write. transform(current) returns the new value to store, or nil to abort — an abort is true, nil with nothing written. |
backend.SupportsBuffers |
Optional flag. true means the store accepts buffer values natively; the driver then stores codec output as raw buffers (F = "Buf") instead of base64 strings (F = "B64"). |
The abort path is load-bearing: it is how the driver refuses to write over a live session lock, a malformed record, or an unmigratable schema. A backend that writes anything when the transform returns nil breaks every fail-closed guarantee upstream.
The driver supplies retries (BackoffSeconds ladder), so backends do not retry internally — they report failure as false, nil, err with a useful string and let the caller decide.
DataStoreBackend
Section titled “DataStoreBackend”The production default. Deliberately thin — about forty lines — because Roblox already provides the two properties that matter:
- Writes go through
UpdateAsynconly.UpdateAsyncis an atomic read-modify-write: the transform sees the current stored value and its result replaces it with no window for another server’s write to interleave. This is what makes the driver’s in-transform lock check a hard guarantee on DataStores. - Native versioning underneath. Because every write is an
UpdateAsync, Roblox’s point-in-time versioning (ListVersionsAsync) keeps working as a recovery layer below the driver’s own backup subsystem.
Details:
| Behavior | Description |
|---|---|
| Construction | DataStoreBackend.new(name, scope?) — Config.Name is the store name; BackendConfig.Scope passes through. |
| Reads | GetAsync, pcall-wrapped; errors surface as strings. |
| GDPR | If the new envelope carries Meta.UserIds, it is forwarded as UpdateAsync’s second return — the metadata Roblox uses for right-to-erasure tracking. attach() sets this automatically. |
SupportsBuffers |
true — DataStores accept buffer values natively, so codec-packed profiles store as raw buffers with no base64 overhead. |
MemoryBackend
Section titled “MemoryBackend”An in-process table for tests and Studio development — MemoryBackend.new() takes nothing and shares nothing across servers.
Its one design decision: records round-trip through JSONEncode/JSONDecode on purpose. A plain Lua table would happily hold functions, Instances, mixed-key tables, and cycles — and your tests would pass right up until a real DataStore rejected them. The JSON round-trip makes anything that wouldn’t survive production fail in the spec suite first. An unencodable value fails the write (value is not serializable); an undecodable stored string fails the read (corrupt record) — which is exactly how the DataDriver backup-restore specs corrupt records deliberately.
update is trivially atomic (single process, no yields between read and write), so it faithfully models UpdateAsync semantics including the abort path.
HttpBackend
Section titled “HttpBackend”An adapter for keeping authoritative data in your own database behind a REST API. Server-only — auth headers never replicate to clients.
The expected API surface, from the module header:
GET {BaseUrl}/{key} -> 200 + JSON body, or 404 when the key is newPUT {BaseUrl}/{key} with JSON body -> 2xx. A 404 on PUT is an error.A missing route must never report a save as successful.| Config field | Default | Description |
|---|---|---|
BaseUrl |
— (required) | Trailing slashes are trimmed; keys are URL-encoded onto the path. |
Headers |
{} |
Sent on every request — put your API key here. |
Request |
HttpService.RequestAsync |
Injectable transport, used by the spec suite to fake the API. |
Semantics worth knowing:
- 404 is asymmetric. On GET it means “no key yet” and reads as
true, nil— a brand-new player. On PUT it is an error: the spec suite pins this because a misconfiguredBaseUrlonce made every save report OK while writing nothing. A silent success on a broken route is a data-loss machine. updateis read-modify-write over two HTTP calls — not atomic by itself. GET, run the transform, PUT. Between those calls another server could write.- ETags make it atomic. If the GET response carries an
ETagheader, the PUT sends it back asIf-Match— a compare-and-swap. A concurrent writer bumps the revision, the stale PUT fails with412 Precondition Failed, and the driver’s retry ladder re-runs the whole read-transform-write against fresh data. With ETags, the session lock is a hard guarantee, same asUpdateAsync. - Without ETags, the backend warns once and writes are last-write-wins. The driver’s session lock still makes collisions rare — only one server holds a profile at a time, and the lock check runs inside every transform — but rare is not never: the read-to-write window exists. Do not put authoritative player data behind an ETag-less API.
Writing your own backend
Section titled “Writing your own backend”Implement the contract and pass the instance as Config.Backend (or Config.Backup.Backend):
local MyBackend = {}MyBackend.__index = MyBackend
function MyBackend.new(client) return setmetatable({ Client = client, SupportsBuffers = false, -- only claim true if the store accepts buffer values }, MyBackend)end
function MyBackend.read(self, key: string): (boolean, any, string?) local Ok, ValueOrErr = pcall(self.Client.get, self.Client, key) if not Ok then return false, nil, tostring(ValueOrErr) end return true, ValueOrErr, nil -- a missing key is (true, nil): absence is not an errorend
function MyBackend.update(self, key: string, transform: (any) -> any?): (boolean, any, string?) local Ok, Current, Err = self:read(key) if not Ok then return false, nil, Err end local New = transform(Current) if New == nil then return true, nil, nil -- abort: MUST write nothing end local WriteOk, WriteErr = pcall(self.Client.put, self.Client, key, New) if not WriteOk then return false, nil, tostring(WriteErr) end return true, New, nilend
return MyBackendRules that keep the driver’s guarantees intact:
- Never write on abort.
transformreturningnilmust leave the store byte-identical. - Never report a failed write as success. The driver acknowledges purchases and releases locks based on your
ok. - Don’t retry internally — return the error and let the driver’s ladder pace retries.
- Make
updateatomic if the store allows it (a transaction, a CAS token, a conditional write). If it can’t be, document the window; the session lock reduces but does not eliminate it. - Only set
SupportsBuffers = trueif the store genuinely round-tripsbuffervalues; the driver feature-selects the codec wire format (BufvsB64) from this flag per backend, including re-encoding backup payloads when the primary and backup backends disagree.