{"id":"mystifine/soulstore","name":"soulstore","scope":"mystifine","platform":"roblox","description":"Mirrored from the Wally registry.","version":"1.0.3","latest":"1.0.3","versions":["1.0.2","1.0.3"],"license":"MIT","licenseRating":"safe","licenseCaveats":["License identified from the packaged LICENSE file; the manifest declared none."],"licenseVerified":true,"dependencies":{},"integrity":"694bde3b38cd16676744f6038e231820c4065332dd49d0e2f107b8b993484794","likes":0,"downloads":0,"install":"forest install mystifine/soulstore","url":"https://forest.dev/p/roblox/mystifine/soulstore","files":"https://api.forest.dev/ai/package/roblox/mystifine/soulstore/files","readme":"# SoulStore\r\n\r\nA lightweight, production-ready Roblox player data module with session locking, automatic saving, and change listeners.\r\n\r\n---\r\n\r\n## Features\r\n\r\n- **Session locking** — prevents cross-server data collisions with time-based auto-release\r\n- **Automatic saving** — configurable interval-based auto-save with state-aware scheduling\r\n- **Change listeners** — subscribe to nested data changes via path-based callbacks\r\n- **Load/save hooks** — attach `OnLoad` and `OnSave` callbacks for data transformations\r\n- **Reconciliation** — safely merge default data into existing player data without overwriting\r\n- **Safe shutdown** — `BindToClose` handler ensures all souls are saved before the server closes\r\n\r\n---\r\n\r\n## Installation\r\n\r\nDrop `SoulStore.lua` into `ServerScriptService` or any server-accessible `ModuleScript` location, then require it from your server scripts.\r\n\r\n```lua\r\nlocal SoulStore = require(game.ServerScriptService.SoulStore)\r\n```\r\n\r\n---\r\n\r\n## Quick Start\r\n\r\n```lua\r\nlocal SoulStore = require(game.ServerScriptService.SoulStore)\r\n\r\nlocal DEFAULT_DATA = {\r\n    Coins = 0,\r\n    Level = 1,\r\n    Inventory = {},\r\n}\r\n\r\ngame.Players.PlayerAdded:Connect(function(player)\r\n    local soul = SoulStore.new(\"PlayerData\", player, DEFAULT_DATA)\r\n\r\n    soul:SetOnLoad(function(data)\r\n        -- Runs after data is loaded, before the soul is marked ready.\r\n        -- Use this to migrate or transform data on load.\r\n        if not data.Settings then\r\n            data.Settings = { MusicEnabled = true }\r\n        end\r\n    end)\r\n\r\n    soul:LoadData()\r\n\r\n    -- Wait for load before doing anything with the data\r\n    -- (LoadData is synchronous — it blocks until loaded or the player leaves)\r\n\r\n    soul:SetData({\"Coins\"}, 100)\r\n    print(soul:GetData({\"Coins\"})) -- 100\r\nend)\r\n```\r\n\r\n---\r\n\r\n## API\r\n\r\n### `SoulStore.new(datastoreId, player, defaultData) → Soul`\r\n\r\nCreates a new Soul object. If a soul already exists in cache for this player and datastore, the cached instance is returned instead.\r\n\r\n| Parameter | Type | Description |\r\n|---|---|---|\r\n| `datastoreId` | `string` | The DataStore name to use |\r\n| `player` | `Player` | The player this soul belongs to |\r\n| `defaultData` | `{}` | Default data table (must be a dictionary) |\r\n\r\n---\r\n\r\n### `soul:LoadData()`\r\n\r\nLoads the player's data from the DataStore. Handles session lock detection, retrying until the lock expires or the player leaves. Blocks the calling thread until resolved.\r\n\r\n- If the player leaves mid-load, the soul is cleaned up automatically.\r\n- The `OnLoad` callback fires after data is assigned but before `LoadState` becomes `Loaded`.\r\n\r\n---\r\n\r\n### `soul:SaveData(sessionEnding: boolean?)`\r\n\r\nSaves the player's data to the DataStore.\r\n\r\n- Pass `true` for `sessionEnding` when the player is leaving — this unlocks the session and clears the soul from cache on success.\r\n- Auto-saves and manual saves pass `false` (or omit the argument).\r\n- The `OnSave` callback fires on each attempt, receiving a snapshot of the data.\r\n\r\n---\r\n\r\n### `soul:GetData(path: {any}?) → any?`\r\n\r\nRetrieves a value from the soul's data by path.\r\n\r\n```lua\r\n-- Get the entire data table\r\nlocal data = soul:GetData()\r\n\r\n-- Get a nested value\r\nlocal coins = soul:GetData({\"Coins\"})\r\nlocal musicSetting = soul:GetData({\"Settings\", \"MusicEnabled\"})\r\n```\r\n\r\nReturns `nil` and warns if a key in the path doesn't exist.\r\n\r\n---\r\n\r\n### `soul:SetData(path: {any}, value: any) → any?`\r\n\r\nSets a value in the soul's data by path. Fires any registered `OnDataChanged` listeners for the affected path and its ancestors.\r\n\r\n```lua\r\nsoul:SetData({\"Coins\"}, 500)\r\nsoul:SetData({\"Settings\", \"MusicEnabled\"}, false)\r\n```\r\n\r\n---\r\n\r\n### `soul:OnDataChanged(path, callback) → { Disconnect: () -> nil }`\r\n\r\nListens for changes at the given path. The callback receives `(oldValue, newValue)`.\r\n\r\n```lua\r\nlocal connection = soul:OnDataChanged({\"Coins\"}, function(old, new)\r\n    print(string.format(\"Coins changed: %d -> %d\", old, new))\r\nend)\r\n\r\n-- Later, when you no longer need it:\r\nconnection:Disconnect()\r\n```\r\n\r\nListeners fire for changes at the exact path and any descendant path. For example, a listener on `{\"Settings\"}` fires when `{\"Settings\", \"MusicEnabled\"}` changes.\r\n\r\n---\r\n\r\n### `soul:Reconcile(data: {})`\r\n\r\nMerges `data` into the soul's existing data. Only fills in keys that are `nil` — existing values are never overwritten. Useful for adding new fields to returning players.\r\n\r\n```lua\r\nsoul:Reconcile({\r\n    NewFeatureFlag = false,  -- only added if not already present\r\n    Coins = 999,             -- ignored, Coins already exists\r\n})\r\n```\r\n\r\n---\r\n\r\n### `soul:SetOnLoad(callback: (data: {}) -> nil)`\r\n\r\nAttaches a callback that fires once after data is loaded from the DataStore. Receives the raw loaded data table directly — use this for migrations or one-time transforms.\r\n\r\n```lua\r\nsoul:SetOnLoad(function(data)\r\n    -- Rename an old key\r\n    if data.Gold then\r\n        data.Coins = data.Gold\r\n        data.Gold = nil\r\n    end\r\nend)\r\n```\r\n\r\nThe callback is wrapped in a `pcall` — errors are logged but do not interrupt loading.\r\n\r\n---\r\n\r\n### `soul:SetOnSave(callback: (data: {}) -> nil)`\r\n\r\nAttaches a callback that fires before each save attempt. Receives a **snapshot** of the data (not the live table) — mutations here affect what gets saved, not `soul.Data` itself.\r\n\r\n```lua\r\nsoul:SetOnSave(function(data)\r\n    -- Strip a temporary runtime field before saving\r\n    data.SessionStartTime = nil\r\nend)\r\n```\r\n\r\nThe callback is wrapped in a `pcall`. Because it runs inside the retry loop, it fires on every save attempt including retries.\r\n\r\n---\r\n\r\n### `SoulStore.getSoul(datastoreId, player) → Soul?`\r\n\r\nReturns the cached soul for a player if one exists. Returns `nil` otherwise.\r\n\r\n---\r\n\r\n### `SoulStore.resetData(datastoreId, datastoreKey)`\r\n\r\nRemoves a single key from a DataStore. Intended for development and admin tooling only.\r\n\r\n---\r\n\r\n### `SoulStore.resetAllData(datastoreId)`\r\n\r\nRemoves **all keys** from a DataStore. Includes a 10-second warning delay. Use with extreme caution — this is irreversible.\r\n\r\n---\r\n\r\n## Configuration\r\n\r\nSettings are defined at the top of the module in `SOUL_STORE_SETTINGS`:\r\n\r\n| Setting | Default | Description |\r\n|---|---|---|\r\n| `DEBUG_MODE` | `true` | Enables console output for load/save events and errors |\r\n| `TRACE_BACK_MESSAGE` | `false` | Appends a stack trace to all debug output |\r\n| `AUTO_SAVE_INTERVAL` | `30` | Seconds between automatic saves (minimum 6, recommended 30+) |\r\n| `MINIMUM_SAVE_INTERVAL` | `6` | Minimum seconds between save retry attempts |\r\n| `MINIMUM_LOAD_INTERVAL` | `6` | Minimum seconds between load retry attempts |\r\n| `SESSION_LOCK_AUTO_RELEASE` | `300` | Seconds before a session lock is considered stale and released |\r\n\r\n---\r\n\r\n## Session Locking\r\n\r\nSoulStore attaches metadata to each player's DataStore entry to prevent two servers from writing the same player's data simultaneously.\r\n\r\nWhen a player joins:\r\n1. `LoadData` reads the DataStore and checks for a lock.\r\n2. If unlocked (or the lock has expired), it claims ownership by writing `Locked = true` and the current `JobId`.\r\n3. If locked by another server, it waits `MINIMUM_LOAD_INTERVAL` seconds and retries.\r\n\r\nWhen a player leaves:\r\n1. `SaveData(true)` writes the final data with `Locked = false`, releasing the lock.\r\n2. Any other server can now load this player's data cleanly.\r\n\r\n`SESSION_LOCK_AUTO_RELEASE` is the safety net for cases where a server crashes before releasing its lock. Set it higher than `AUTO_SAVE_INTERVAL` to ensure saves always happen within the lock window.\r\n\r\n---\r\n\r\n## Types\r\n\r\n```lua\r\nexport type SoulMetaData = {\r\n    Locked: boolean,\r\n    SaveId: number,\r\n    LastUpdate: number,\r\n    SessionId: string,\r\n}\r\n\r\nexport type Soul = {\r\n    DatastoreId: string,\r\n    Player: Player,\r\n    Data: { MetaData: SoulMetaData },\r\n    LoadState: string,\r\n    SaveState: string,\r\n    -- methods...\r\n}\r\n```\r\n\r\n---\r\n\r\n## Notes\r\n\r\n- `LoadData` is **synchronous** — it yields the calling thread until data is loaded or the player leaves. Call it inside a `task.spawn` or a `PlayerAdded` connection to avoid blocking other code.\r\n- `soul.Data` should not be mutated directly for tracked fields. Use `SetData` to ensure change listeners fire correctly.\r\n- `MetaData` is a reserved key inside the data table. Do not use it in your `defaultData`.\r\n- `resetData` and `resetAllData` are available on the live module. Consider guarding them with `RunService:IsStudio()` in your own code if you expose admin tooling.\r\n\r\n---\r\n\r\n*Created by Mystifine*","readmeTruncated":false}