{"id":"uhowen/profilelockservice","name":"profilelockservice","scope":"uhowen","platform":"roblox","description":"Roblox profile data storage with session locks, autosave, migrations, and queued writes.","version":"0.2.1","latest":"0.2.1","versions":["0.2.0","0.2.1"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"cac28f1d9e86ebc0114afb9e7c13cda22866f03f700136d52e3ae9d5debafa56","likes":0,"downloads":0,"install":"forest install uhowen/profilelockservice","url":"https://forest.dev/p/roblox/uhowen/profilelockservice","files":"https://api.forest.dev/ai/package/roblox/uhowen/profilelockservice/files","readme":"# ProfileLockService\n\n`ProfileLockService` is a Roblox persistence library for games that want one live owner per profile, queued writes, autosave, and migrations without building everything around raw `DataStoreService`.\n\nIt is key-based.\n\nYou do not have to use coins, cash, or any specific schema.\n\nYou choose:\n\n- what your profile keys are\n- what your data looks like\n- when your game changes it\n\n## What it does\n\n- one live session owns a profile at a time\n- dirty profiles autosave on an interval\n- profiles that have not changed still refresh their session lock through a heartbeat\n- writes are queued per profile key\n- stale locks can be recovered with load policies\n- schema defaults and migrations are supported\n- raw datastore budget checks are supported\n- profiles can be released on player leave and server shutdown\n\n## Layout\n\n```text\nProfileLockService/\n  default.project.json\n  init.luau\n  LICENSE\n  README.md\n  examples/\n    Basic.server.luau\n  src/\n    adapters/\n      DataStoreAdapter.luau\n      MemoryAdapter.luau\n    Backoff.luau\n    Constants.luau\n    DataService.luau\n    DeepCopy.luau\n    DeepMerge.luau\n    Errors.luau\n    Path.luau\n    Profile.luau\n    ProfileStore.luau\n    Queue.luau\n    Record.luau\n    Session.luau\n    Signal.luau\n    StoreOptions.luau\n```\n\n## Install\n\n### Wally\n\n```toml\n[dependencies]\nProfileLockService = \"uhowen/profilelockservice@0.2.1\"\n```\n\nThen require it from your Wally packages folder.\n\n```luau\nlocal ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\nlocal ProfileLockService = require(ReplicatedStorage.Packages.ProfileLockService)\n```\n\n### Manual\n\nYou can also require the module from a server-accessible location.\n\n```luau\nlocal ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\nlocal ProfileLockService = require(ReplicatedStorage.ProfileLockService)\n```\n\n## Quick start\n\n```luau\nlocal Players = game:GetService(\"Players\")\nlocal ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\n\nlocal ProfileLockService = require(ReplicatedStorage.ProfileLockService)\n\nlocal service = ProfileLockService.new()\n\nlocal store = service:CreateStore({\n\tname = \"PlayerProfiles\",\n\tscope = \"live\",\n\tkeyPrefix = \"profile\",\n\tautoSaveInterval = 60,\n\tsessionHeartbeatInterval = 30,\n\tlockTimeout = 180,\n\tdefaults = {\n\t\tprogress = {\n\t\t\tstage = 1,\n\t\t\tcheckpoints = {},\n\t\t},\n\t\tsettings = {\n\t\t\tmusic = true,\n\t\t\tquality = \"high\",\n\t\t},\n\t\tstats = {\n\t\t\tjoins = 0,\n\t\t},\n\t},\n})\n\nstore:BindToPlayerRemoving(Players)\nstore:BindToClose()\n\nPlayers.PlayerAdded:Connect(function(player)\n\tlocal profile, loadError = store:LoadProfileForPlayerAsync(player)\n\n\tif not profile then\n\t\twarn(loadError)\n\t\tplayer:Kick(\"Could not load your profile.\")\n\t\treturn\n\tend\n\n\tprofile:Increment({\"stats\", \"joins\"}, 1)\n\tprofile:Set({\"settings\", \"lastSeenJobId\"}, game.JobId)\nend)\n```\n\n## Core idea\n\nA `DataService` owns stores.\n\nA `ProfileStore` owns a group of profiles under one datastore.\n\nA `Profile` is one loaded data entry that belongs to one live session.\n\nThe normal flow is:\n\n1. Create a store\n2. Load a profile by profile id or player\n3. Edit the profile data\n4. Let autosave or manual saves write it\n5. Release the profile when the session ends\n\n## Profile keys\n\nThe store is key-based, not cash-system-based.\n\nYou can load profiles with any string or number id:\n\n```luau\nlocal profile = store:LoadProfileAsync(\"guild_4821\")\nlocal settings = store:LoadProfileAsync(\"global_settings\")\nlocal slot = store:LoadProfileAsync(`player_{player.UserId}_slot_2`)\n```\n\nThere is also a player helper for the common case:\n\n```luau\nlocal profile = store:LoadProfileForPlayerAsync(player)\n```\n\n## Editing data\n\n### Set one value\n\n```luau\nprofile:Set({\"settings\", \"music\"}, false)\n```\n\n### Increment a number\n\n```luau\nprofile:Increment({\"stats\", \"joins\"}, 1)\n```\n\n### Read a value\n\n```luau\nlocal stage = profile:Get({\"progress\", \"stage\"}, 1)\n```\n\n### Update several values at once\n\n```luau\nprofile:Update(function(data)\n\tdata.progress.stage += 1\n\tdata.progress.checkpoints = data.progress.checkpoints or {}\n\ttable.insert(data.progress.checkpoints, os.time())\n\treturn data\nend)\n```\n\n### Update one nested value\n\n```luau\nprofile:UpdatePath({\"progress\", \"stage\"}, function(stage)\n\treturn stage + 1\nend, 1)\n```\n\n### Take a safe copy\n\n```luau\nlocal snapshot = profile:GetSnapshot()\n```\n\n### Remove a value\n\n```luau\nprofile:Remove({\"temporaryState\"})\n```\n\n### Reconcile missing defaults\n\n```luau\nprofile:Reconcile()\n```\n\n### Mark manual table edits dirty\n\n```luau\nlocal data = profile:GetData()\ndata.settings.quality = \"low\"\nprofile:Touch()\n```\n\n## Autosave and session locks\n\nTwo background systems keep the store running:\n\n- `autoSaveInterval` saves dirty profiles\n- `sessionHeartbeatInterval` refreshes live locks even when nothing changed\n\nThis matters because a profile should not look stale just because the player was idle.\n\nRecommended rule:\n\n- set `sessionHeartbeatInterval` lower than `lockTimeout`\n\nExample:\n\n```luau\nlocal store = service:CreateStore({\n\tname = \"PlayerProfiles\",\n\tdefaults = {\n\t\tvalue = 0,\n\t},\n\tautoSaveInterval = 60,\n\tsessionHeartbeatInterval = 30,\n\tlockTimeout = 180,\n})\n```\n\n## Load policies\n\n### `default`\n\nFails if another live session owns the profile.\n\n### `steal`\n\nTakes over only if the existing session is stale.\n\n### `force`\n\nAlways takes ownership immediately.\n\nUse it carefully.\n\n## Store options\n\n```luau\nlocal store = service:CreateStore({\n\tname = \"PlayerProfiles\",\n\tscope = \"live\",\n\tkeyPrefix = \"profile\",\n\tautoSaveInterval = 60,\n\tsessionHeartbeatInterval = 30,\n\tmaxRetries = 4,\n\tretryDelay = 0.5,\n\tretryDelayCap = 2,\n\tschemaVersion = 2,\n\tloadPolicy = \"default\",\n\tlockTimeout = 180,\n\tbudgetWaitTimeout = 10,\n\tbudgetPollInterval = 0.25,\n\trespectRequestBudgets = true,\n\tdefaults = {\n\t\tprogress = {\n\t\t\tstage = 1,\n\t\t},\n\t},\n\tmigrations = {\n\t\t[2] = function(data)\n\t\t\tdata.progress = data.progress or {}\n\t\t\tdata.progress.checkpoints = data.progress.checkpoints or {}\n\t\t\treturn data\n\t\tend,\n\t},\n\thooks = {\n\t\tvalidate = function(data, stage)\n\t\t\treturn type(data.progress.stage) == \"number\" and data.progress.stage >= 1\n\t\tend,\n\t},\n})\n```\n\n## Useful methods\n\n### DataService\n\n- `service:CreateStore(options)`\n- `service:GetStore(name)`\n- `service:GetStores()`\n- `service:DestroyStore(name)`\n- `service:CloseAsync()`\n\n### ProfileStore\n\n- `store:BuildProfileKey(profileId)`\n- `store:LoadProfileAsync(profileId, loadOptions)`\n- `store:LoadProfileForPlayerAsync(player, loadOptions)`\n- `store:GetProfile(profileId)`\n- `store:GetProfileForPlayer(player)`\n- `store:HasProfile(profileId)`\n- `store:GetLoadedProfiles()`\n- `store:GetLoadedProfileCount()`\n- `store:GetStats()`\n- `store:SaveProfileAsync(profileId, force)`\n- `store:ReleaseProfileAsync(profileId, force)`\n- `store:ViewRawAsync(profileId)`\n- `store:ViewProfileAsync(profileId)`\n- `store:WipeProfileAsync(profileId)`\n- `store:BindToPlayerRemoving(players)`\n- `store:BindToClose()`\n- `store:CloseAsync()`\n\n### Profile\n\n- `profile:GetProfileId()`\n- `profile:GetUserId()`\n  Returns the player `UserId` only when the profile was loaded through `LoadProfileForPlayerAsync`.\n- `profile:GetKey()`\n- `profile:GetData()`\n- `profile:GetSnapshot()`\n- `profile:Get(path, fallback)`\n- `profile:GetMeta()`\n- `profile:GetState()`\n- `profile:GetLoadCount()`\n- `profile:GetLastLoadAt()`\n- `profile:GetLastSaveAt()`\n- `profile:GetLastHeartbeatAt()`\n- `profile:IsDirty()`\n- `profile:IsReleased()`\n- `profile:MarkDirty()`\n- `profile:Touch()`\n- `profile:Set(path, value)`\n- `profile:UpdatePath(path, updater, fallback)`\n- `profile:Increment(path, amount)`\n- `profile:Remove(path)`\n- `profile:Reconcile()`\n- `profile:Update(mutator)`\n- `profile:SaveAsync(force)`\n- `profile:ListenToRelease(callback)`\n- `profile:ListenToSave(callback)`\n- `profile:ListenToSaveFailed(callback)`\n- `profile:ReleaseAsync(force)`\n\n## Events\n\n- `store.ProfileLoaded`\n- `store.ProfileReleased`\n- `store.ProfileSaved`\n- `store.ProfileSaveFailed`\n- `profile.BeforeSave`\n- `profile.Saved`\n- `profile.SaveFailed`\n- `profile.Released`\n\n## Memory adapter\n\nYou can test store behavior without touching Roblox datastores.\n\n```luau\nlocal MemoryAdapter = require(ReplicatedStorage.ProfileLockService.src.adapters.MemoryAdapter)\n\nlocal store = service:CreateStore({\n\tname = \"LocalTest\",\n\tdefaults = {\n\t\tvalue = 0,\n\t},\n\tadapter = MemoryAdapter.new(),\n})\n\nlocal profile = store:LoadProfileAsync(\"test_key\")\nprofile:Increment({\"value\"}, 10)\nprofile:SaveAsync()\n```\n\n## Example patterns\n\n### Player profile\n\n```luau\nlocal profile = store:LoadProfileForPlayerAsync(player)\n```\n\n### Multiple slots per player\n\n```luau\nlocal profile = store:LoadProfileAsync(`player_{player.UserId}_slot_1`)\n```\n\n### World state\n\n```luau\nlocal profile = store:LoadProfileAsync(\"world_state\")\n```\n\n### Guild or clan data\n\n```luau\nlocal profile = store:LoadProfileAsync(`guild_{guildId}`)\n```\n\n## Notes\n\n- release profiles when the owner is done with them\n- avoid forcing lock ownership unless you really mean it\n- keep `sessionHeartbeatInterval` lower than `lockTimeout`\n- do not mutate nested data manually unless you mark it dirty\n- if you want per-player leaderstats, build that in your game code, not in the persistence library\n\n## Tests\n\nThe repo includes a lightweight memory-adapter self-test under [tests/MemoryAdapter.selftest.luau](C:/Users/woody/Desktop/owens%20new%20portfolioo/profile-lock-service-repo/tests/MemoryAdapter.selftest.luau).\n","readmeTruncated":false}