Skip to content

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".

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.

The production default. Deliberately thin — about forty lines — because Roblox already provides the two properties that matter:

  • Writes go through UpdateAsync only. UpdateAsync is 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.

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.

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 new
PUT {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 misconfigured BaseUrl once made every save report OK while writing nothing. A silent success on a broken route is a data-loss machine.
  • update is 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 ETag header, the PUT sends it back as If-Match — a compare-and-swap. A concurrent writer bumps the revision, the stale PUT fails with 412 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 as UpdateAsync.
  • 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.

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 error
end
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, nil
end
return MyBackend

Rules that keep the driver’s guarantees intact:

  1. Never write on abort. transform returning nil must leave the store byte-identical.
  2. Never report a failed write as success. The driver acknowledges purchases and releases locks based on your ok.
  3. Don’t retry internally — return the error and let the driver’s ladder pace retries.
  4. Make update atomic 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.
  5. Only set SupportsBuffers = true if the store genuinely round-trips buffer values; the driver feature-selects the codec wire format (Buf vs B64) from this flag per backend, including re-encoding backup payloads when the primary and backup backends disagree.