{"id":"itzbbbbas/datastack","name":"datastack","scope":"itzbbbbas","platform":"roblox","description":"Roblox DataStore library: session-locked, lockless and peek profiles, cross-key transactions, named migrations, immutable data, headless tests. Forked from pepeeltoro41/dataforge.","version":"0.3.0","latest":"0.3.0","versions":["0.1.0","0.2.0","0.3.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"42d883c725bb222f8783ac272dc0e92e744367566e69b6e5ce9ae1d4ca771f30","likes":0,"downloads":0,"install":"forest install itzbbbbas/datastack","url":"https://forest.dev/p/roblox/itzbbbbas/datastack","files":"https://api.forest.dev/ai/package/roblox/itzbbbbas/datastack/files","readme":"# DataStack\n\nRoblox 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](https://github.com/PepeElToro41/dataforge) by PepeElToro41. Schema, path and replication ideas come from [Scribe](https://scribe.ericplane.dev/) by ericplane.\n\n## Install\n\n```toml\n# wally.toml\n[dependencies]\nDataStack = \"itzbbbbas/datastack@0.3.0\"\n```\n\n## Use\n\n```lua\nlocal DataStack = require(ReplicatedStorage.Packages.DataStack)\n\nlocal store = DataStack.CreateStore({\n\tname = \"PlayerData\",\n\ttemplate = { coins = 0, inventory = {} },\n\tmigrations = {\n\t\t{ name = \"v2 add inventory\", apply = function(data) data.inventory = {}; return data end },\n\t},\n})\n\nPlayers.PlayerAdded:Connect(function(player)\n\tlocal profile = store:Load(tostring(player.UserId), { player.UserId })\n\tprofile:Update(function(data)\n\t\treturn { coins = data.coins + 1, inventory = data.inventory }\n\tend)\n\tprofile:Mutate(function(data)\n\t\tdata.coins += 1\n\tend)\nend)\n\nPlayers.PlayerRemoving:Connect(function(player)\n\tlocal profile = store:GetLoaded(tostring(player.UserId))\n\tif profile then\n\t\tprofile:Unload()\n\tend\nend)\n\ngame:BindToClose(function()\n\tstore:Close()\nend)\n```\n\nData 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.\n\n## API\n\nMethods are PascalCase. Config keys, template fields and the stored record are snake_case.\n\n### DataStack\n\n| Function | Does |\n| --- | --- |\n| `CreateStore(config)` | Builds a store and registers it. |\n| `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. |\n| `Stores()` | Every open store. |\n| `Erase(key)` | Removes `key` from every open store. Unloads local profiles, waits for a foreign lock to expire, then removes the record. |\n| `Hooks.Memory`, `Hooks.Roblox` | Storage backends. |\n| `Schedulers.Virtual`, `Schedulers.Roblox` | Clocks. |\n| `Util` | `DeepCopy`, `DeepFreeze`, `DeepEqual`, `DirtyTop`. |\n\n### Config\n\n| Key | Default | Does |\n| --- | --- | --- |\n| `name` | required | DataStore name. |\n| `template` | required | Starting data for a new key. |\n| `migrations` | `{}` | Ordered `{ name, apply }` list. Applied names are stored on the record, so a rename reruns the step. |\n| `autosave_interval` | 30 | Seconds between autosaves of a locked profile. |\n| `flush_interval` | `autosave_interval` | Seconds between flushes of a lockless profile. |\n| `lock_ttl` | 60 | Seconds a session lock lasts without a refresh. |\n| `load_timeout` | `lock_ttl + 10` | Seconds `Load` waits on a foreign lock. |\n| `load_poll` | 1 | Seconds between lock polls. |\n| `retry_attempts`, `retry_base` | 5, 1 | Exponential backoff on storage calls. |\n| `pre_save(data)` | identity | Runs on the frozen data before every write and returns what is stored. |\n| `resolve_key(key)` | identity | Returns the key to lock and read, and optionally a different key every write lands on. |\n| `read_only(key)` | false | A profile that resolves true refreshes its lock but never writes data. |\n\n### Store\n\n`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.\n\n### Profile\n\n`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`.\n\nA lockless profile adds `Fetch()`. A peek profile has `Get()`, `Refresh()`, `lock`, `pending`, `migrations`.\n\n## Schema\n\nA template can mix plain values with declarators. `Compile` turns it into defaults, a validator and the set of server-only paths.\n\n```lua\nlocal D = DataStack.Declare\nlocal template = {\n\tcoins = D.Int(0, { min = 0 }),\n\tnickname = D.String(\"\", { max_length = 20 }),\n\tmode = D.Enum(\"easy\", { \"easy\", \"hard\" }),\n\tspawn = D.Optional { x = 0, y = 0 },\n\twealth = D.Big(0),\n\titems = D.ArrayOf(D.String \"\"),\n\tslots = D.DictOf { id = D.String \"\" },\n\tseen = D.MapOf(\"number\", D.Bool(false)),\n\tadmin_note = D.ServerOnly(D.String \"\"),\n\tsettings = { volume = 1 },\n}\nlocal schema = DataStack.Compile(template)\nlocal store = DataStack.CreateStore { name = \"PlayerData\", template = schema.defaults }\nlocal ok, err = schema.validate(profile:Get())\n```\n\n`Big` values are plain `{ m, e }` tables. Use `DataStack.Big.Add`, `Sub`, `Mul`, `Div`, `Compare`, `Short`. No metatable, so they survive JSON and copies.\n\n### Paths and values\n\n`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.\n\n| Method | Does |\n| --- | --- |\n| `Get()` | Reads the field. |\n| `Set(v)`, `Update(fn)`, `Increment(n)` | Writes through `profile:Update`, cloning only the tables along the path. `Increment` handles `Big`. |\n| `Insert(v, index?)`, `Remove(key)`, `Clear()`, `Count()`, `Child(key)` | Container helpers. |\n| `Changed(cb)`, `Observe(cb)`, `OnChildChanged(cb)` | Return an unsubscribe function. Fire only when the addressed value differs. |\n\n### Generated types\n\n```sh\nlune run scripts/gen-types path/to/Template.luau path/to/Types.luau PlayerData\n```\n\nEmits `PlayerData` and `PlayerDataPaths` types from the template. `DataStack.GenTypes.Emit(template, name)` is the same function for use inside a game's own script.\n\n## Replication\n\nServer side, attach a store to a transport. Client side, mirror a key.\n\n```lua\n-- server\nlocal remote = Instance.new \"RemoteEvent\"\nDataStack.Replication.Server.Attach(store, DataStack.Replication.Remote.Server(remote), {\n\ttarget_of = DataStack.Replication.Remote.PlayerOfKey,\n\tserver_only = schema.server_only,\n})\n\n-- client\nlocal client = DataStack.Replication.Client.Attach(DataStack.Replication.Remote.Client(remote), { charm = Charm })\nlocal mirror = client:Mirror(\"PlayerData\", tostring(Players.LocalPlayer.UserId))\nmirror:OnReady(function(data) end)\nmirror:At(\"currencies.almond_coins\"):Observe(function(coins) end)\nlocal wallet = mirror:Atom \"currencies\"\n```\n\nA 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.\n\n## Diagnostics\n\n| Tool | Does |\n| --- | --- |\n| `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. |\n| `Diag.Snapshot.Attach(profile, n)` | Ring of the last `n` committed records by reference. `List()`, `Diff(from, to)`, `Rollback(index)`. |\n| `Diag.Schema.DriftReport(template, data)` | Every missing, extra or mistyped path, sorted. `validate` stops at the first. |\n| `Diag.Schema.SizeReport(data)` | Approximate JSON bytes per top-level key against the 4 MB limit. |\n| `store:Peek(key)` | A migration dry run: the migrated data and applied names in memory, nothing written. |\n| `store:GetLockless(key, ids)` | Offline edit of a player who is not on this server. `Fetch`, `Update`, `Save`. |\n| `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`. |\n\nThree 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.\n\n## Stored record\n\nSee [docs/envelope.md](docs/envelope.md).\n\n## Tests\n\n```sh\nrokit install\nzune run tests/lib.spec.luau\n```\n\nThe 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.\n\n## License\n\nMIT. See [LICENSE](LICENSE).\n","readmeTruncated":false}