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, 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)
  • XAsync throws the raw message string. X rejects with a Promise.Error of kind ExecutionError. Its .error field holds that string, and tostring adds a trace. Code that matches on error text reads .error.
  • X() runs XAsync at 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

FunctionDoes
create_store(config)Builds a store and registers it.
transaction(profiles, fn, config?), transaction_asyncAtomic 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_asyncRemoves 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.
Utildeep_copy, deep_freeze, deep_equal, dirty_top.

Config

KeyDefaultDoes
namerequiredDataStore name.
templaterequiredStarting 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_interval30Seconds between autosaves of a locked profile.
flush_intervalautosave_intervalSeconds between flushes of a lockless profile.
refresh_interval0 (off)Seconds between re-fetches of a lockless profile that has fetched once. The fallback when a ping is lost.
notifyfalseA 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_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), 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.

MethodDoes
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

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), 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 calls Fetch. The writer skips its own ping, and a transaction pings only on its final write.
  • Refresh. Each fetched lockless profile re-fetches every refresh_interval seconds, so a lost ping costs one interval at most.
  • Change. OnChange fires 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

check_circle

Safe for commercial use

Automated license review — not legal advice.