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, an immutable data model, and a headless test runtime. Forked from dataforge by PepeElToro41. Schema, path and replication ideas come from Scribe by ericplane.
Install
# wally.toml
[dependencies]
DataStack = "itzbbbbas/datastack@0.3.0"
Use
local DataStack = require(ReplicatedStorage.Packages.DataStack)
local store = DataStack.CreateStore({
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:Load(tostring(player.UserId), { player.UserId })
profile:Update(function(data)
return { coins = data.coins + 1, inventory = data.inventory }
end)
profile:Mutate(function(data)
data.coins += 1
end)
end)
Players.PlayerRemoving:Connect(function(player)
local profile = store:GetLoaded(tostring(player.UserId))
if profile then
profile:Unload()
end
end)
game:BindToClose(function()
store:Close()
end)
Data is frozen. Update returns a new table, Mutate edits a deep copy. A change fires OnChange(new, old, dirty_top), where dirty_top is the set of top-level keys whose value changed.
API
Methods are PascalCase. Config keys, template fields and the stored record are snake_case.
DataStack
| Function | Does |
|---|---|
CreateStore(config) | Builds a store and registers it. |
Transaction(profiles, fn, config?) | 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) | 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 | DeepCopy, DeepFreeze, DeepEqual, DirtyTop. |
Config
| Key | Default | Does |
|---|---|---|
name | required | DataStore name. |
template | required | Starting data for a new key. |
migrations | {} | Ordered { name, apply } list. Applied names are stored on the record, so a rename reruns the step. |
autosave_interval | 30 | Seconds between autosaves of a locked profile. |
flush_interval | autosave_interval | Seconds between flushes of a lockless profile. |
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), Close(), and the hooks OnChange, OnSave, OnClosing, OnClosed, OnLockLost, each receiving the profile first.
Profile
Get(), Update(fn), Mutate(fn), Save(), Unload(), Release(), Reacquire(), WaitSettled(), WaitClosed(), the same five hooks, and the fields key, load_key, save_key, read_only, is_locked, open.
A lockless profile adds Fetch(). A peek profile has Get(), Refresh(), lock, pending, migrations.
Schema
A template can mix plain values with declarators. Compile turns it into defaults, a validator and the set of server-only paths.
local D = DataStack.Declare
local template = {
coins = D.Int(0, { min = 0 }),
nickname = D.String("", { max_length = 20 }),
mode = D.Enum("easy", { "easy", "hard" }),
spawn = D.Optional { x = 0, y = 0 },
wealth = D.Big(0),
items = D.ArrayOf(D.String ""),
slots = D.DictOf { id = D.String "" },
seen = D.MapOf("number", D.Bool(false)),
admin_note = D.ServerOnly(D.String ""),
settings = { volume = 1 },
}
local schema = DataStack.Compile(template)
local store = DataStack.CreateStore { 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:Update, cloning only the tables along the path. Increment handles Big. |
Insert(v, index?), Remove(key), Clear(), Count(), Child(key) | Container helpers. |
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. DataStack.GenTypes.Emit(template, name) is the same function for use inside a game's own script.
Replication
Server side, attach a store to a transport. Client side, mirror a key.
-- server
local remote = Instance.new "RemoteEvent"
DataStack.Replication.Server.Attach(store, DataStack.Replication.Remote.Server(remote), {
target_of = DataStack.Replication.Remote.PlayerOfKey,
server_only = schema.server_only,
})
-- client
local client = DataStack.Replication.Client.Attach(DataStack.Replication.Remote.Client(remote), { charm = Charm })
local mirror = client:Mirror("PlayerData", tostring(Players.LocalPlayer.UserId))
mirror:OnReady(function(data) end)
mirror:At("currencies.almond_coins"):Observe(function(coins) end)
local wallet = mirror:Atom "currencies"
A load sends one full snapshot. Each commit sends a patch keyed by path, with removals marked. Paths under server_only never leave the server. Atom(top_key) returns a Charm atom that updates only when that top-level key changes; pass your Charm module in Client.Attach, it is not a dependency.
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). |
Diag.Schema.DriftReport(template, data) | Every missing, extra or mistyped path, sorted. validate stops at the first. |
Diag.Schema.SizeReport(data) | Approximate JSON bytes per top-level key against the 4 MB limit. |
store:Peek(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, Update, Save. |
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 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 migration mismatch report through on_invariant(message). Both default to Warn on the hook.
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.3.0
License
MIT
Safe for commercial use
Automated license review — not legal advice.
