Forest Logo
search
package_2

datastack

By @itzbbbbas

Roblox

Mirrored from Wally

DataStack

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

FunctionDoes
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.RobloxStorage backends.
Schedulers.Virtual, Schedulers.RobloxClocks.
UtilDeepCopy, DeepFreeze, DeepEqual, DirtyTop.

Config

KeyDefaultDoes
namerequiredDataStore name.
templaterequiredStarting data for a new key.
migrations{}Ordered { name, apply } list. Applied names are stored on the record, so a rename reruns the step.
autosave_interval30Seconds between autosaves of a locked profile.
flush_intervalautosave_intervalSeconds between flushes of a lockless profile.
lock_ttl60Seconds a session lock lasts without a refresh.
load_timeoutlock_ttl + 10Seconds Load waits on a foreign lock.
load_poll1Seconds between lock polls.
retry_attempts, retry_base5, 1Exponential backoff on storage calls.
pre_save(data)identityRuns on the frozen data before every write and returns what is stored.
resolve_key(key)identityReturns the key to lock and read, and optionally a different key every write lands on.
read_only(key)falseA 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.

MethodDoes
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

ToolDoes
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

check_circle

Safe for commercial use

Automated license review — not legal advice.