Start typing to search packages!
datastack
By @itzbbbbas
Roblox
Mirrored from WallyDataStack
Roblox DataStore library. Session-locked, lockless and peek profiles, cross-key transactions, named migrations, frozen committed snapshots, and a headless test runtime. Forked from dataforge by PepeElToro41. Schema, path and replication ideas come from Scribe by ericplane.
See DataForge comparison for the shared features and the update styles DataStack exposes.
Install
# wally.toml
[server-dependencies]
DataStack = "itzbbbbas/datastack@0.10.0"
Server realm only. Client code never requires it.
Use
local DataStack = require(ServerScriptService.ServerPackages.DataStack)
local store = DataStack.create_store({
name = "PlayerData",
template = { coins = 0, inventory = {} },
migrations = {
{ name = "v2 add inventory", apply = function(data) data.inventory = {}; return data end },
},
})
Players.PlayerAdded:Connect(function(player)
local profile = store:LoadAsync(tostring(player.UserId), { player.UserId })
profile:UpdateImmutableAsync(function(data)
data.coins += 1
return data
end)
profile:UpdateAsync(function(data)
data.coins += 1
end)
end)
Players.PlayerRemoving:Connect(function(player)
local profile = store:GetLoaded(tostring(player.UserId))
if profile then
profile:UnloadAsync()
end
end)
game:BindToClose(function()
store:CloseAsync()
end)
Committed data is frozen. UpdateImmutable gives the callback a writable copy: edit and return that copy, or return a replacement. Update lets the callback edit its copy without returning it. A change fires OnChange(new, old, dirty_top), where dirty_top is the set of top-level keys whose value changed.
The cancel rule differs from Lyra. Lyra's update commits only when the callback returns true. DataStack's Update commits unless the callback returns false.
Promises
Every public method that can yield has two forms, named as in Lyra. XAsync yields and returns the value. X returns an evaera Promise of the same value. UpdateImmutable, Update and the Value writers count as yielding, because they wait on the profile's mutex while a save holds it. Calls that never yield, such as Get, At, GetLoaded, GetLockless and the On* hooks, have one form.
store:Load(tostring(player.UserId), { player.UserId })
:andThen(function(profile)
profile:UpdateAsync(function(data)
data.coins += 1
end)
end)
:catch(function(err)
warn(err.error)
end)
XAsyncthrows the raw message string.Xrejects with aPromise.Errorof kindExecutionError. Its.errorfield holds that string, andtostringadds a trace. Code that matches on error text reads.error.X()runsXAsyncat once, up to its first yield. It does not wait for the next frame.- Cancel stops the Promise, not the work.
:cancel()drops the listeners, and the call still runs to the end on its own thread.
API
Methods called with : are PascalCase. Functions called with . on a module, such as DataStack.create_store, are snake_case. Config keys, template fields and the stored record are snake_case.
DataStack
| Function | Does |
|---|---|
create_store(config) | Builds a store and registers it. |
transaction(profiles, fn, config?), transaction_async | Atomic update across stores and profile kinds. fn(ctx) uses ctx:Get(profile) and ctx:Set(profile, new). Return false to cancel. |
stores() | Every open store. |
erase(key), erase_async | Removes key from every open store. Unloads local profiles, waits for a foreign lock to expire, then removes the record. |
Hooks.Memory, Hooks.Roblox | Storage backends. |
Schedulers.Virtual, Schedulers.Roblox | Clocks. |
Util | deep_copy, deep_freeze, deep_equal, dirty_top. |
Config
| Key | Default | Does |
|---|---|---|
name | required | DataStore name. |
template | required | Starting data for a new key. |
migrations | {} | Ordered { name, apply } list, or DataStack.MigrationStep.add_fields and transform. A step runs once when its name is missing from the record. An unknown stored name is kept and ignored. |
autosave_interval | 30 | Seconds between autosaves of a locked profile. |
flush_interval | autosave_interval | Seconds between flushes of a lockless profile. |
refresh_interval | 0 (off) | Seconds between re-fetches of a lockless profile that has fetched once. The fallback when a ping is lost. |
notify | false | A lockless write pings the store's topic, and every other server holding that key fetches it at once. Needs a hook with Publish and Subscribe. |
lock_ttl | 60 | Seconds a session lock lasts without a refresh. |
load_timeout | lock_ttl + 10 | Seconds Load waits on a foreign lock. |
load_poll | 1 | Seconds between lock polls. |
retry_attempts, retry_base | 5, 1 | Exponential backoff on storage calls. |
pre_save(data) | identity | Runs on the frozen data before every write and returns what is stored. |
resolve_key(key) | identity | Returns the key to lock and read, and optionally a different key every write lands on. |
read_only(key) | false | A profile that resolves true refreshes its lock but never writes data. |
Store
Load(key, user_ids), WaitLoaded(key, user_ids), GetLoaded(key), GetLockless(key, user_ids), Peek(key), Transaction(profiles, transform, config?), Erase(key), Commit(key, transform, user_ids?), Versions(key, limit?), Restore(key, version, user_ids?), Close(), and the hooks OnChange, OnSave, OnClosing, OnClosed, OnLockLost, each receiving the profile first. Every one except GetLoaded, GetLockless and the hooks also has an Async form.
Profile
Get(), UpdateImmutable(fn), Update(fn), Save(), Unload(), Release(), Reacquire(), WaitSettled(), WaitClosed(), the same five hooks, and the fields key, load_key, save_key, read_only, is_locked, open. UpdateImmutable, Update, Save, Unload, Release, Reacquire, WaitSettled and WaitClosed each have an Async form.
A lockless profile adds Fetch() and FetchAsync(), and has no Release, Reacquire or WaitClosed. A peek profile has Get(), Refresh(), RefreshAsync(), lock, pending, migrations.
Schema
Templates use Typeguard builders, so one module types store data, remote payloads and attributes. A template can mix builders with plain values. Compile.compile turns it into defaults, a validator and the set of server-only paths.
local T = require(ReplicatedStorage.Packages.Typeguard)
local template = {
coins = T.Int({ default = 0, min = 0 }),
nickname = T.String({ default = "", max_length = 20 }),
mode = T.Enum({ default = "easy", members = { "easy", "hard" } }),
spawn = T.Optional({ x = 0, y = 0 }),
wealth = T.Big({ default = 0 }),
items = T.Array(T.String()),
slots = T.Dict({ id = T.String({ default = "" }) }),
seen = T.Dict(T.Bool(), { key = "number" }),
admin_note = T.ServerOnly(T.String({ default = "" })),
flags = T.Dict(T.Any()),
tutorial_step = T.OneOf(T.Bool({ default = false }), T.String()),
settings = { volume = 1 },
}
local schema = DataStack.Compile.compile(template)
local store = DataStack.create_store { name = "PlayerData", template = schema.defaults }
local ok, err = schema.validate(profile:Get())
Big values are plain { m, e } tables. Use DataStack.Big.Add, Sub, Mul, Div, Compare, Short. No metatable, so they survive JSON and copies.
Paths and values
profile:At(path) addresses one field. A path is a dotted string, or a DataStack.Path.root() child such as paths.currencies.almond_coins. A field named Get or Count is safe, because a path is a value and not a child of the data.
| Method | Does |
|---|---|
Get() | Reads the field. |
Set(v), Update(fn), Increment(n) | Writes through profile:UpdateImmutableAsync, cloning only the tables along the path. Increment handles Big. Each writer has an Async form; the plain form returns a Promise. |
Insert(v, index?), Remove(key), Clear(), Count(), Child(key) | Container helpers. Insert, Remove and Clear have Async forms. |
Changed(cb), Observe(cb), OnChildChanged(cb) | Return an unsubscribe function. Fire only when the addressed value differs. |
Generated types
lune run scripts/gen-types path/to/Template.luau path/to/Types.luau PlayerData
Emits PlayerData and PlayerDataPaths types from the template. Typeguard.Emit(template, name) is the same function for use inside a game's own script.
Replication
Attach a store to a transport. The client side is your own module: DataStack lives in the server realm and ships no client code.
local remote = Instance.new("RemoteEvent")
DataStack.Replication.Server.attach(store, DataStack.Replication.Remote.server(remote), {
target_of = DataStack.Replication.Remote.player_of_key,
server_only = schema.server_only,
})
A load sends one full snapshot. Each commit sends a patch keyed by path, with removals marked Replication.Diff.REMOVED. An unload sends closed. Paths under server_only never leave the server. The payload shapes and a client recipe are in docs/envelope.md.
Diagnostics
| Tool | Does |
|---|---|
Diag.Metrics.new(opts):Attach(store) | Counts loads, saves, changes, failures and lock losses, times every storage call (p50, p90, p99, max), keeps a ring of committed changes with dirty_top, and forwards events to AddSink(fn). Snapshot() includes the DataStore request budget on Roblox. |
Diag.Snapshot.attach(profile, n) | Ring of the last n committed records by reference. List(), Diff(from, to), Rollback(index), RollbackAsync(index). |
Diag.Schema.drift_report(template, data) | Every missing, extra or mistyped path, sorted. validate stops at the first. |
Diag.Schema.size_report(data) | Approximate JSON bytes per top-level key against the 4 MB limit. |
store:PeekAsync(key) | A migration dry run: the migrated data and applied names in memory, nothing written. |
store:GetLockless(key, ids) | Offline edit of a player who is not on this server. Fetch, UpdateImmutable, Save. |
store:VersionsAsync(key), store:RestoreAsync(key, version) | Roll a key back to a stored DataStore version. Restore waits out a foreign lock, migrates the old data, and refuses a pending transaction. |
Studio.DataDelve.create({ validate }) | A hook for the DataDelve plugin. Edits only data, refuses a live lock or a pending transaction, keeps migrations. |
Inspector.watch(store), Inspector.report(), Inspector.render(Iris) | Text report for a console, or an Iris window with an editable data tree, snapshots with rollback, and metrics. Call Inspector.render inside Iris:Connect. |
Three config hooks feed a game's own guardrails: validate(data) runs on every load and reports through on_drift(message). A lost session lock and a failed migration step report through on_invariant(message). Both default to Warn on the hook.
Realtime lockless
A lockless key several servers share stays fresh two ways.
local store = DataStack.create_store({
name = "global",
template = { event = "" },
notify = true,
refresh_interval = 30,
})
- Ping. After a lockless write lands, the writer publishes
{ key, job }on a topic named after the store. Every other server that holds a fetched lockless profile for that key callsFetch. The writer skips its own ping, and a transaction pings only on its final write. - Refresh. Each fetched lockless profile re-fetches every
refresh_intervalseconds, so a lost ping costs one interval at most. - Change.
OnChangefires only when the value differs, so a refresh that reads the same data stays quiet.
On Roblox the ping goes through MessagingService, which caps publishes per server and does not guarantee delivery, so keep refresh_interval on. Hooks.Memory delivers pings in process, and hooks that share it model two servers in tests.
Stored record
See docs/envelope.md.
Tests
rokit install
zune run tests/lib.spec.luau
The suite runs on the memory hook and the virtual scheduler, so no Roblox is needed. hook:FailNext(store, key, op, "before" | "after") injects a failure, scheduler.Step(dt) advances time.
License
MIT. See LICENSE.
Package Details
Install command (Click to copy)
Version
0.12.0
License
MIT
Safe for commercial use
Automated license review — not legal advice.
