{"id":"coffilhg/profilestorev2","name":"profilestorev2","scope":"coffilhg","platform":"roblox","description":"Periodic DataStore saving solution with session locking + Core Customization  (A Fork of ProfileStore, original ProfileStore was Developed by loleris);  Original ProfileStore Repository link: https://github.com/MadStudioRoblox/ProfileService","version":"2.0.3","latest":"2.0.3","versions":["2.0.0","2.0.1","2.0.2","2.0.3"],"license":"Apache-2.0","licenseRating":"safe","licenseCaveats":["Modified files must carry a notice of changes. If the package ships a NOTICE file, its attributions must be preserved."],"licenseVerified":true,"dependencies":{},"integrity":"1f966e0f8d3436b99ee88cbaa5ee116865e50dd90f309d44c60e6e9e3142f62e","likes":0,"downloads":0,"install":"forest install coffilhg/profilestorev2","url":"https://forest.dev/p/roblox/coffilhg/profilestorev2","files":"https://api.forest.dev/ai/package/roblox/coffilhg/profilestorev2/files","readme":"# ProfileStoreV2\r\n\r\nThis is a fork of **[ProfileStore](<https://github.com/MadStudioRoblox/ProfileStore>)**, it's original README is preserved at the bottom of this README.\r\n\r\nThis fork makes it easy to implement your own modifications to the data flow.\r\n\r\n---\r\n\r\n## Available Here!\r\n- **[This repository](ProfileStore.luau) ~ [ProfileStore.luau](ProfileStore.luau)**\r\n- **[Wally](<https://wally.run/package/coffilhg/profilestorev2>)**\r\n\r\n    ```toml\r\n    ProfileStoreV2 = \"coffilhg/profilestorev2@2.0.3\"\r\n    ```\r\n- **[Rotriever](<https://github.com/Coffilhg/ProfileStoreV2/releases/tag/v2.0.3>)**\r\n\r\n    ```toml\r\n    ProfileStoreV2 = \"github.com/Coffilhg/ProfileStoreV2@2.0.3\"\r\n    ```\r\n\r\n\r\n---\r\n\r\n## Definitions\r\n\r\n**Roblox Datatypes**\r\n> **Those are the Datatypes exclusive to Roblox Studio, e.g. Color3, Vector3, UDim2, CFrame and more**\r\n\r\n**JSON Acceptable**\r\n> **Those are the Datatypes handled by HttpService:JSONEncode!**\r\n> \r\n> Why?\r\n> \r\n> > HttpService:JSONEncode is used automatically before saving to datastore as stated **[here](<https://create.roblox.com/docs/cloud-services/data-stores/error-codes-and-limits#data-limits>):**\r\n> >\r\n> > > “The data (key value) is also stored as a string, regardless of its initial type. You can check the size of the data with the `JSONEncode()` function, which converts Luau data into a serialized JSON table.”\r\n> \r\n> So as a type it would be defined like this: (**[ProfileStore.luau line 997](<ProfileStore.luau/#L997>)**)\r\n> \r\n> ```lua\r\n> type JSONAcceptable = { JSONAcceptable } | { [string]: JSONAcceptable } | number | string | boolean | buffer\r\n> ```\r\n\r\n**State A / Runtime**\r\n> **The type of data/data structure that is used at Runtime, the only limit is your implementation.**\r\n\r\n**State B / Datastore**\r\n> **The type of data/data structure that lives in the Datastore, limited to JSON Acceptable types**\r\n\r\n**State C / Roblox**\r\n> **The type of data/data structure that should be returned by Custom DeepCopy callback, it is highly recommended, that if your State A uses metatables, you limit it to only return clean Roblox tables containing only the actual Data, without metadata or metatables;**\r\n>\r\n> **Otherwise the memory used for storing and Decoding might get messy - up to you.**\r\n>\r\n> **This is a type of data/data structure, that isn't limited to JSON Acceptable types only, but is defined by you.**\r\n>\r\n> **Note: DeepCopy callback might receive both State A and State C as input, but must always output State C**\r\n>\r\n> **Note 2: If your use case is only making sure the data transitions from State B to State A on Load and State A to State B on Upload, you really don't have any differences between State C and State A; Thus meaning DeepCopy, Reconcile and RuntimeWrapper callbacks are useless for you.**\r\n\r\n---\r\n\r\n## Practical Working Example\r\nTo showcase how useful this can get, we got **[CoffeeParser](<https://github.com/Coffilhg/Useful-Modules/tree/CoffeeParser>)** **[(V1.0.2)](<https://github.com/Coffilhg/Useful-Modules/releases/tag/vCoffeeParser/1.0.2>)** and wired it into ProfileStore, using the V2 modifications, this was possible with just less than 90 lines of code - **[ModifyWithCoffeeParser.luau](<server/ModifyWithCoffeeParser.luau>)** then used this in **[ProfileStoreTest (line 183)](<ProfileStoreTest.server.luau/#L183>)**\r\n\r\n<details>\r\n  <summary>All tests passed!</summary>\r\n\r\n  ## [PS_TEST]: Test complete! PASS ✅ = 13; FAIL ❌ = 0\r\n  ## [PS_TEST]: Test Timestamps: \r\n|\tTest Name (✅/❌)                                       \t|\tAbsolute time()\t|\tRelative time()\t|\r\n|------------------------------------------------------------|-----------------|-----------------|\r\n|\tScript Started (✅)                                    \t|\t0.000          \t|\tnone           \t|\r\n|\t[PS_TEST]: Versioning test(✅)                         \t|\t1.904          \t|\t1.904          \t|\r\n|\t[PS_TEST]: Payload test(✅)                            \t|\t13.854         \t|\t11.950         \t|\r\n|\t[PS_TEST]: DataStore KeyInfo (Roblox Metadata) test(✅)\t|\t14.888         \t|\t1.033          \t|\r\n|\t[PS_TEST]: Message test(✅)                            \t|\t20.538         \t|\t5.650          \t|\r\n|\t[PS_TEST]: LastSavedData test(✅)                      \t|\t32.321         \t|\t11.783         \t|\r\n|\t[PS_TEST]: .OnOverwrite test(✅)                       \t|\t33.767         \t|\t1.446          \t|\r\n|\t[PS_TEST]: Test #1(✅)                                 \t|\t35.721         \t|\t1.954          \t|\r\n|\t[PS_TEST]: Test #2(✅)                                 \t|\t38.283         \t|\t2.563          \t|\r\n|\t[PS_TEST]: Test #3(✅)                                 \t|\t39.388         \t|\t1.104          \t|\r\n|\t[PS_TEST]: Test #4(✅)                                 \t|\t50.933         \t|\t11.546         \t|\r\n|\t[PS_TEST]: Test #5(✅)                                 \t|\t62.583         \t|\t11.650         \t|\r\n|\t[PS_TEST]: Test #6(✅)                                 \t|\t63.700         \t|\t1.117          \t|\r\n|\t[PS_TEST]: Cache test(✅)                              \t|\t66.733         \t|\t3.033          \t|\r\n  ## [PS_TEST]: Test PASSED ✅✅✅!\r\n\r\n</details>\r\n\r\nWith this setup, it is possible to store and manipulate Roblox Datatypes at Runtime, whilst CoffeeParser and ProfileStoreV2 make sure they'll be saved in JSON Acceptable way (every Datatype handled by the HTTPService:JSONEncode)\r\n\r\n\r\n\r\n# Changes made to ProfileStore\r\n\r\n### Every ProfileStore Object now has Custom Callbacks for easy Data flow customization\r\n- By default there are no modifications, ProfileStore works just the same as in it's original version. All of the custom callbacks are nil, therefore not used and do not apply any changes to the usual data flow.\r\n    **[defined in lines 1392-1398 in ProfileStore.luau](<ProfileStore.luau/#L1392-1398>):**\r\n\r\n    ```lua\r\n    custom_callbacks = {\r\n\t\tDeepCopyTable = nil,\r\n\t\tReconcileTable = nil,\r\n\t\tDecode = nil,\r\n\t\tEncode = nil,\r\n\t\tRuntimeWrapper = nil,\r\n\t},\r\n    ```\r\n\r\n    Each of the callbacks can be set using the new methods on ProfileStore Object\r\n\r\n### New Methods for ProfileStore objects (All chainable)\r\n- **:SetDeepCopyTableCallback(callback)**\r\n    Sets the custom Deep Copy callback; Returns self.\r\n    \r\n    Your callback will receive State A or State C input and must output State C\r\n\r\n    The <strong>callback</strong> will be called in many cases with a table Profile.Data (from the Datastore, but not limited to), must return a copy of that table (usually modified copying)\r\n- **:SetReconcileTableCallback(callback)**\r\n    Sets the custom Reconcile callback; Returns self.\r\n\r\n    This one is only ever used when calling **Profile:Reconcile** on Profile objects created via this ProfileStore Object.\r\n\r\n    The <strong>callback</strong> will be called at Profile:Reconcile() with ``( target: Profile.Data (Decoded; State A or State C), template: ProfileStore.Template | Profile.ProfileStore.Template (Can be any of the states A, B and C or also a hybrid - the handler is in your hands), profile: Profile<T> [\"usually unnecessary/unused, so it was marked with ? to silence the type checker when you leave this unused\"] ) -> nothing, but mutate the target to State A``\r\n- **:SetDecodeCallback(callback)**\r\n    Sets the custom Decode callback; Returns self.\r\n\r\n    The <strong>callback</strong> will be called at the start of transform_function with Profile.Data (from the Datastore), if there was any saved;\r\n    It will also be called at the start of Profile.New() to write LastSavedData. The `Profile.Data` (`_Data`) is directly what your Decode callback outputs or fresh data from datastore, if no custom Decode callback is set.\r\n\r\n    Decode(State B) -> State A\r\n- **:SetEncodeCallback(callback)** \r\n    Sets the custom Encode callback; Returns self.\r\n\r\n    The <strong>callback</strong> will be called at the end of transform_function with Profile.Data (after it has been Decoded by the custom Decode callback set using SetDecodeCallback method), before it's returned back into DataStore\r\n    \r\n    Encode(State C) -> State B\r\n- **:SetRuntimeWrapperCallback(callback)**\r\n    Sets the custom Runtime Overwrite handler callback; Returns self.\r\n\r\n    The callback will be executed whenever Profile.Data is manually overwritten at runtime.\r\n\r\n    This does happen in the test cases! In an example with CoffeeParser this is not necessary.\r\n\r\n    However more complex systems might need this.\r\n\r\n    RuntimeWrapper(\r\n        table that Can be any of the states A, B and C or also a hybrid - the handler is in your hands,\r\n        \r\n        Profile to which the return value will be written\r\n    ) -> State A\r\n\r\n## IMPORTANT Note about Encoding/Decoding\r\n\r\n**Encoding and Decoding** must be lossless.\r\n\r\n\r\n### Methods usage summary:\r\n```lua\r\n-- the types StateA, StateB and StateC are not defined anywhere\r\n-- they are here solely for the looks of the example\r\n\r\nlocal Store = ProfileStore.New(\"StoreName\", {--[[data template]]})\r\n    :SetDeepCopyTableCallback(function(t: (StateA | StateC)): StateA\r\n        local copy : StateA = {}\r\n        -- do your DeepCopy process\r\n        return copy -- return as State A\r\n    end)\r\n    :SetReconcileTableCallback(function(\r\n        target: (StateA | StateC), -- the table to mutate (make changes to) into StateA\r\n        template: (StateA | StateB | StateC | any), -- truly your input of the [[data template]]\r\n        profile -- : ProfileStore.Profile<[[data template]]> -- can be commented out\r\n        -- why is it (profile) here? - Used internally to ensure custom DeepCopyTable callback is used\r\n    )\r\n        -- mutate target to contain all keys from template, that it doesn't have yet\r\n        -- if your StateC and StateA are really different, you shall mutate the target\r\n        -- to become StateA\r\n        -- return nothing\r\n    end)\r\n    :SetDecodeCallback(function(data: StateB): StateC\r\n        local result = {}\r\n        -- do your Decoding process\r\n        return result -- return as State C\r\n    end)\r\n    :SetEncodeCallback(function(data: StateC): StateB\r\n        local result = {}\r\n        -- do your Encoding process\r\n        return result -- return as State B\r\n    end)\r\n    :SetRuntimeWrapperCallback(function(data: (StateA | StateB | StateC | any)): StateA\r\n        local result = {}\r\n        -- implement your own rules on what happens whenever Profile.Data is overwritten as a whole\r\n        return result -- return as State A\r\n    end)\r\n```\r\n\r\n---\r\n\r\n### Every Profile object now doesn't have [\"Data\"] field directly\r\n\r\n``Profile.__index`` was changed and ``Profile.__newindex`` added.\r\n\r\nProfile Objects work just the way they did before, no new methods. The `Data` is now actually stored as `_Data`, however no changes to the code are needed, since per request of `Data` you'd end up receiving `_Data`. This change was only needed to implement Runtime Wrapper Callback functionalities, since `__newindex` only fires when the assigned key doesn't exist yet.\r\n\r\n### Note on `Profile.LastSavedData`\r\nIt was inconsistent in the first Release (v2.0.0) and would be StateC at `Profile.New`, but become StateB after any save;\r\n\r\nNow it was made consistent and will always be StateC (by DeepCopy or your own DeepCopy Callback). Here's why it'll be StateC.\r\n\r\nWhile changing it to be StateB is the easiest approach and would only require a single encode on every `Profile.New` call, if you've added an Encode callback, it might be annoying to work with encoded data or even wasteful having to decode it for every edit.\r\n\r\nOn the other hand StateA, if it really is different from StateC in your setup, it might get very complex to guarantee StateA. Moreover StateA, when different from StateC, is likely to contain metatables, wrappers, references and so on. Keeping a copy with all of the functionality added by such additions not only doubles the memory, but might also mismatch with what was actually saved to the Datastore.\r\n\r\nLastly StateC is the canonical \"plain Roblox data\" representation. The perfect middle ground:\r\n> **Produced by `DeepCopy` and `Decode`**\r\n>\r\n> **Consumed by `Encode`**\r\n>\r\n> **Allowed to contain both Primitive (JSON Acceptable) and Roblox Datatypes**\r\n\r\nIt also eliminates the need for any additional operations like Encoding or running Reconcillation.\r\n\r\nWe only need to let the already computed StateC to escape the `transform_function` from `UpdateAsync` to `SaveProfileAsync`.\r\n\r\nMany approaches were evaluated. Making `UpdateAsync` return a third value or writing a temporary value to `Profile` like `_PendingLastSavedData`, would both work and it wouldn't break any of the original code, however most of the original code does not need it either. We only need it for `SaveProfileAsync`, so...\r\n\r\nThe best approach is adding an optional additional parameter - `escapeCallback`, which can be scaled in the future if needed, but for now will only be called with the StateC to be cached only inside `SaveProfileAsync`, where the cached result is only used under the condition that `loaded_data ~= nil and key_info ~= nil` (so it's guaranteed here)\r\n\r\n---\r\n\r\n### ProfileStore object creation has two new additional params\r\n```lua\r\n-- learn more about datastores here:\r\n-- https://create.roblox.com/docs/cloud-services/data-stores/versioning-listing-and-caching\r\n--[=[-- quotes from the link above:\r\n\r\n>   ⚠️ For new experiences, use listing and prefixes to organize keys in your data\r\n>   store instead of the legacy scopes feature. For existing experiences that use\r\n>   scopes, continue using them.\r\n\r\n>   When you use the AllScopes property, ListKeysAsync() returns every key with their\r\n>   scope as the prefix argument, such as global/player_data_1234 or houses/house3.\r\n>   Remember that the default scope is global.\r\n\r\n    Personal advice by Coffilhg:\r\n    \r\n    Not using scopes means you will be using the Key name to fit both your scope the key,\r\n    that means you have only 50 characters to fit scope + key.\r\n    Also when listing with `DataStoreOptions.AllScopes = true` and DataStore:ListKeysAsync()\r\n    you'll see the following format `global/{your custom scope}{key}`\r\n\r\n    Using specific scopes is not recommended by Roblox, as stated in the quote, I believe this is\r\n    because that'd mean you need to get your DataStore in multiple Instances, e.g.\r\n    local MyDatastoreWithScopeA = ProfileStore.New(\"MyDatastore\", {--[[data template]]}, \"ScopeA\")\r\n    local MyDatastoreWithScopeB = ProfileStore.New(\"MyDatastore\", {--[[data template]]}, \"ScopeB\")\r\n    Now you have 50 characters for the scope and 50 characters for the key and the scopes aren't\r\n    messed up to be in the `global/{your custom scope}{key}` format, but there's a better way.\r\n\r\n    You can do the following:\r\n    local MyDatastoreWithAllScopes = ProfileStore.New(\"MyDatastore\", {--[[data template]]}, nil, true)\r\n    then whenever writing keys, do it using the `{scope}/{key}` format for your keys, Roblox then\r\n    handles scope and key separately, this way you have 50 characters for both scope and the key\r\n    later you can do :ListKeysAsync(`{scope}`) or\r\n    DataStoreService:GetDataStore(\"MyDatastore\", `{scope}`):ListKeysAsync()\r\n    this would be the best, merged approach\r\n\r\n    Check out\r\n    other/DatastoreScopesListing.luau in this repository\r\n    to test this yourself\r\n\r\n    read more about the limits here:\r\n    https://create.roblox.com/docs/cloud-services/data-stores/error-codes-and-limits#data-limits\r\n\r\n\r\n--]=]--\r\n\r\n\r\n-- highlights:\r\nfunction ProfileStore.New(store_name, template, scope, allScopes)\r\n    -- original code from ProfileService\r\n\r\n    local options = Instance.new(\"DataStoreOptions\")\r\n\toptions:SetExperimentalFeatures({v2 = true})\r\n\toptions.AllScopes = not not allScopes\r\n\r\n\tlocal effectiveScope = nil\r\n\tif options.AllScopes == true then\r\n\t\teffectiveScope = \"\"\r\n\telseif type(scope) == `string` then\r\n\t\teffectiveScope = scope\r\n\tend\r\n\r\n    -- original code from ProfileService\r\n\r\n    self.data_store = DataStoreService:GetDataStore(store_name, effectiveScope, options)\r\n\r\n    -- original code from ProfileService\r\nend\r\n```\r\n\r\n---\r\n\r\n# The original README Conte","readmeTruncated":true,"readmeFull":"https://api.forest.dev/v1/package/coffilhg/roblox/profilestorev2/2.0.3/readme"}