├── selene.toml ├── default.project.json ├── src ├── noYield.luau ├── freezeDeep.luau ├── copyDeep.luau ├── Error.luau ├── Config.luau ├── Internal.luau ├── Migration.luau ├── AutoSave.luau ├── PromiseTypes.luau ├── Data │ ├── init.luau │ └── Throttle.luau ├── init.luau ├── Collection.luau ├── Document.luau ├── Document.test.luau └── init.test.luau ├── .gitignore ├── test ├── runTests.server.luau └── default.project.json ├── examples ├── player-data │ ├── wally.toml │ ├── wally.lock │ ├── default.project.json │ └── src │ │ └── server │ │ └── init.server.luau └── developer-product-handling │ ├── test │ ├── src │ │ ├── PlayerMock.lua │ │ ├── runTest.server.lua │ │ ├── MarketplaceServiceMock.lua │ │ └── DeveloperProductHandling.test.lua │ ├── wally.toml │ ├── default.project.json │ └── wally.lock │ ├── wally.toml │ ├── default.project.json │ ├── wally.lock │ └── src │ └── server │ └── init.server.luau ├── wally.toml ├── wally.lock ├── docs ├── intro.md ├── Example.md ├── Migrations.md └── DeveloperProduct.md ├── LICENSE ├── README.md └── CHANGELOG.md /selene.toml: -------------------------------------------------------------------------------- 1 | std = "roblox" 2 | -------------------------------------------------------------------------------- /default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lapis", 3 | "tree": { 4 | "$path": "src" 5 | } 6 | } -------------------------------------------------------------------------------- /src/noYield.luau: -------------------------------------------------------------------------------- 1 | local function noYield(callback) 2 | for _ in 3 | function() 4 | callback() 5 | end 6 | do 7 | break 8 | end 9 | end 10 | 11 | return noYield 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.rbxm 2 | *.rbxmx 3 | *.rbxl 4 | *.rbxlx 5 | 6 | *.rbxlx.lock 7 | *.rbxl.lock 8 | 9 | /Packages 10 | /DevPackages 11 | 12 | /examples/**/Packages 13 | /examples/**/ServerPackages 14 | 15 | /sourcemap.json 16 | 17 | /build 18 | -------------------------------------------------------------------------------- /test/runTests.server.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | local Midori = require(ReplicatedStorage.DevPackages.Midori) 4 | 5 | Midori.runTests(ReplicatedStorage.Packages.Lapis, { 6 | timeoutWarningDelay = 3, 7 | concurrent = true, 8 | }) 9 | -------------------------------------------------------------------------------- /examples/player-data/wally.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lapis/player-data-example" 3 | version = "0.1.0" 4 | registry = "https://github.com/UpliftGames/wally-index" 5 | realm = "shared" 6 | private = true 7 | 8 | [dependencies] 9 | t = "osyrisrblx/t@3.0.0" 10 | Promise = "evaera/promise@4.0.0" 11 | -------------------------------------------------------------------------------- /src/freezeDeep.luau: -------------------------------------------------------------------------------- 1 | local function freezeDeep(value) 2 | if typeof(value) ~= "table" then 3 | return 4 | end 5 | 6 | if not table.isfrozen(value) then 7 | table.freeze(value) 8 | end 9 | 10 | for _, innerValue in value do 11 | freezeDeep(innerValue) 12 | end 13 | end 14 | 15 | return freezeDeep 16 | -------------------------------------------------------------------------------- /examples/developer-product-handling/test/src/PlayerMock.lua: -------------------------------------------------------------------------------- 1 | local Players = game:GetService("Players") 2 | 3 | local Player = {} 4 | Player.__index = Player 5 | 6 | function Player.new(userId) 7 | return setmetatable({ 8 | UserId = userId, 9 | Parent = Players, 10 | }, Player) 11 | end 12 | 13 | return Player 14 | -------------------------------------------------------------------------------- /examples/developer-product-handling/test/src/runTest.server.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | local Midori = require(ReplicatedStorage.Packages.Midori) 4 | 5 | Midori.runTests(script.Parent, { 6 | showTimeoutWarning = true, 7 | timeoutWarningDelay = 3, 8 | concurrent = true, 9 | }) 10 | -------------------------------------------------------------------------------- /src/copyDeep.luau: -------------------------------------------------------------------------------- 1 | local function copyDeep(value) 2 | if typeof(value) ~= "table" then 3 | return value 4 | end 5 | 6 | local new = table.clone(value) 7 | 8 | for k, v in value do 9 | if type(v) == "table" then 10 | new[k] = copyDeep(v) 11 | end 12 | end 13 | 14 | return new 15 | end 16 | 17 | return copyDeep 18 | -------------------------------------------------------------------------------- /examples/developer-product-handling/wally.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lapis/developer-product-example" 3 | version = "0.1.0" 4 | registry = "https://github.com/UpliftGames/wally-index" 5 | realm = "shared" 6 | private = true 7 | 8 | [dependencies] 9 | Promise = "evaera/promise@4.0.0" 10 | Sift = "csqrl/sift@0.0.8" 11 | t = "osyrisrblx/t@3.0.0" 12 | -------------------------------------------------------------------------------- /wally.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nezuo/lapis" 3 | version = "0.3.4" 4 | registry = "https://github.com/UpliftGames/wally-index" 5 | exclude = ["**"] 6 | include = ["src", "src/*", "default.project.json", "wally.toml", "LICENSE"] 7 | realm = "shared" 8 | 9 | [dependencies] 10 | Promise = "evaera/promise@4.0.0" 11 | 12 | [dev-dependencies] 13 | Midori = "nezuo/midori@0.1.4" 14 | DataStoreServiceMock = "nezuo/data-store-service-mock@0.3.7" 15 | -------------------------------------------------------------------------------- /src/Error.luau: -------------------------------------------------------------------------------- 1 | type ErrorKind = 2 | "RobloxApiError" 3 | | "DefaultDataThrew" 4 | | "SessionLocked" 5 | | "MigrationError" 6 | | "BeforeSaveCloseCallbackThrew" 7 | | "DocumentRemoved" 8 | | "SessionLockStolen" 9 | | "ValidateThrew" 10 | | "ValidateFailed" 11 | 12 | local Error = {} 13 | 14 | function Error.new(kind: ErrorKind, message: string) 15 | return { 16 | kind = kind, 17 | message = message, 18 | } 19 | end 20 | 21 | return Error 22 | -------------------------------------------------------------------------------- /examples/developer-product-handling/test/wally.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lapis/developer-product-example-tests" 3 | version = "0.1.0" 4 | registry = "https://github.com/UpliftGames/wally-index" 5 | realm = "shared" 6 | private = true 7 | 8 | [dependencies] 9 | Midori = "nezuo/midori@0.1.3" 10 | Promise = "evaera/promise@4.0.0" 11 | Sift = "csqrl/sift@0.0.8" 12 | t = "osyrisrblx/t@3.0.0" 13 | 14 | [server-dependencies] 15 | DataStoreServiceMock = "nezuo/data-store-service-mock@0.3.5" 16 | -------------------------------------------------------------------------------- /examples/player-data/wally.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Wally. 2 | # It is not intended for manual editing. 3 | registry = "test" 4 | 5 | [[package]] 6 | name = "evaera/promise" 7 | version = "4.0.0" 8 | dependencies = [] 9 | 10 | [[package]] 11 | name = "lapis/example" 12 | version = "0.1.0" 13 | dependencies = [["Promise", "evaera/promise@4.0.0"], ["t", "osyrisrblx/t@3.0.0"]] 14 | 15 | [[package]] 16 | name = "osyrisrblx/t" 17 | version = "3.0.0" 18 | dependencies = [] 19 | -------------------------------------------------------------------------------- /examples/player-data/default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "tree": { 4 | "$className": "DataModel", 5 | "Players": { 6 | "$properties": { 7 | "CharacterAutoLoads": false 8 | } 9 | }, 10 | "ReplicatedStorage": { 11 | "Packages": { 12 | "Lapis": { 13 | "$path": "../../" 14 | }, 15 | "$path": "Packages" 16 | } 17 | }, 18 | "ServerScriptService": { 19 | "Server": { 20 | "$path": "src/server" 21 | } 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /examples/developer-product-handling/default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "developer-product-handling", 3 | "tree": { 4 | "$className": "DataModel", 5 | "Players": { 6 | "$properties": { 7 | "CharacterAutoLoads": false 8 | } 9 | }, 10 | "ReplicatedStorage": { 11 | "Packages": { 12 | "Lapis": { 13 | "$path": "../../" 14 | }, 15 | "$path": "Packages" 16 | } 17 | }, 18 | "ServerScriptService": { 19 | "Server": { 20 | "$path": "src/server" 21 | } 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /test/default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lapis-test", 3 | "tree": { 4 | "$className": "DataModel", 5 | "Players": { 6 | "$properties": { 7 | "CharacterAutoLoads": false 8 | } 9 | }, 10 | "ReplicatedStorage": { 11 | "Packages": { 12 | "Lapis": { 13 | "$path": "../" 14 | }, 15 | "$path": "../Packages" 16 | }, 17 | "DevPackages": { 18 | "$path": "../DevPackages" 19 | } 20 | }, 21 | "ServerScriptService": { 22 | "runTests": { 23 | "$path": "runTests.server.luau" 24 | } 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /examples/developer-product-handling/wally.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Wally. 2 | # It is not intended for manual editing. 3 | registry = "test" 4 | 5 | [[package]] 6 | name = "csqrl/sift" 7 | version = "0.0.8" 8 | dependencies = [] 9 | 10 | [[package]] 11 | name = "evaera/promise" 12 | version = "4.0.0" 13 | dependencies = [] 14 | 15 | [[package]] 16 | name = "lapis/developer-product-example" 17 | version = "0.1.0" 18 | dependencies = [["Promise", "evaera/promise@4.0.0"], ["Sift", "csqrl/sift@0.0.8"], ["t", "osyrisrblx/t@3.1.1"]] 19 | 20 | [[package]] 21 | name = "osyrisrblx/t" 22 | version = "3.1.1" 23 | dependencies = [] 24 | -------------------------------------------------------------------------------- /wally.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Wally. 2 | # It is not intended for manual editing. 3 | registry = "test" 4 | 5 | [[package]] 6 | name = "evaera/promise" 7 | version = "4.0.0" 8 | dependencies = [] 9 | 10 | [[package]] 11 | name = "nezuo/data-store-service-mock" 12 | version = "0.3.7" 13 | dependencies = [] 14 | 15 | [[package]] 16 | name = "nezuo/lapis" 17 | version = "0.3.2" 18 | dependencies = [["Promise", "evaera/promise@4.0.0"], ["DataStoreServiceMock", "nezuo/data-store-service-mock@0.3.7"], ["Midori", "nezuo/midori@0.1.4"]] 19 | 20 | [[package]] 21 | name = "nezuo/midori" 22 | version = "0.1.4" 23 | dependencies = [] 24 | -------------------------------------------------------------------------------- /examples/developer-product-handling/test/default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "developer-product-handling-tests", 3 | "tree": { 4 | "$className": "DataModel", 5 | "Players": { 6 | "$properties": { 7 | "CharacterAutoLoads": false 8 | } 9 | }, 10 | "ReplicatedStorage": { 11 | "Packages": { 12 | "Lapis": { 13 | "$path": "../../../" 14 | }, 15 | "$path": "Packages" 16 | } 17 | }, 18 | "ServerScriptService": { 19 | "ServerPackages": { 20 | "$path": "ServerPackages" 21 | }, 22 | "Test": { 23 | "$path": "src" 24 | } 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/Config.luau: -------------------------------------------------------------------------------- 1 | local DataStoreService = game:GetService("DataStoreService") 2 | 3 | local Config = {} 4 | Config.__index = Config 5 | 6 | function Config.new() 7 | return setmetatable({ 8 | config = { 9 | saveAttempts = 5, 10 | loadAttempts = 20, 11 | loadRetryDelay = 1, 12 | showRetryWarnings = true, 13 | dataStoreService = DataStoreService, 14 | }, 15 | }, Config) 16 | end 17 | 18 | function Config:get(key) 19 | return self.config[key] 20 | end 21 | 22 | function Config:set(values) 23 | for key, value in values do 24 | if self.config[key] == nil then 25 | error(`Invalid config key "{tostring(key)}"`) 26 | end 27 | 28 | self.config[key] = value 29 | end 30 | end 31 | 32 | return Config 33 | -------------------------------------------------------------------------------- /examples/developer-product-handling/test/src/MarketplaceServiceMock.lua: -------------------------------------------------------------------------------- 1 | local MarketplaceServiceMock = {} 2 | MarketplaceServiceMock.__index = MarketplaceServiceMock 3 | 4 | function MarketplaceServiceMock.new() 5 | return setmetatable({ 6 | nextPurchaseId = 1, 7 | }, MarketplaceServiceMock) 8 | end 9 | 10 | function MarketplaceServiceMock:onProductPurchased(userId, productId, existingPurchaseId: string?) 11 | if self.ProcessReceipt == nil then 12 | error("ProcessReceipt callback wasn't set") 13 | end 14 | 15 | local purchaseId = existingPurchaseId 16 | 17 | if purchaseId == nil then 18 | purchaseId = tostring(self.nextPurchaseId) 19 | self.nextPurchaseId += 1 20 | end 21 | 22 | local productPurchaseDecision = self.ProcessReceipt({ 23 | PurchaseId = purchaseId, 24 | PlayerId = userId, 25 | ProductId = productId, 26 | }) 27 | 28 | return productPurchaseDecision, purchaseId 29 | end 30 | 31 | return MarketplaceServiceMock 32 | -------------------------------------------------------------------------------- /examples/developer-product-handling/test/wally.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Wally. 2 | # It is not intended for manual editing. 3 | registry = "test" 4 | 5 | [[package]] 6 | name = "csqrl/sift" 7 | version = "0.0.8" 8 | dependencies = [] 9 | 10 | [[package]] 11 | name = "evaera/promise" 12 | version = "4.0.0" 13 | dependencies = [] 14 | 15 | [[package]] 16 | name = "lapis/developer-product-example-tests" 17 | version = "0.1.0" 18 | dependencies = [["Midori", "nezuo/midori@0.1.3"], ["Promise", "evaera/promise@4.0.0"], ["Sift", "csqrl/sift@0.0.8"], ["t", "osyrisrblx/t@3.1.1"], ["DataStoreServiceMock", "nezuo/data-store-service-mock@0.3.5"]] 19 | 20 | [[package]] 21 | name = "nezuo/data-store-service-mock" 22 | version = "0.3.5" 23 | dependencies = [] 24 | 25 | [[package]] 26 | name = "nezuo/midori" 27 | version = "0.1.3" 28 | dependencies = [] 29 | 30 | [[package]] 31 | name = "osyrisrblx/t" 32 | version = "3.1.1" 33 | dependencies = [] 34 | -------------------------------------------------------------------------------- /docs/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Introduction 6 | 7 | ## Features 8 | - **Session Locking** - Documents can only be accessed from one server at a time. This prevents some bugs and duping methods. 9 | - **Validation** - Ensure your data is correct before saving it. 10 | - **Migrations** - Update the structure of your data over time. 11 | - **Retries** - Failed DataStore requests will be retried. 12 | - **Throttling** - DataStore requests will never exceed their budget and throw an error. 13 | - **Promise-based API** - Promises are used instead of yielding. 14 | - **Immutability** - By default, documents are deep frozen must be updated immutably. This can be disabled. 15 | - **Save Batching** - Pending `Document:save()` and `Document:close()` calls are combined into one DataStore request when possible. 16 | - **Auto Save** - Documents are automatically saved every 5 minutes. 17 | - **BindToClose** - All documents are automatically closed when the game shuts down. -------------------------------------------------------------------------------- /src/Internal.luau: -------------------------------------------------------------------------------- 1 | local AutoSave = require(script.Parent.AutoSave) 2 | local Collection = require(script.Parent.Collection) 3 | local Config = require(script.Parent.Config) 4 | local Data = require(script.Parent.Data) 5 | 6 | local Internal = {} 7 | 8 | function Internal.new(enableAutoSave) 9 | local config = Config.new() 10 | local data = Data.new(config) 11 | local autoSave = AutoSave.new(data) 12 | 13 | if enableAutoSave then 14 | autoSave:start() 15 | end 16 | 17 | local usedCollections = {} 18 | 19 | local internal = {} 20 | 21 | if not enableAutoSave then 22 | -- This exposes AutoSave to unit tests. 23 | internal.autoSave = autoSave 24 | end 25 | 26 | function internal.setConfig(values) 27 | config:set(values) 28 | end 29 | 30 | function internal.createCollection(name, options) 31 | if usedCollections[name] then 32 | error(`Collection "{name}" already exists`) 33 | end 34 | 35 | usedCollections[name] = true 36 | 37 | return Collection.new(name, options, data, autoSave, config) 38 | end 39 | 40 | return internal 41 | end 42 | 43 | return Internal 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Micah 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lapis 2 | A Roblox DataStore abstraction that offers: 3 | - **Session Locking** - Documents can only be accessed from one server at a time. This prevents some bugs and duping methods. 4 | - **Validation** - Ensure your data is correct before saving it. 5 | - **Migrations** - Update the structure of your data over time. 6 | - **Retries** - Failed DataStore requests will be retried. 7 | - **Throttling** - DataStore requests will never exceed their budget and throw an error. 8 | - **Promise-based API** - Promises are used instead of yielding. 9 | - **Immutability** - By default, documents are deep frozen must be updated immutably. This can be disabled. 10 | - **Save Batching** - Pending `Document:save()` and `Document:close()` calls are combined into one DataStore request when possible. 11 | - **Auto Save** - Documents are automatically saved every 5 minutes. 12 | - **BindToClose** - All documents are automatically closed when the game shuts down. 13 | 14 | This library was inspired by [Quicksave](https://github.com/evaera/Quicksave). 15 | 16 | ## Warning 17 | Lapis has not been battle-tested in a large production game yet. It may contain obscure bugs so use at your own risk. 18 | -------------------------------------------------------------------------------- /src/Migration.luau: -------------------------------------------------------------------------------- 1 | local Migration = {} 2 | 3 | function Migration.getLastCompatibleVersion(migrations) 4 | local serverVersion = #migrations 5 | 6 | for version = serverVersion, 1, -1 do 7 | local migration = migrations[version] 8 | 9 | if migration.backwardsCompatible ~= true then 10 | return version 11 | end 12 | end 13 | 14 | return 0 15 | end 16 | 17 | function Migration.migrate(migrations, value, key) 18 | local serverVersion = #migrations 19 | local savedVersion = value.migrationVersion 20 | 21 | local data = value.data 22 | local lastCompatibleVersion = value.lastCompatibleVersion 23 | 24 | if serverVersion > savedVersion then 25 | for version = savedVersion + 1, #migrations do 26 | local ok, migrated = pcall(migrations[version].migrate, data, key) 27 | if not ok then 28 | return false, `Migration {version} threw an error: {migrated}` 29 | end 30 | 31 | if migrated == nil then 32 | return false, `Migration {version} returned 'nil'` 33 | end 34 | 35 | data = migrated 36 | end 37 | 38 | lastCompatibleVersion = Migration.getLastCompatibleVersion(migrations) 39 | elseif serverVersion < savedVersion then 40 | -- lastCompatibleVersion will be nil for documents that existed before backwards compatibilty was added and haven't been migrated to a new version since. 41 | if lastCompatibleVersion == nil or serverVersion < lastCompatibleVersion then 42 | return false, 43 | `Saved migration version {savedVersion} is not backwards compatible with version {serverVersion}` 44 | end 45 | end 46 | 47 | return true, data, lastCompatibleVersion 48 | end 49 | 50 | return Migration 51 | -------------------------------------------------------------------------------- /src/AutoSave.luau: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | 3 | local Promise = require(script.Parent.Parent.Promise) 4 | 5 | local UPDATE_INTERVAL = 5 * 60 6 | 7 | local AutoSave = {} 8 | AutoSave.__index = AutoSave 9 | 10 | function AutoSave.new(data) 11 | return setmetatable({ 12 | documents = {}, 13 | data = data, 14 | gameClosed = false, 15 | ongoingLoads = 0, 16 | ongoingRemoveLocks = 0, 17 | }, AutoSave) 18 | end 19 | 20 | function AutoSave:addDocument(document) 21 | table.insert(self.documents, document) 22 | end 23 | 24 | function AutoSave:removeDocument(document) 25 | local index = table.find(self.documents, document) 26 | 27 | table.remove(self.documents, index) 28 | end 29 | 30 | function AutoSave:finishLoad(document) 31 | if self.gameClosed then 32 | document:close() 33 | end 34 | 35 | self.ongoingLoads -= 1 36 | end 37 | 38 | function AutoSave:onGameClose() 39 | self.gameClosed = true 40 | self.data.throttle.gameClosed = true 41 | 42 | while #self.documents > 0 do 43 | self.documents[#self.documents]:close() 44 | end 45 | 46 | Promise.try(function() 47 | while self.ongoingLoads > 0 do 48 | task.wait() 49 | end 50 | end) 51 | :andThen(function() 52 | return Promise.allSettled({ 53 | self.data:waitForOngoingSaves(), 54 | Promise.try(function() 55 | while self.ongoingRemoveLocks > 0 do 56 | task.wait() 57 | end 58 | end), 59 | }) 60 | end) 61 | :await() 62 | end 63 | 64 | function AutoSave:start() 65 | local nextUpdateAt = os.clock() + UPDATE_INTERVAL 66 | RunService.Heartbeat:Connect(function() 67 | if os.clock() >= nextUpdateAt then 68 | for _, document in self.documents do 69 | document:save():catch(warn) 70 | end 71 | 72 | nextUpdateAt += UPDATE_INTERVAL 73 | end 74 | end) 75 | 76 | game:BindToClose(function() 77 | self:onGameClose() 78 | end) 79 | end 80 | 81 | return AutoSave 82 | -------------------------------------------------------------------------------- /examples/player-data/src/server/init.server.luau: -------------------------------------------------------------------------------- 1 | local Players = game:GetService("Players") 2 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 3 | 4 | local Lapis = require(ReplicatedStorage.Packages.Lapis) 5 | local t = require(ReplicatedStorage.Packages.t) 6 | 7 | local DEFAULT_DATA = { coins = 100 } 8 | 9 | local collection = Lapis.createCollection("PlayerData", { 10 | defaultData = DEFAULT_DATA, 11 | -- You can use t by osyrisrblx to type check your data at runtime. 12 | validate = t.strictInterface({ coins = t.integer }), 13 | }) 14 | 15 | local documents = {} 16 | 17 | local function onPlayerAdded(player: Player) 18 | -- The second argument associates the document with the player's UserId which is useful 19 | -- for GDPR compliance. 20 | collection 21 | :load(`Player{player.UserId}`, { player.UserId }) 22 | :andThen(function(document) 23 | if player.Parent == nil then 24 | -- The player might have left before the document finished loading. 25 | -- The document needs to be closed because PlayerRemoving won't fire at this point. 26 | document:close():catch(warn) 27 | return 28 | end 29 | 30 | documents[player] = document 31 | end) 32 | :catch(function(message) 33 | warn(`Player {player.Name}'s data failed to load: {message}`) 34 | 35 | -- Optionally, you can kick the player when their data fails to load: 36 | player:Kick("Data failed to load.") 37 | end) 38 | end 39 | 40 | local function onPlayerRemoving(player: Player) 41 | local document = documents[player] 42 | 43 | -- The document won't be added to the dictionary if PlayerRemoving fires bofore it finishes loading. 44 | if document ~= nil then 45 | documents[player] = nil 46 | document:close():catch(warn) 47 | end 48 | end 49 | 50 | Players.PlayerAdded:Connect(onPlayerAdded) 51 | Players.PlayerRemoving:Connect(onPlayerRemoving) 52 | 53 | for _, player in Players:GetPlayers() do 54 | onPlayerAdded(player) 55 | end 56 | -------------------------------------------------------------------------------- /docs/Example.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Example Usage 6 | The following code is an example of how you would load and close player data: 7 | ```lua 8 | local DEFAULT_DATA = { coins = 100 } 9 | 10 | local collection = Lapis.createCollection("PlayerData", { 11 | defaultData = DEFAULT_DATA, 12 | -- You can use t by osyrisrblx to type check your data at runtime. 13 | validate = t.strictInterface({ coins = t.integer }), 14 | }) 15 | 16 | local documents = {} 17 | 18 | local function onPlayerAdded(player: Player) 19 | -- The second argument associates the document with the player's UserId which is useful 20 | -- for GDPR compliance. 21 | collection 22 | :load(`Player{player.UserId}`, { player.UserId }) 23 | :andThen(function(document) 24 | if player.Parent == nil then 25 | -- The player might have left before the document finished loading. 26 | -- The document needs to be closed because PlayerRemoving won't fire at this point. 27 | document:close():catch(warn) 28 | return 29 | end 30 | 31 | documents[player] = document 32 | end) 33 | :catch(function(message) 34 | warn(`Player {player.Name}'s data failed to load: {message}`) 35 | 36 | -- Optionally, you can kick the player when their data fails to load: 37 | player:Kick("Data failed to load.") 38 | end) 39 | end 40 | 41 | local function onPlayerRemoving(player: Player) 42 | local document = documents[player] 43 | 44 | -- The document won't be added to the dictionary if PlayerRemoving fires bofore it finishes loading. 45 | if document ~= nil then 46 | documents[player] = nil 47 | document:close():catch(warn) 48 | end 49 | end 50 | 51 | Players.PlayerAdded:Connect(onPlayerAdded) 52 | Players.PlayerRemoving:Connect(onPlayerRemoving) 53 | 54 | for _, player in Players:GetPlayers() do 55 | onPlayerAdded(player) 56 | end 57 | ``` 58 | :::info 59 | You do not need to handle `game:BindToClose` or auto saving. Lapis automatically does both of those. 60 | ::: -------------------------------------------------------------------------------- /src/PromiseTypes.luau: -------------------------------------------------------------------------------- 1 | export type Status = "Started" | "Resolved" | "Rejected" | "Cancelled" 2 | 3 | export type Promise = { 4 | andThen: ( 5 | self: Promise, 6 | successHandler: (...any) -> ...any, 7 | failureHandler: ((...any) -> ...any)? 8 | ) -> Promise, 9 | andThenCall: (self: Promise, callback: (T...) -> ...any, T...) -> any, 10 | andThenReturn: (self: Promise, ...any) -> Promise, 11 | 12 | await: (self: Promise) -> (boolean, ...any), 13 | awaitStatus: (self: Promise) -> (Status, ...any), 14 | 15 | cancel: (self: Promise) -> (), 16 | catch: (self: Promise, failureHandler: (...any) -> ...any) -> Promise, 17 | expect: (self: Promise) -> ...any, 18 | 19 | finally: (self: Promise, finallyHandler: (status: Status) -> ...any) -> Promise, 20 | finallyCall: (self: Promise, callback: (T...) -> ...any, T...) -> Promise, 21 | finallyReturn: (self: Promise, ...any) -> Promise, 22 | 23 | getStatus: (self: Promise) -> Status, 24 | now: (self: Promise, rejectionValue: any?) -> Promise, 25 | tap: (self: Promise, tapHandler: (...any) -> ...any) -> Promise, 26 | timeout: (self: Promise, seconds: number, rejectionValue: any?) -> Promise, 27 | } 28 | 29 | export type TypedPromise = { 30 | andThen: (self: Promise, successHandler: (T...) -> ...any, failureHandler: ((...any) -> ...any)?) -> Promise, 31 | andThenCall: (self: Promise, callback: (T...) -> ...any, T...) -> Promise, 32 | andThenReturn: (self: Promise, ...any) -> Promise, 33 | 34 | await: (self: Promise) -> (boolean, T...), 35 | awaitStatus: (self: Promise) -> (Status, T...), 36 | 37 | cancel: (self: Promise) -> (), 38 | catch: (self: Promise, failureHandler: (...any) -> ...any) -> Promise, 39 | expect: (self: Promise) -> T..., 40 | 41 | finally: (self: Promise, finallyHandler: (status: Status) -> ...any) -> Promise, 42 | finallyCall: (self: Promise, callback: (T...) -> ...any, T...) -> Promise, 43 | finallyReturn: (self: Promise, ...any) -> Promise, 44 | 45 | getStatus: (self: Promise) -> Status, 46 | now: (self: Promise, rejectionValue: any?) -> Promise, 47 | tap: (self: Promise, tapHandler: (T...) -> ...any) -> Promise, 48 | timeout: (self: Promise, seconds: number, rejectionValue: any?) -> Promise, 49 | } 50 | 51 | return nil 52 | -------------------------------------------------------------------------------- /docs/Migrations.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | # Migrations 6 | 7 | ## Writing Migrations 8 | Migrations allow you to update the structure of your data over time. You can add new keys, remove ones you no longer need, or change the way you store something. 9 | 10 | Here is an example of a few migrations: 11 | ```lua 12 | local MIGRATIONS = { 13 | -- Migrate from version 0 to 1. 14 | function(old) 15 | return Dictionary.merge(old, { 16 | coins = 0, -- Add a key called coins to the data. 17 | }) 18 | end, 19 | -- Migrate from version 1 to 2. 20 | function(old) 21 | -- We no longer need the playTime key, so we remove it. 22 | -- Note: Migrations can update the data mutably but you still need to return the value. 23 | old.playTime = nil 24 | 25 | return old 26 | end, 27 | } 28 | 29 | local collection = Lapis.createCollection("collection", { 30 | migrations = MIGRATIONS, 31 | validate = validate, 32 | defaultData = DEFAULT_DATA, 33 | }) 34 | ``` 35 | 36 | ## Backwards Compatibility 37 | If you release an update that includes a migration, a player might join a new server, leave, and then join an old server. 38 | This would cause the player's document to fail to load on the old server since the document's version would be ahead of the old server's version. 39 | 40 | To solve this problem, you have two options: 41 | 1. Use Roblox's `Migrate To Latest Update` feature to ensure all servers are up-to-date. 42 | 2. Make your migrations backwards compatible. 43 | 44 | Here's an example of how to make migrations backwards compatible: 45 | ```lua 46 | local function v1() 47 | -- v1 removes a key which causes an error on servers with version 0. 48 | old.playTime = nil 49 | return old 50 | end 51 | 52 | local function v2() 53 | -- v2 adds a new value to the player's data which won't result in an error on servers with version 1. 54 | old.items = {} 55 | return old 56 | end 57 | 58 | local function v3() 59 | -- v3 removes a key which causes an error on servers with version 0, 1, or 2. 60 | old.coins = nil 61 | return old 62 | end 63 | 64 | local MIGRATIONS = { 65 | { 66 | migrate = v1, 67 | backwardsCompatible = false, -- Version 1 isn't backwards compatible with version 0. 68 | }, 69 | { 70 | migrate = v2, 71 | backwardsCompatible = true, -- Version 2 is backwards compatible with version 1. 72 | }, 73 | v3, -- Migrations aren't backwards compatible by default. 74 | } 75 | ``` 76 | 77 | A migration is backwards compatible with the previous version if it can be safely loaded on an old server without resulting in bugs, errors, or incorrect behavior. 78 | 79 | Generally, additive changes are backwards compatible, while removals are not. It's up to you to determine when a change is backwards compatible. 80 | 81 | Backward compatibility is transitive, so for example, if `v2` is backwards compatible with `v1` and `v3` is backwards compatible with `v2`, `v3` is also backwards compatible with `v1`. 82 | 83 | Note that a migration won't be backwards compatible if it fails to pass the previous version's validation. If you intend to 84 | use backwards compatibilty, you should use functions like `t.interface` over `t.strictInterface`. 85 | 86 | ### How to fix mistakes in backwards compatibilty? 87 | If you mistakenly mark a change as backwards compatible when it isn't, you will need to use `Migrate To Latest Update` to correct it. Therefore, be careful not to mark `backwardsCompatible` incorrectly! 88 | -------------------------------------------------------------------------------- /docs/DeveloperProduct.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | # Handling Developer Products 6 | The following code is an example of how you would handle players purchasing developer products: 7 | ```lua 8 | local DEFAULT_DATA = { coins = 100, recentPurchases = {} } 9 | local RECENT_PURCHASES_LIMIT = 100 10 | local PRODUCTS = { 11 | [12345] = function(oldData) 12 | -- Product callbacks return a new version of the data. 13 | return Sift.Dictionary.merge(oldData, { 14 | coins = oldData.coins + 100, 15 | }) 16 | end, 17 | } 18 | 19 | local collection = Lapis.createCollection("PlayerData", { 20 | defaultData = DEFAULT_DATA, 21 | validate = t.strictInterface({ coins = t.integer, recentPurchases = t.array(t.string) }), 22 | }) 23 | 24 | local documents = {} 25 | 26 | local function onPlayerAdded(player: Player) 27 | collection 28 | :load(`Player{player.UserId}`, { player.UserId }) 29 | :andThen(function(document) 30 | if player.Parent == nil then 31 | document:close():catch(warn) 32 | return 33 | end 34 | 35 | documents[player] = document 36 | end) 37 | :catch(function(message) 38 | warn(`Player {player.Name}'s data failed to load: {message}`) 39 | player:Kick("Data failed to load.") 40 | end) 41 | end 42 | 43 | local function onPlayerRemoving(player: Player) 44 | local document = documents[player] 45 | 46 | if document ~= nil then 47 | documents[player] = nil 48 | document:close():catch(warn) 49 | end 50 | end 51 | 52 | local function processReceipt(receiptInfo): Enum.ProductPurchaseDecision 53 | local player = Players:GetPlayerByUserId(receiptInfo.PlayerId) 54 | if player == nil then 55 | return Enum.ProductPurchaseDecision.NotProcessedYet 56 | end 57 | 58 | while documents[player] == nil and player.Parent ~= nil do 59 | -- Wait until the document loads or the player leaves. 60 | task.wait() 61 | end 62 | 63 | local document = documents[player] 64 | if document == nil then 65 | return Enum.ProductPurchaseDecision.NotProcessedYet 66 | end 67 | 68 | local data = document:read() 69 | 70 | if table.find(data.recentPurchases, receiptInfo.PurchaseId) then 71 | -- The purchase has been added to the player's data, but it might not have saved yet. 72 | local saveOk = document:save():await() 73 | 74 | if saveOk then 75 | return Enum.ProductPurchaseDecision.PurchaseGranted 76 | else 77 | return Enum.ProductPurchaseDecision.NotProcessedYet 78 | end 79 | end 80 | 81 | -- The product callback must not yield. Otherwise, it can return outdated data and overwrite new changes. 82 | local productOk, dataWithProduct = pcall(PRODUCTS[receiptInfo.ProductId], data) 83 | if not productOk then 84 | return Enum.ProductPurchaseDecision.NotProcessedYet 85 | end 86 | 87 | local newRecentPurchases = Sift.Array.push(data.recentPurchases, receiptInfo.PurchaseId) 88 | if #newRecentPurchases > RECENT_PURCHASES_LIMIT then 89 | newRecentPurchases = Sift.Array.shift(newRecentPurchases, #newRecentPurchases - RECENT_PURCHASES_LIMIT) 90 | end 91 | 92 | document:write(Sift.Dictionary.merge(dataWithProduct, { 93 | recentPurchases = newRecentPurchases, 94 | })) 95 | 96 | local saveOk = document:save():await() 97 | if not saveOk then 98 | return Enum.ProductPurchaseDecision.NotProcessedYet 99 | end 100 | 101 | return Enum.ProductPurchaseDecision.PurchaseGranted 102 | end 103 | 104 | -- The ProcessReceipt callback must be set before the Players.PlayerAdded signal is fired, otherwise the player's 105 | -- existing receipts won't be processed when they join. 106 | MarketplaceService.ProcessReceipt = processReceipt 107 | 108 | Players.PlayerAdded:Connect(onPlayerAdded) 109 | Players.PlayerRemoving:Connect(onPlayerRemoving) 110 | 111 | for _, player in Players:GetPlayers() do 112 | onPlayerAdded(player) 113 | end 114 | ``` 115 | -------------------------------------------------------------------------------- /src/Data/init.luau: -------------------------------------------------------------------------------- 1 | local Error = require(script.Parent.Error) 2 | local Promise = require(script.Parent.Parent.Promise) 3 | local Throttle = require(script.Throttle) 4 | 5 | local Data = {} 6 | Data.__index = Data 7 | 8 | function Data.new(config) 9 | local throttle = Throttle.new(config) 10 | 11 | throttle:start() 12 | 13 | return setmetatable({ 14 | config = config, 15 | throttle = throttle, 16 | ongoingSaves = {}, 17 | }, Data) 18 | end 19 | 20 | function Data:waitForOngoingSave(dataStore, key) 21 | if self.ongoingSaves[dataStore] == nil or self.ongoingSaves[dataStore][key] == nil then 22 | return Promise.resolve() 23 | end 24 | 25 | local ongoingSave = self.ongoingSaves[dataStore][key] 26 | 27 | return Promise.allSettled({ 28 | ongoingSave.promise, 29 | if ongoingSave.pendingSave ~= nil then ongoingSave.pendingSave.promise else nil, 30 | }) 31 | end 32 | 33 | function Data:waitForOngoingSaves() 34 | local promises = {} 35 | 36 | for _, ongoingSaves in self.ongoingSaves do 37 | for _, ongoingSave in ongoingSaves do 38 | if ongoingSave.pendingSave ~= nil then 39 | table.insert(promises, ongoingSave.pendingSave.promise) 40 | end 41 | 42 | table.insert(promises, ongoingSave.promise) 43 | end 44 | end 45 | 46 | return Promise.allSettled(promises) 47 | end 48 | 49 | function Data:read(dataStore, key) 50 | return self.throttle:getAsync(dataStore, key) 51 | end 52 | 53 | function Data:load(dataStore, key, transform) 54 | return self:waitForOngoingSave(dataStore, key):andThen(function() 55 | local attempts = self.config:get("loadAttempts") 56 | local retryDelay = self.config:get("loadRetryDelay") 57 | 58 | return self.throttle:updateAsync(dataStore, key, transform, true, attempts, retryDelay) 59 | end) 60 | end 61 | 62 | function Data:save(dataStore, key, transform) 63 | if self.ongoingSaves[dataStore] == nil then 64 | self.ongoingSaves[dataStore] = {} 65 | end 66 | 67 | local ongoingSave = self.ongoingSaves[dataStore][key] 68 | 69 | if ongoingSave == nil then 70 | local attempts = self.config:get("saveAttempts") 71 | local promise = self.throttle:updateAsync(dataStore, key, transform, false, attempts):finally(function() 72 | self.ongoingSaves[dataStore][key] = nil 73 | 74 | if next(self.ongoingSaves[dataStore]) == nil then 75 | self.ongoingSaves[dataStore] = nil 76 | end 77 | end) 78 | 79 | if promise:getStatus() == Promise.Status.Started then 80 | self.ongoingSaves[dataStore][key] = { promise = promise } 81 | end 82 | 83 | return promise 84 | elseif ongoingSave.pendingSave == nil then 85 | local pendingSave = { transform = transform } 86 | 87 | local function save() 88 | return self:save(dataStore, key, pendingSave.transform) 89 | end 90 | 91 | -- promise:finally(save) can't be used because if the ongoingSave promise rejects, so will the promise returned from finally. 92 | pendingSave.promise = ongoingSave.promise:andThen(save, save) 93 | 94 | ongoingSave.pendingSave = pendingSave 95 | 96 | return pendingSave.promise 97 | else 98 | ongoingSave.pendingSave.transform = transform 99 | 100 | return ongoingSave.pendingSave.promise 101 | end 102 | end 103 | 104 | function Data:removeLock(dataStore, key, lockIdToRemove) 105 | local function transform(value, keyInfo) 106 | if value == nil then 107 | return "fail", Error.new("DocumentRemoved", "The document was removed") 108 | end 109 | 110 | if value.lockId ~= lockIdToRemove then 111 | return "fail", Error.new("SessionLockStolen", "The session lock was stolen") 112 | end 113 | 114 | value.lockId = nil 115 | 116 | return "succeed", value, keyInfo:GetUserIds(), keyInfo:GetMetadata() 117 | end 118 | 119 | local attempts = self.config:get("saveAttempts") 120 | 121 | return self.throttle:updateAsync(dataStore, key, transform, false, attempts) 122 | end 123 | 124 | function Data:remove(dataStore, key) 125 | return self.throttle:removeAsync(dataStore, key) 126 | end 127 | 128 | return Data 129 | -------------------------------------------------------------------------------- /examples/developer-product-handling/src/server/init.server.luau: -------------------------------------------------------------------------------- 1 | local MarketplaceService = game:GetService("MarketplaceService") 2 | local Players = game:GetService("Players") 3 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 4 | 5 | local Lapis = require(ReplicatedStorage.Packages.Lapis) 6 | local Sift = require(ReplicatedStorage.Packages.Sift) 7 | local t = require(ReplicatedStorage.Packages.t) 8 | 9 | local DEFAULT_DATA = { coins = 100, recentPurchases = {} } 10 | local RECENT_PURCHASES_LIMIT = 100 11 | local PRODUCTS = { 12 | [12345] = function(oldData) 13 | -- Product callbacks return a new version of the data. 14 | return Sift.Dictionary.merge(oldData, { 15 | coins = oldData.coins + 100, 16 | }) 17 | end, 18 | } 19 | 20 | local collection = Lapis.createCollection("PlayerData", { 21 | defaultData = DEFAULT_DATA, 22 | validate = t.strictInterface({ coins = t.integer, recentPurchases = t.array(t.string) }), 23 | }) 24 | 25 | local documents = {} 26 | 27 | local function onPlayerAdded(player: Player) 28 | collection 29 | :load(`Player{player.UserId}`, { player.UserId }) 30 | :andThen(function(document) 31 | if player.Parent == nil then 32 | document:close():catch(warn) 33 | return 34 | end 35 | 36 | documents[player] = document 37 | end) 38 | :catch(function(message) 39 | warn(`Player {player.Name}'s data failed to load: {message}`) 40 | player:Kick("Data failed to load.") 41 | end) 42 | end 43 | 44 | local function onPlayerRemoving(player: Player) 45 | local document = documents[player] 46 | 47 | if document ~= nil then 48 | documents[player] = nil 49 | document:close():catch(warn) 50 | end 51 | end 52 | 53 | local function processReceipt(receiptInfo): Enum.ProductPurchaseDecision 54 | local player = Players:GetPlayerByUserId(receiptInfo.PlayerId) 55 | if player == nil then 56 | return Enum.ProductPurchaseDecision.NotProcessedYet 57 | end 58 | 59 | while documents[player] == nil and player.Parent ~= nil do 60 | -- Wait until the document loads or the player leaves. 61 | task.wait() 62 | end 63 | 64 | local document = documents[player] 65 | if document == nil then 66 | return Enum.ProductPurchaseDecision.NotProcessedYet 67 | end 68 | 69 | local data = document:read() 70 | 71 | if table.find(data.recentPurchases, receiptInfo.PurchaseId) then 72 | -- The purchase has been added to the player's data, but it might not have saved yet. 73 | local saveOk = document:save():await() 74 | 75 | if saveOk then 76 | return Enum.ProductPurchaseDecision.PurchaseGranted 77 | else 78 | return Enum.ProductPurchaseDecision.NotProcessedYet 79 | end 80 | end 81 | 82 | -- The product callback must not yield. Otherwise, it can return outdated data and overwrite new changes. 83 | local productOk, dataWithProduct = pcall(PRODUCTS[receiptInfo.ProductId], data) 84 | if not productOk then 85 | return Enum.ProductPurchaseDecision.NotProcessedYet 86 | end 87 | 88 | local newRecentPurchases = Sift.Array.push(data.recentPurchases, receiptInfo.PurchaseId) 89 | if #newRecentPurchases > RECENT_PURCHASES_LIMIT then 90 | newRecentPurchases = Sift.Array.shift(newRecentPurchases, #newRecentPurchases - RECENT_PURCHASES_LIMIT) 91 | end 92 | 93 | document:write(Sift.Dictionary.merge(dataWithProduct, { 94 | recentPurchases = newRecentPurchases, 95 | })) 96 | 97 | local saveOk = document:save():await() 98 | if not saveOk then 99 | return Enum.ProductPurchaseDecision.NotProcessedYet 100 | end 101 | 102 | return Enum.ProductPurchaseDecision.PurchaseGranted 103 | end 104 | 105 | -- The ProcessReceipt callback must be set before the Players.PlayerAdded signal is fired, otherwise the player's 106 | -- existing receipts won't be processed when they join. 107 | MarketplaceService.ProcessReceipt = processReceipt 108 | 109 | Players.PlayerAdded:Connect(onPlayerAdded) 110 | Players.PlayerRemoving:Connect(onPlayerRemoving) 111 | 112 | for _, player in Players:GetPlayers() do 113 | onPlayerAdded(player) 114 | end 115 | -------------------------------------------------------------------------------- /src/init.luau: -------------------------------------------------------------------------------- 1 | --!strict 2 | 3 | local Internal = require(script.Internal) 4 | local PromiseTypes = require(script.PromiseTypes) 5 | 6 | local internal = Internal.new(true) 7 | 8 | type Migrate = (data: any, key: string) -> any 9 | type Migration = Migrate | { backwardsCompatible: boolean?, migrate: Migrate } 10 | 11 | export type DataStoreService = { 12 | GetDataStore: (name: string) -> GlobalDataStore, 13 | GetRequestBudgetForRequestType: (requestType: Enum.DataStoreRequestType) -> number, 14 | } 15 | 16 | export type PartialLapisConfig = { 17 | saveAttempts: number?, 18 | loadAttempts: number?, 19 | loadRetryDelay: number?, 20 | showRetryWarnings: boolean?, 21 | dataStoreService: DataStoreService?, 22 | [any]: nil, 23 | } 24 | 25 | export type CollectionOptions = { 26 | defaultData: T | (key: string) -> T, 27 | migrations: { Migration }?, 28 | validate: ((any) -> (boolean, string?))?, 29 | freezeData: boolean?, 30 | [any]: nil, 31 | } 32 | 33 | export type Collection = { 34 | load: (self: Collection, key: string, defaultUserIds: { number }?) -> PromiseTypes.TypedPromise>, 35 | read: (self: Collection, key: string) -> PromiseTypes.TypedPromise, 36 | remove: (self: Collection, key: string) -> PromiseTypes.TypedPromise<()>, 37 | } 38 | 39 | export type Document = { 40 | read: (self: Document) -> T, 41 | write: (self: Document, T) -> (), 42 | addUserId: (self: Document, userId: number) -> (), 43 | removeUserId: (self: Document, userId: number) -> (), 44 | save: (self: Document) -> PromiseTypes.TypedPromise<()>, 45 | close: (self: Document) -> PromiseTypes.TypedPromise<()>, 46 | beforeSave: (self: Document, callback: () -> ()) -> (), 47 | beforeClose: (self: Document, callback: () -> ()) -> (), 48 | } 49 | 50 | --[=[ 51 | @class Lapis 52 | ]=] 53 | local Lapis = {} 54 | 55 | --[=[ 56 | @interface PartialLapisConfig 57 | @within Lapis 58 | .saveAttempts number? -- Max save/close retry attempts 59 | .loadAttempts number? -- Max load retry attempts 60 | .loadRetryDelay number? -- Seconds between load attempts 61 | .showRetryWarnings boolean? -- Show warning on retry 62 | .dataStoreService (DataStoreService | table)? -- Useful for mocking DataStoreService, especially in a local place 63 | ]=] 64 | 65 | --[=[ 66 | @type Migration (data: any, key: string) -> any | { backwardsCompatible: boolean?, migrate: (data: any, key: string) -> any } 67 | @within Lapis 68 | ]=] 69 | 70 | --[=[ 71 | ```lua 72 | Lapis.setConfig({ 73 | saveAttempts = 10, 74 | showRetryWarnings = false, 75 | }) 76 | ``` 77 | 78 | ```lua 79 | -- The default config values: 80 | { 81 | saveAttempts = 5, 82 | loadAttempts = 20, 83 | loadRetryDelay = 1, 84 | showRetryWarnings = true, 85 | dataStoreService = DataStoreService, 86 | } 87 | ``` 88 | 89 | @param partialConfig PartialLapisConfig 90 | ]=] 91 | function Lapis.setConfig(partialConfig: PartialLapisConfig) 92 | internal.setConfig(partialConfig) 93 | end 94 | 95 | --[=[ 96 | @interface CollectionOptions 97 | @within Lapis 98 | .validate ((any) -> true | (false, string))? -- Takes a document's data and returns true on success or false and an error on fail. 99 | .defaultData T | (key: string) -> T -- If set to a function, it's called when a new document is created and is passed the key of the document. 100 | .freezeData boolean? -- If `true`, data will be deep frozen and can only be updated immutably by calling [`Document:write`](Document#write). Default: `true` 101 | .migrations { Migration }? -- Migrations take old data and return new data. Order is first to last. For more information, see: [Migrations](../docs/Migrations). 102 | ]=] 103 | 104 | --[=[ 105 | Creates a [Collection]. 106 | 107 | @param name string 108 | @param options CollectionOptions 109 | @return Collection 110 | ]=] 111 | function Lapis.createCollection(name: string, options: CollectionOptions): Collection 112 | return internal.createCollection(name, options) 113 | end 114 | 115 | return Lapis 116 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Lapis Changelog 2 | 3 | ## Unreleased Changes 4 | 5 | ## 0.3.4 - February 11, 2025 6 | * The document's key is now passed as a second argument to migrations. 7 | 8 | ## 0.3.3 - August 24, 2024 9 | * Files now use the `.luau` extension instead of `.lua`. ([#61]) 10 | * Switched wally realm to `shared`. This means Lapis can be used as a shared or server dependency. ([#62]) 11 | * `beforeClose` and `beforeSave` now throw an error if they yield. For more information, see the PR. ([#64]) 12 | * Added `Collection:remove` to remove the data from a document. ([#65]) 13 | * Session locks will now be removed from documents if `Document:close` fails for the following reasons: ([#66]) 14 | * `beforeSave`/`beforeClose` threw an error. 15 | * `validate` threw an error. 16 | * `validate` didn't return `true`. 17 | * `DataStoreKeyInfo:GetMetadata()` is now preserved. Before, it would be erased anytime a `Document` was saved or closed. ([#67]) 18 | 19 | [#61]: https://github.com/nezuo/lapis/pull/61 20 | [#62]: https://github.com/nezuo/lapis/pull/62 21 | [#64]: https://github.com/nezuo/lapis/pull/64 22 | [#65]: https://github.com/nezuo/lapis/pull/65 23 | [#66]: https://github.com/nezuo/lapis/pull/66 24 | [#67]: https://github.com/nezuo/lapis/pull/67 25 | 26 | ## 0.3.2 - August 6, 2024 27 | * Added `Collection:read` to view a document's data without editing or session locking it. ([#59]) 28 | * **IMPORTANT**: Fixed bug that resets a saved migration version that is ahead of the server's version and is backwards compatible. ([#60]) 29 | 30 | [#59]: https://github.com/nezuo/lapis/pull/59 31 | [#60]: https://github.com/nezuo/lapis/pull/60 32 | 33 | ## 0.3.1 - July 6, 2024 34 | * Added `Document:keyInfo()`. It returns the last updated `DataStoreKeyInfo` returned from loading, saving, or closing the document. ([#50]) 35 | * Migrations can now update the data mutably. Note: The value still needs to be returned from the migration function. ([#51]) 36 | * Gracefully handle `validate` throwing an error in `Collection:load`. Before it would keep retrying the load and spam the console with confusing errors. ([#52]) 37 | * Allow `defaultData` to be a function. The return value will be validated when a new document is created. ([#53]) 38 | * Migrations can now be marked as `backwardsCompatible`. This allows documents to be loaded on servers with an older version as long as they are compatible. 39 | For more information, see the [docs](https://nezuo.github.io/lapis/docs/Migrations#backwards-compatibility). ([#54]) 40 | * The `validate` option for collections is now optional. ([#56]) 41 | * Add `freezeData` option to collections to allow mutable updates to documents. ([#57]) 42 | 43 | [#50]: https://github.com/nezuo/lapis/pull/50 44 | [#51]: https://github.com/nezuo/lapis/pull/51 45 | [#52]: https://github.com/nezuo/lapis/pull/52 46 | [#53]: https://github.com/nezuo/lapis/pull/53 47 | [#54]: https://github.com/nezuo/lapis/pull/54 48 | [#56]: https://github.com/nezuo/lapis/pull/56 49 | [#57]: https://github.com/nezuo/lapis/pull/57 50 | 51 | ## 0.3.0 - April 14, 2024 52 | * **BREAKING CHANGE**: `Collection:load` no longer caches promises. Each call will now return a unique promise and attempt to load the document separately. This is to fix an edge case that can result in data loss. More information can be found in the pull request. ([#48]) 53 | 54 | [#48]: https://github.com/nezuo/lapis/pull/48 55 | 56 | ## 0.2.11 - April 9, 2024 57 | * Fix infinite yield in `game:BindToClose` caused by a document failing to load. ([#45]) 58 | 59 | [#45]: https://github.com/nezuo/lapis/pull/45 60 | 61 | ## 0.2.10 - March 25, 2024 62 | * `Document:load` now infinitely yields and doesn't load the document after `game:BindToClose` is called. If a document 63 | does load because UpdateAsync is called just before game close, it is automatically closed. ([#43]) 64 | 65 | [#43]: https://github.com/nezuo/lapis/pull/43 66 | 67 | ## 0.2.9 - January 1, 2024 68 | * `Document:close` no longer errors when called again and instead returns the original promise. ([#35]) 69 | * This is so it won't error when called from `PlayerRemoving` if `game:BindToClose` happens to run first. 70 | 71 | [#35]: https://github.com/nezuo/lapis/pull/35 72 | 73 | ## 0.2.8 - December 27, 2023 74 | * Removed internal compression code since compression is no longer planned ([#31]) 75 | * Data is no longer loaded if it doesn't pass the `validate` function. This means it won't be session locked and migrated. ([#32]) 76 | 77 | [#31]: https://github.com/nezuo/lapis/pull/31 78 | [#32]: https://github.com/nezuo/lapis/pull/32 79 | 80 | ## 0.2.7 - November 12, 2023 81 | * Add `Document:beforeSave` callback to make changes to a document before it saves ([#29]) 82 | 83 | [#29]: https://github.com/nezuo/lapis/pull/29 84 | 85 | ## 0.2.6 - October 24, 2023 86 | * Added types ([#24]) 87 | * Added `document:beforeClose` callback to make final changes to a document before it closes ([#25]) 88 | * This callback works even when the document is closed by `game:BindToClose`. 89 | * Added APIs to set a document's `DataStoreKeyInfo:GetUserIds()` ([#26]) 90 | * Changed `Collection:load(key: string)` to `Collection:load(key: string, defaultUserIds: {number}?)` 91 | * `defaultUserIds` only applies if it's the first time the document has ever been loaded. 92 | * Added `Document:addUserId(userId: number)` 93 | * Added `Document:removeUserId(userId: number)` 94 | 95 | [#24]: https://github.com/nezuo/lapis/pull/24 96 | [#25]: https://github.com/nezuo/lapis/pull/25 97 | [#26]: https://github.com/nezuo/lapis/pull/26 98 | 99 | ## 0.2.5 - September 8, 2023 100 | * Fix existing data not being frozen on load ([#20]) 101 | 102 | [#20]: https://github.com/nezuo/lapis/pull/20 103 | 104 | ## 0.2.4 - August 3, 2023 105 | * Fix `game:BindToClose` not waiting for documents to close 106 | 107 | ## 0.2.3 - July 19, 2023 108 | * Fix silly mistake where I don't return the collection from `createCollection` 109 | 110 | ## 0.2.2 - July 19, 2023 111 | * Remove write cooldown throttling since write cooldowns [were removed](https://devforum.roblox.com/t/removal-of-6s-cool-down-for-data-stores/2436230) ([#11]) 112 | * Fix save merging algorithm ([#13]) 113 | * Added new throttle queue which allows load/save/close requests from different keys to be processed at the same time ([#15]) 114 | 115 | [#11]: https://github.com/nezuo/lapis/pull/11 116 | [#13]: https://github.com/nezuo/lapis/pull/13 117 | [#15]: https://github.com/nezuo/lapis/pull/15 118 | 119 | ## 0.2.1 - June 10, 2023 120 | * Move TestEZ and DataStoreServiceMock to dev dependencies 121 | * Remove unused files from published package 122 | 123 | ## 0.2.0 - May 24, 2023 124 | * Renamed `Collection:openDocument` to `Collection:load` 125 | * Renamed `retryAttempts` config setting to `saveAttempts` 126 | * Renamed `acquireLockAttempts` config setting to `loadAttempts` 127 | * Renamed `acquireLockDelay` config setting to `loadRetryDelay` 128 | * Fixed edge case that allowed documents to load even when their migration version exceeded the server's latest migration 129 | -------------------------------------------------------------------------------- /src/Collection.luau: -------------------------------------------------------------------------------- 1 | local HttpService = game:GetService("HttpService") 2 | 3 | local Document = require(script.Parent.Document) 4 | local Error = require(script.Parent.Error) 5 | local freezeDeep = require(script.Parent.freezeDeep) 6 | local Migration = require(script.Parent.Migration) 7 | local Promise = require(script.Parent.Parent.Promise) 8 | local copyDeep = require(script.Parent.copyDeep) 9 | 10 | local LOCK_EXPIRE = 30 * 60 11 | 12 | --[=[ 13 | Collections are analagous to [GlobalDataStore]. 14 | 15 | @class Collection 16 | ]=] 17 | local Collection = {} 18 | Collection.__index = Collection 19 | 20 | function Collection.new(name, options, data, autoSave, config) 21 | if typeof(options.defaultData) ~= "function" and options.validate ~= nil then 22 | assert(options.validate(options.defaultData)) 23 | end 24 | 25 | local migrations = {} 26 | if options.migrations ~= nil then 27 | for _, migration in options.migrations do 28 | if typeof(migration) == "function" then 29 | table.insert(migrations, { migrate = migration }) 30 | else 31 | table.insert(migrations, migration) 32 | end 33 | end 34 | end 35 | options.migrations = migrations 36 | 37 | options.freezeData = if options.freezeData ~= nil then options.freezeData else true 38 | 39 | freezeDeep(options) 40 | 41 | return setmetatable({ 42 | dataStore = config:get("dataStoreService"):GetDataStore(name), 43 | options = options, 44 | data = data, 45 | autoSave = autoSave, 46 | }, Collection) 47 | end 48 | 49 | --[=[ 50 | Loads the document with `key`, migrates it, and session locks it. 51 | 52 | If specified, the document's `DataStoreKeyInfo:GetUserIds()` will be set to `defaultUserIds` if the document has 53 | never been loaded. 54 | 55 | @param key string 56 | @param defaultUserIds {number}? 57 | @return Promise 58 | ]=] 59 | function Collection:load(key, defaultUserIds) 60 | if self.autoSave.gameClosed then 61 | -- If game:BindToClose has been called, this infinitely yields so the document can't load. 62 | return Promise.new(function() end) 63 | end 64 | 65 | local lockId = HttpService:GenerateGUID(false) 66 | 67 | self.autoSave.ongoingLoads += 1 68 | 69 | return self.data 70 | :load(self.dataStore, key, function(value, keyInfo) 71 | if value == nil then 72 | local defaultData 73 | if typeof(self.options.defaultData) == "function" then 74 | local defaultOk, tailoredDefaultData = pcall(self.options.defaultData, key) 75 | if not defaultOk then 76 | return "fail", 77 | Error.new("DefaultDataThrew", `'defaultData' threw an error: {tailoredDefaultData}`) 78 | end 79 | 80 | if self.options.validate ~= nil then 81 | local validateOk, valid, message = pcall(self.options.validate, tailoredDefaultData) 82 | if not validateOk then 83 | return "fail", Error.new("ValidateThrew", `'validate' threw an error: {valid}`) 84 | elseif not valid then 85 | return "fail", Error.new("ValidateFailed", `Invalid data: {message}`) 86 | end 87 | end 88 | 89 | defaultData = copyDeep(tailoredDefaultData) 90 | else 91 | -- The data was validated when the collection was created. 92 | defaultData = if self.options.freezeData 93 | then self.options.defaultData 94 | else copyDeep(self.options.defaultData) 95 | end 96 | 97 | local data = { 98 | migrationVersion = #self.options.migrations, 99 | lastCompatibleVersion = Migration.getLastCompatibleVersion(self.options.migrations), 100 | lockId = lockId, 101 | data = defaultData, 102 | } 103 | 104 | return "succeed", data, defaultUserIds 105 | end 106 | 107 | if 108 | value.lockId ~= nil 109 | and (DateTime.now().UnixTimestampMillis - keyInfo.UpdatedTime) / 1000 < LOCK_EXPIRE 110 | then 111 | return "retry", Error.new("SessionLocked", "Could not acquire lock") 112 | end 113 | 114 | local savedVersion = value.migrationVersion 115 | 116 | local migrationOk, migrated, lastCompatibleVersion = Migration.migrate(self.options.migrations, value, key) 117 | if not migrationOk then 118 | return "fail", Error.new("MigrationError", migrated) 119 | end 120 | 121 | if self.options.validate ~= nil then 122 | local validateOk, valid, message = pcall(self.options.validate, migrated) 123 | if not validateOk then 124 | return "fail", Error.new("ValidateThrew", `'validate' threw an error: {valid}`) 125 | elseif not valid then 126 | return "fail", Error.new("ValidateFailed", `Invalid data: {message}`) 127 | end 128 | end 129 | 130 | local data = { 131 | migrationVersion = math.max(savedVersion, #self.options.migrations), 132 | lastCompatibleVersion = lastCompatibleVersion, 133 | lockId = lockId, 134 | data = migrated, 135 | } 136 | 137 | return "succeed", data, keyInfo:GetUserIds(), keyInfo:GetMetadata() 138 | end) 139 | :andThen(function(value, keyInfo) 140 | if value == "cancelled" then 141 | self.autoSave.ongoingLoads -= 1 142 | 143 | -- Infinitely yield because the load was cancelled by game:BindToClose. 144 | return Promise.new(function() end) 145 | end 146 | 147 | local data = value.data 148 | 149 | if self.options.freezeData then 150 | freezeDeep(data) 151 | end 152 | 153 | local document = Document.new(self, key, self.options.validate, lockId, data, keyInfo) 154 | 155 | self.autoSave:finishLoad(document) 156 | 157 | if self.autoSave.gameClosed then 158 | -- Infinitely yield because the document will automatically be closed. 159 | return Promise.new(function() end) 160 | end 161 | 162 | self.autoSave:addDocument(document) 163 | 164 | return document 165 | end) 166 | :catch(function(err) 167 | self.autoSave.ongoingLoads -= 1 168 | 169 | return Promise.reject(`DataStoreFailure({err.message})`) 170 | end) 171 | end 172 | 173 | --[=[ 174 | Reads the data of the document with `key` regardless of whether it is session locked. This is useful for viewing a 175 | document without editing or session locking it. The data gets migrated but not saved. 176 | 177 | If the document has never been loaded, the promise will return `nil`. 178 | 179 | [DataStoreGetOptions.UseCache](https://create.roblox.com/docs/reference/engine/classes/DataStoreGetOptions#UseCache) is disabled. 180 | 181 | @param key string 182 | @return Promise 183 | ]=] 184 | function Collection:read(key) 185 | return self.data:read(self.dataStore, key):andThen(function(value, keyInfo) 186 | if value == nil then 187 | return nil 188 | end 189 | 190 | local migrationOk, migrated = Migration.migrate(self.options.migrations, value, key) 191 | if not migrationOk then 192 | return Promise.reject(migrated) 193 | end 194 | 195 | if self.options.validate ~= nil then 196 | local validateOk, valid, message = pcall(self.options.validate, migrated) 197 | if not validateOk then 198 | return Promise.reject(`'validate' threw an error: {valid}`) 199 | elseif not valid then 200 | return Promise.reject(`Invalid data: {message}`) 201 | end 202 | end 203 | 204 | return value.data, keyInfo 205 | end) 206 | end 207 | 208 | --[=[ 209 | Removes the data of the document with `key`. 210 | 211 | If a document is open while `remove` is called, the open document will fail to save/close and the data will still be removed. 212 | 213 | @param key string 214 | @return Promise<()> 215 | ]=] 216 | function Collection:remove(key) 217 | return self.data:remove(self.dataStore, key) 218 | end 219 | 220 | return Collection 221 | -------------------------------------------------------------------------------- /src/Data/Throttle.luau: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | 3 | local Error = require(script.Parent.Parent.Error) 4 | local Promise = require(script.Parent.Parent.Parent.Promise) 5 | 6 | local GET_ASYNC_RETRY_ATTEMPTS = 5 7 | local GET_ASYNC_RETRY_DELAY = 1 8 | 9 | local REMOVE_ASYNC_RETRY_ATTEMPTS = 5 10 | local REMOVE_ASYNC_RETRY_DELAY = 1 11 | 12 | local getAsyncOptions = Instance.new("DataStoreGetOptions") 13 | getAsyncOptions.UseCache = false 14 | 15 | local function updateAsync(throttle, request) 16 | return Promise.new(function(resolve) 17 | local resultOutside, transformedOutside, keyInfo 18 | local ok, err = pcall(function() 19 | _, keyInfo = request.dataStore:UpdateAsync(request.key, function(...) 20 | if request.cancelOnGameClose and throttle.gameClosed then 21 | resultOutside = "cancelled" 22 | return nil 23 | end 24 | 25 | local result, transformed, userIds, metadata = request.transform(...) 26 | 27 | resultOutside = result 28 | transformedOutside = transformed 29 | 30 | if result == "succeed" then 31 | return transformed, userIds, metadata 32 | else 33 | return nil 34 | end 35 | end) 36 | end) 37 | 38 | if resultOutside == "cancelled" then 39 | resolve("cancelled") 40 | elseif not ok then 41 | resolve("retry", Error.new("RobloxApiError", err)) 42 | else 43 | resolve(resultOutside, transformedOutside, keyInfo) 44 | end 45 | end) 46 | end 47 | 48 | local function getAsync(request) 49 | return Promise.new(function(resolve) 50 | local ok, value, keyInfo = pcall(function() 51 | return request.dataStore:GetAsync(request.key, getAsyncOptions) 52 | end) 53 | 54 | resolve(ok, value, keyInfo) 55 | end) 56 | end 57 | 58 | local function removeAsync(request) 59 | return Promise.new(function(resolve) 60 | local ok, message = pcall(function() 61 | request.dataStore:RemoveAsync(request.key) 62 | end) 63 | 64 | resolve(ok, message) 65 | end) 66 | end 67 | 68 | local Throttle = {} 69 | Throttle.__index = Throttle 70 | 71 | function Throttle.new(config) 72 | return setmetatable({ 73 | config = config, 74 | updateAsyncQueue = {}, 75 | getAsyncQueue = {}, 76 | removeAsyncQueue = {}, 77 | gameClosed = false, 78 | }, Throttle) 79 | end 80 | 81 | function Throttle:getUpdateAsyncBudget() 82 | return self.config:get("dataStoreService"):GetRequestBudgetForRequestType(Enum.DataStoreRequestType.UpdateAsync) 83 | end 84 | 85 | function Throttle:getGetAsyncBudget() 86 | return self.config:get("dataStoreService"):GetRequestBudgetForRequestType(Enum.DataStoreRequestType.GetAsync) 87 | end 88 | 89 | function Throttle:getRemoveAsyncBudget() 90 | return self.config 91 | :get("dataStoreService") 92 | :GetRequestBudgetForRequestType(Enum.DataStoreRequestType.SetIncrementAsync) 93 | end 94 | 95 | function Throttle:start() 96 | local function retryRequest(request, err) 97 | request.attempts -= 1 98 | 99 | if request.attempts == 0 then 100 | request.reject(err) 101 | else 102 | if self.config:get("showRetryWarnings") then 103 | warn(`DataStore operation failed. Retrying...\nError: {err.message}`) 104 | end 105 | 106 | task.wait(request.retryDelay) 107 | end 108 | end 109 | 110 | local function updateUpdateAsync() 111 | for index = #self.updateAsyncQueue, 1, -1 do 112 | local request = self.updateAsyncQueue[index] 113 | 114 | if request.attempts == 0 then 115 | table.remove(self.updateAsyncQueue, index) 116 | elseif request.promise == nil and request.cancelOnGameClose and self.gameClosed then 117 | request.resolve("cancelled") 118 | table.remove(self.updateAsyncQueue, index) 119 | end 120 | end 121 | 122 | for _, request in self.updateAsyncQueue do 123 | if self:getUpdateAsyncBudget() == 0 then 124 | break 125 | end 126 | 127 | if request.promise ~= nil then 128 | continue 129 | end 130 | 131 | local promise = updateAsync(self, request):andThen(function(result, value, keyInfo) 132 | if result == "cancelled" then 133 | request.attempts = 0 134 | request.resolve("cancelled") 135 | elseif result == "succeed" then 136 | request.attempts = 0 137 | request.resolve(value, keyInfo) 138 | elseif result == "fail" then 139 | request.attempts = 0 140 | request.reject(value) 141 | elseif result == "retry" then 142 | retryRequest(request, value) 143 | else 144 | error("unreachable") 145 | end 146 | 147 | request.promise = nil 148 | end) 149 | 150 | if promise:getStatus() == Promise.Status.Started then 151 | request.promise = promise 152 | end 153 | end 154 | end 155 | 156 | local function updateGetAsync() 157 | for index = #self.getAsyncQueue, 1, -1 do 158 | local request = self.getAsyncQueue[index] 159 | 160 | if request.attempts == 0 then 161 | table.remove(self.getAsyncQueue, index) 162 | end 163 | end 164 | 165 | for _, request in self.getAsyncQueue do 166 | if self:getGetAsyncBudget() == 0 then 167 | break 168 | end 169 | 170 | if request.promise ~= nil then 171 | continue 172 | end 173 | 174 | local promise = getAsync(request):andThen(function(ok, value, keyInfo) 175 | if ok then 176 | request.attempts = 0 177 | request.resolve(value, keyInfo) 178 | else 179 | retryRequest(request, value) 180 | end 181 | 182 | request.promise = nil 183 | end) 184 | 185 | if promise:getStatus() == Promise.Status.Started then 186 | request.promise = promise 187 | end 188 | end 189 | end 190 | 191 | local function updateRemoveAsync() 192 | for index = #self.removeAsyncQueue, 1, -1 do 193 | local request = self.removeAsyncQueue[index] 194 | 195 | if request.attempts == 0 then 196 | table.remove(self.removeAsyncQueue, index) 197 | end 198 | end 199 | 200 | for _, request in self.removeAsyncQueue do 201 | if self:getRemoveAsyncBudget() == 0 then 202 | break 203 | end 204 | 205 | if request.promise ~= nil then 206 | continue 207 | end 208 | 209 | local promise = removeAsync(request):andThen(function(ok, message) 210 | if ok then 211 | request.attempts = 0 212 | request.resolve() 213 | else 214 | retryRequest(request, message) 215 | end 216 | 217 | request.promise = nil 218 | end) 219 | 220 | if promise:getStatus() == Promise.Status.Started then 221 | request.promise = promise 222 | end 223 | end 224 | end 225 | 226 | RunService.PostSimulation:Connect(function() 227 | updateUpdateAsync() 228 | updateGetAsync() 229 | updateRemoveAsync() 230 | end) 231 | end 232 | 233 | function Throttle:updateAsync(dataStore, key, transform, cancelOnGameClose, retryAttempts, retryDelay) 234 | return Promise.new(function(resolve, reject) 235 | table.insert(self.updateAsyncQueue, { 236 | dataStore = dataStore, 237 | key = key, 238 | transform = transform, 239 | attempts = retryAttempts, 240 | retryDelay = retryDelay, 241 | cancelOnGameClose = cancelOnGameClose, 242 | resolve = resolve, 243 | reject = reject, 244 | }) 245 | end) 246 | end 247 | 248 | function Throttle:getAsync(dataStore, key) 249 | return Promise.new(function(resolve, reject) 250 | table.insert(self.getAsyncQueue, { 251 | dataStore = dataStore, 252 | key = key, 253 | attempts = GET_ASYNC_RETRY_ATTEMPTS, 254 | retryDelay = GET_ASYNC_RETRY_DELAY, 255 | resolve = resolve, 256 | reject = reject, 257 | }) 258 | end) 259 | end 260 | 261 | function Throttle:removeAsync(dataStore, key) 262 | return Promise.new(function(resolve, reject) 263 | table.insert(self.removeAsyncQueue, { 264 | dataStore = dataStore, 265 | key = key, 266 | attempts = REMOVE_ASYNC_RETRY_ATTEMPTS, 267 | retryDelay = REMOVE_ASYNC_RETRY_DELAY, 268 | resolve = resolve, 269 | reject = reject, 270 | }) 271 | end) 272 | end 273 | 274 | return Throttle 275 | -------------------------------------------------------------------------------- /src/Document.luau: -------------------------------------------------------------------------------- 1 | local Error = require(script.Parent.Error) 2 | local freezeDeep = require(script.Parent.freezeDeep) 3 | local Promise = require(script.Parent.Parent.Promise) 4 | local noYield = require(script.Parent.noYield) 5 | 6 | local function runCallback(document, name, callback) 7 | if callback == nil then 8 | return Promise.resolve() 9 | end 10 | 11 | document.callingCallback = name 12 | 13 | return Promise.new(function(resolve, reject) 14 | local ok, message = pcall(noYield, callback) 15 | 16 | document.callingCallback = nil 17 | 18 | if not ok then 19 | reject(Error.new("BeforeSaveCloseCallbackThrew", `{name} callback threw error: {message}`)) 20 | else 21 | resolve() 22 | end 23 | end) 24 | end 25 | 26 | --[=[ 27 | @class Document 28 | ]=] 29 | local Document = {} 30 | Document.__index = Document 31 | 32 | function Document.new(collection, key, validate, lockId, data, keyInfo) 33 | return setmetatable({ 34 | collection = collection, 35 | key = key, 36 | validate = validate, 37 | lockId = lockId, 38 | data = data, 39 | userIds = keyInfo:GetUserIds(), 40 | lastKeyInfo = keyInfo, 41 | closed = false, 42 | }, Document) 43 | end 44 | 45 | --[=[ 46 | Returns the document's data. 47 | 48 | @return any 49 | ]=] 50 | function Document:read() 51 | return self.data 52 | end 53 | 54 | --[=[ 55 | Updates the document's cached data. This method doesn't save the data to the DataStore; it only modifies the 56 | document's in-memory data. 57 | 58 | This method should be used when performing immutable updates to the document's data. For mutable updates, the data 59 | can be directly modified: 60 | ```lua 61 | local data = document:read() 62 | data.coins += 100 63 | ``` 64 | 65 | :::warning 66 | Throws an error if the document was closed or if the data is invalid. 67 | ::: 68 | 69 | @param data any 70 | ]=] 71 | function Document:write(data) 72 | assert(not self.closed, "Cannot write to a closed document") 73 | 74 | if self.validate ~= nil then 75 | assert(self.validate(data)) 76 | end 77 | 78 | if self.collection.options.freezeData then 79 | freezeDeep(data) 80 | end 81 | 82 | self.data = data 83 | end 84 | 85 | --[=[ 86 | Adds a user id to the document's `DataStoreKeyInfo:GetUserIds()`. The change won't apply until the document is 87 | saved or closed. 88 | 89 | If the user id is already associated with the document the method won't do anything. 90 | 91 | @param userId number 92 | ]=] 93 | function Document:addUserId(userId) 94 | assert(not self.closed, "Cannot add user id to a closed document") 95 | 96 | if table.find(self.userIds, userId) == nil then 97 | table.insert(self.userIds, userId) 98 | end 99 | end 100 | 101 | --[=[ 102 | Removes a user id from the document's `DataStoreKeyInfo:GetUserIds()`. The change won't apply until the document is 103 | saved or closed. 104 | 105 | If the user id is not associated with the document the method won't do anything. 106 | 107 | @param userId number 108 | ]=] 109 | function Document:removeUserId(userId) 110 | assert(not self.closed, "Cannot remove user id from a closed document") 111 | 112 | local index = table.find(self.userIds, userId) 113 | 114 | if index ~= nil then 115 | table.remove(self.userIds, index) 116 | end 117 | end 118 | 119 | --[=[ 120 | Returns the last updated `DataStoreKeyInfo` returned from loading, saving, or closing the document. 121 | 122 | @return DataStoreKeyInfo 123 | ]=] 124 | function Document:keyInfo() 125 | return self.lastKeyInfo 126 | end 127 | 128 | --[=[ 129 | Saves the document's data. If the save is throttled and you call it multiple times, it will save only once with the 130 | latest data. 131 | 132 | Documents are saved automatically. This method is used mainly to handle developer product purchases 133 | (see the [example](../docs/DeveloperProduct)) or other situations requiring immediate saving. 134 | 135 | :::warning 136 | Throws an error if the document was closed. 137 | ::: 138 | 139 | :::warning 140 | If the beforeSave callback yields or errors, the returned promise will reject and the data will not be saved. 141 | ::: 142 | 143 | @return Promise<()> 144 | ]=] 145 | function Document:save() 146 | assert(not self.closed, "Cannot save a closed document") 147 | assert(self.callingCallback == nil, `Cannot save in {self.callingCallback} callback`) 148 | 149 | return runCallback(self, "beforeSave", self.beforeSaveCallback) 150 | :andThen(function() 151 | return self.collection.data 152 | :save(self.collection.dataStore, self.key, function(value, keyInfo) 153 | if value == nil then 154 | return "fail", Error.new("DocumentRemoved", "The document was removed") 155 | end 156 | 157 | if value.lockId ~= self.lockId then 158 | return "fail", Error.new("SessionLockStolen", "The session lock was stolen") 159 | end 160 | 161 | if not self.collection.options.freezeData and self.validate ~= nil then 162 | local validateOk, valid, message = pcall(self.validate, self.data) 163 | if not validateOk then 164 | return "fail", Error.new("ValidateThrew", `'validate' threw an error: {valid}`) 165 | elseif not valid then 166 | return "fail", Error.new("ValidateFailed", `Invalid data: {message}`) 167 | end 168 | end 169 | 170 | value.data = self.data 171 | 172 | return "succeed", value, self.userIds, keyInfo:GetMetadata() 173 | end) 174 | :andThen(function(_, keyInfo) 175 | self.lastKeyInfo = keyInfo 176 | end) 177 | end) 178 | :catch(function(err) 179 | return Promise.reject(`DataStoreFailure({err.message})`) 180 | end) 181 | end 182 | 183 | --[=[ 184 | Saves the document and removes the session lock. The document is unusable after calling. If a save is currently in 185 | progress it will close the document instead. 186 | 187 | If called again, it will return the promise from the original call. 188 | 189 | :::warning 190 | If the beforeSave or beforeClose callbacks yield or error, the returned promise will reject and the data will not be saved. 191 | ::: 192 | 193 | @return Promise<()> 194 | ]=] 195 | function Document:close() 196 | assert(self.callingCallback == nil, `Cannot close in {self.callingCallback} callback`) 197 | 198 | if self.closePromise == nil then 199 | self.closePromise = runCallback(self, "beforeSave", self.beforeSaveCallback) 200 | :andThenCall(runCallback, self, "beforeClose", self.beforeCloseCallback) 201 | :finally(function() 202 | self.closed = true 203 | 204 | self.collection.autoSave:removeDocument(self) 205 | end) 206 | :andThen(function() 207 | return self.collection.data:save(self.collection.dataStore, self.key, function(value, keyInfo) 208 | if value == nil then 209 | return "fail", Error.new("DocumentRemoved", "The document was removed") 210 | end 211 | 212 | if value.lockId ~= self.lockId then 213 | return "fail", Error.new("SessionLockStolen", "The session lock was stolen") 214 | end 215 | 216 | if not self.collection.options.freezeData and self.validate ~= nil then 217 | local validateOk, valid, message = pcall(self.validate, self.data) 218 | if not validateOk then 219 | return "fail", Error.new("ValidateThrew", `'validate' threw an error: {valid}`) 220 | elseif not valid then 221 | return "fail", Error.new("ValidateFailed", `Invalid data: {message}`) 222 | end 223 | end 224 | 225 | value.data = self.data 226 | value.lockId = nil 227 | 228 | return "succeed", value, self.userIds, keyInfo:GetMetadata() 229 | end) 230 | end) 231 | :andThen(function(_, keyInfo) 232 | self.lastKeyInfo = keyInfo 233 | end) 234 | :catch(function(err) 235 | if 236 | err.kind == "BeforeSaveCloseCallbackThrew" 237 | or err.kind == "ValidateThrew" 238 | or err.kind == "ValidateFailed" 239 | then 240 | self.collection.autoSave.ongoingRemoveLocks += 1 241 | 242 | self.collection.data 243 | :removeLock(self.collection.dataStore, self.key, self.lockId) 244 | :catch(function(removeLockErr) 245 | warn(`RemoveLockFailure({removeLockErr.message})`) 246 | end) 247 | :finally(function() 248 | self.collection.autoSave.ongoingRemoveLocks -= 1 249 | end) 250 | end 251 | 252 | return Promise.reject(`DataStoreFailure({err.message})`) 253 | end) 254 | end 255 | 256 | return self.closePromise 257 | end 258 | 259 | --[=[ 260 | Sets a callback that is run inside `document:save` and `document:close` before it saves. The document can be read and written to in the 261 | callback. 262 | 263 | The callback will run before the beforeClose callback inside of `document:close`. 264 | 265 | :::warning 266 | Throws an error if it was called previously. 267 | ::: 268 | 269 | @param callback () -> () 270 | ]=] 271 | function Document:beforeSave(callback) 272 | assert(self.beforeSaveCallback == nil, "Document:beforeSave can only be called once") 273 | 274 | self.beforeSaveCallback = callback 275 | end 276 | 277 | --[=[ 278 | Sets a callback that is run inside `document:close` before it saves. The document can be read and written to in the 279 | callback. 280 | 281 | :::warning 282 | Throws an error if it was called previously. 283 | ::: 284 | 285 | @param callback () -> () 286 | ]=] 287 | function Document:beforeClose(callback) 288 | assert(self.beforeCloseCallback == nil, "Document:beforeClose can only be called once") 289 | 290 | self.beforeCloseCallback = callback 291 | end 292 | 293 | return Document 294 | -------------------------------------------------------------------------------- /examples/developer-product-handling/test/src/DeveloperProductHandling.test.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | local ServerScriptService = game:GetService("ServerScriptService") 3 | 4 | local Promise = require(ReplicatedStorage.Packages.Promise) 5 | local Sift = require(ReplicatedStorage.Packages.Sift) 6 | local t = require(ReplicatedStorage.Packages.t) 7 | local DataStoreServiceMock = require(ServerScriptService.ServerPackages.DataStoreServiceMock) 8 | local LapisInternal = require(ReplicatedStorage.Packages.Lapis.Internal) 9 | local MarketplaceServiceMock = require(script.Parent.MarketplaceServiceMock) 10 | local PlayerMock = require(script.Parent.PlayerMock) 11 | 12 | local USER_ID = 999 13 | local PRODUCT_ID = 12345 14 | local FAILS_PRODUCT_ID = 54321 15 | local RECENT_PURCHASES_LIMIT = 3 16 | 17 | return function(x) 18 | local assertEqual = x.assertEqual 19 | 20 | local function setup(context) 21 | local dataStoreService = DataStoreServiceMock.manual() 22 | context.dataStoreService = dataStoreService 23 | 24 | context.lapis = LapisInternal.new(false) 25 | context.lapis.setConfig({ dataStoreService = dataStoreService, showRetryWarnings = false, loadAttempts = 1 }) 26 | 27 | context.write = function(name, key, data) 28 | local dataStore = dataStoreService.dataStores[name]["global"] 29 | 30 | dataStore:write(key, { 31 | migrationVersion = 0, 32 | data = data, 33 | }) 34 | end 35 | 36 | context.marketplaceService = MarketplaceServiceMock.new() 37 | 38 | local players = {} 39 | 40 | context.addPlayer = function(userId) 41 | local player = PlayerMock.new(userId) 42 | table.insert(players, player) 43 | context.onPlayerAdded(player) 44 | 45 | return player 46 | end 47 | 48 | context.removePlayer = function(player) 49 | table.remove(players, table.find(players, player)) 50 | player.Parent = nil 51 | context.onPlayerRemoving(player) 52 | end 53 | 54 | context.getPlayerByUserId = function(userId) 55 | for _, player in players do 56 | if userId == player.UserId then 57 | return player 58 | end 59 | end 60 | 61 | return nil 62 | end 63 | 64 | context.waitForDocument = function(player) 65 | while context.documents[player] == nil do 66 | task.wait() 67 | end 68 | 69 | return context.documents[player] 70 | end 71 | end 72 | 73 | x.beforeEach(function(context) 74 | setup(context) 75 | 76 | local DEFAULT_DATA = { coins = 100, recentPurchases = {} } 77 | local PRODUCTS = { 78 | [PRODUCT_ID] = function(data) 79 | return Sift.Dictionary.merge(data, { 80 | coins = data.coins + 100, 81 | }) 82 | end, 83 | [FAILS_PRODUCT_ID] = function() 84 | error("product failed to grant") 85 | end, 86 | } 87 | 88 | local collection = context.lapis.createCollection("PlayerData", { 89 | defaultData = DEFAULT_DATA, 90 | validate = t.strictInterface({ coins = t.integer, recentPurchases = t.array(t.string) }), 91 | }) 92 | 93 | local documents = {} 94 | context.documents = documents 95 | 96 | local function onPlayerAdded(player: Player) 97 | collection 98 | :load(`Player{player.UserId}`, { player.UserId }) 99 | :andThen(function(document) 100 | if player.Parent == nil then 101 | document:close():catch(warn) 102 | return 103 | end 104 | 105 | documents[player] = document 106 | end) 107 | :catch(function(message) 108 | warn(`Player {player.Name}'s data failed to load: {message}`) 109 | end) 110 | end 111 | 112 | local function onPlayerRemoving(player) 113 | local document = documents[player] 114 | 115 | if document ~= nil then 116 | documents[player] = nil 117 | document:close():catch(warn) 118 | end 119 | end 120 | 121 | context.marketplaceService.ProcessReceipt = function(receiptInfo) 122 | local player = context.getPlayerByUserId(receiptInfo.PlayerId) 123 | if player == nil then 124 | return Enum.ProductPurchaseDecision.NotProcessedYet 125 | end 126 | 127 | while documents[player] == nil and player.Parent ~= nil do 128 | task.wait() 129 | end 130 | 131 | local document = documents[player] 132 | if document == nil then 133 | return Enum.ProductPurchaseDecision.NotProcessedYet 134 | end 135 | 136 | local data = document:read() 137 | 138 | if table.find(data.recentPurchases, receiptInfo.PurchaseId) then 139 | local saveOk = document:save():await() 140 | 141 | if saveOk then 142 | return Enum.ProductPurchaseDecision.PurchaseGranted 143 | else 144 | return Enum.ProductPurchaseDecision.NotProcessedYet 145 | end 146 | end 147 | 148 | -- The product callback must not yield. Otherwise, it can return outdated data and overwrite new changes. 149 | local productOk, dataWithProduct = pcall(PRODUCTS[receiptInfo.ProductId], data) 150 | if not productOk then 151 | return Enum.ProductPurchaseDecision.NotProcessedYet 152 | end 153 | 154 | local newRecentPurchases = Sift.Array.push(data.recentPurchases, receiptInfo.PurchaseId) 155 | if #newRecentPurchases > RECENT_PURCHASES_LIMIT then 156 | newRecentPurchases = Sift.Array.shift(newRecentPurchases, #newRecentPurchases - RECENT_PURCHASES_LIMIT) 157 | end 158 | 159 | document:write(Sift.Dictionary.merge(dataWithProduct, { 160 | recentPurchases = newRecentPurchases, 161 | })) 162 | 163 | local saveOk = document:save():await() 164 | if not saveOk then 165 | return Enum.ProductPurchaseDecision.NotProcessedYet 166 | end 167 | 168 | return Enum.ProductPurchaseDecision.PurchaseGranted 169 | end 170 | 171 | context.onPlayerAdded = onPlayerAdded 172 | context.onPlayerRemoving = onPlayerRemoving 173 | end) 174 | 175 | x.test("happy path", function(context) 176 | local player = context.addPlayer(USER_ID) 177 | local purchaseDecision, purchaseId = context.marketplaceService:onProductPurchased(USER_ID, PRODUCT_ID) 178 | 179 | local data = context.documents[player]:read() 180 | 181 | assertEqual(purchaseDecision, Enum.ProductPurchaseDecision.PurchaseGranted) 182 | assertEqual(Sift.Dictionary.equalsDeep(data, { coins = 200, recentPurchases = { purchaseId } }), true) 183 | end) 184 | 185 | x.test("handles player leaving before ProcessReceipt", function(context) 186 | local player = context.addPlayer(USER_ID) 187 | 188 | context.removePlayer(player) 189 | 190 | local purchaseDecision = context.marketplaceService:onProductPurchased(USER_ID, PRODUCT_ID) 191 | 192 | assertEqual(purchaseDecision, Enum.ProductPurchaseDecision.NotProcessedYet) 193 | end) 194 | 195 | x.test("handles player leaving before document finishes loading", function(context) 196 | context.dataStoreService.yield:startYield() 197 | 198 | local player = context.addPlayer(USER_ID) 199 | 200 | local promise = Promise.try(function() 201 | return context.marketplaceService:onProductPurchased(USER_ID, PRODUCT_ID) 202 | end) 203 | 204 | context.removePlayer(player) 205 | 206 | local purchaseDecision = promise:expect() 207 | 208 | assertEqual(purchaseDecision, Enum.ProductPurchaseDecision.NotProcessedYet) 209 | 210 | context.dataStoreService.yield:stopYield() 211 | end) 212 | 213 | x.test("handles document load promise rejecting", function(context) 214 | context.dataStoreService.errors:addSimulatedErrors(1000) 215 | 216 | local player = context.addPlayer(USER_ID) 217 | 218 | local promise = Promise.try(function() 219 | return context.marketplaceService:onProductPurchased(USER_ID, PRODUCT_ID) 220 | end) 221 | 222 | task.wait(1) 223 | 224 | context.removePlayer(player) 225 | 226 | assertEqual(promise:expect(), Enum.ProductPurchaseDecision.NotProcessedYet) 227 | end) 228 | 229 | x.nested("handles product already existing in data", function() 230 | x.test("when receipt is already saved to datastore", function(context) 231 | context.write("PlayerData", `Player{USER_ID}`, { 232 | coins = 200, 233 | recentPurchases = { "abc" }, 234 | }) 235 | 236 | local player = context.addPlayer(USER_ID) 237 | 238 | local document = context.waitForDocument(player) 239 | local data = document:read() 240 | 241 | local purchaseDecision = context.marketplaceService:onProductPurchased(USER_ID, PRODUCT_ID, "abc") 242 | 243 | assertEqual(data, document:read()) -- Assert that data hasn't changed. 244 | assertEqual(purchaseDecision, Enum.ProductPurchaseDecision.PurchaseGranted) 245 | end) 246 | 247 | x.test("when receipt is not yet saved to datastore", function(context) 248 | local player = context.addPlayer(USER_ID) 249 | local document = context.waitForDocument(player) 250 | 251 | document:write({ 252 | coins = 200, 253 | recentPurchases = { "abc" }, 254 | }) 255 | 256 | local data = document:read() 257 | 258 | local purchaseDecision = context.marketplaceService:onProductPurchased(USER_ID, PRODUCT_ID, "abc") 259 | 260 | assertEqual(data, document:read()) -- Assert that data hasn't changed. 261 | assertEqual(purchaseDecision, Enum.ProductPurchaseDecision.PurchaseGranted) 262 | end) 263 | 264 | x.test("when datastores fail", function(context) 265 | local player = context.addPlayer(USER_ID) 266 | local document = context.waitForDocument(player) 267 | 268 | document:write({ coins = 200, recentPurchases = { "abc" } }) 269 | 270 | local data = document:read() 271 | 272 | context.dataStoreService.errors:addSimulatedErrors(1000) 273 | 274 | local purchaseDecision = context.marketplaceService:onProductPurchased(USER_ID, PRODUCT_ID, "abc") 275 | 276 | assertEqual(data, document:read()) -- Assert that data hasn't changed. 277 | assertEqual(purchaseDecision, Enum.ProductPurchaseDecision.NotProcessedYet) 278 | end) 279 | end) 280 | 281 | x.nested("granting product failures", function() 282 | x.test("handles product not existing", function(context) 283 | local player = context.addPlayer(USER_ID) 284 | local document = context.waitForDocument(player) 285 | local data = document:read() 286 | 287 | local purchaseDecision = context.marketplaceService:onProductPurchased(USER_ID, -1) 288 | 289 | assertEqual(data, document:read()) -- Assert that data hasn't changed. 290 | assertEqual(purchaseDecision, Enum.ProductPurchaseDecision.NotProcessedYet) 291 | end) 292 | 293 | x.test("handles product function failing", function(context) 294 | local player = context.addPlayer(USER_ID) 295 | local document = context.waitForDocument(player) 296 | local data = document:read() 297 | 298 | local purchaseDecision = context.marketplaceService:onProductPurchased(USER_ID, FAILS_PRODUCT_ID) 299 | 300 | assertEqual(data, document:read()) -- Assert that data hasn't changed. 301 | assertEqual(purchaseDecision, Enum.ProductPurchaseDecision.NotProcessedYet) 302 | end) 303 | end) 304 | 305 | x.nested("recent purchases", function() 306 | x.test("purchases are added to the end of the list", function(context) 307 | local player = context.addPlayer(USER_ID) 308 | local document = context.waitForDocument(player) 309 | 310 | local _, purchaseId1 = context.marketplaceService:onProductPurchased(USER_ID, PRODUCT_ID) 311 | local _, purchaseId2 = context.marketplaceService:onProductPurchased(USER_ID, PRODUCT_ID) 312 | 313 | local expected = { coins = 300, recentPurchases = { purchaseId1, purchaseId2 } } 314 | 315 | assertEqual(Sift.Dictionary.equalsDeep(document:read(), expected), true) 316 | end) 317 | 318 | x.test("purchases are removed from the start of the list", function(context) 319 | context.write("PlayerData", `Player{USER_ID}`, { 320 | coins = 100, 321 | recentPurchases = { "a", "b", "c", "d", "e" }, 322 | }) 323 | 324 | local player = context.addPlayer(USER_ID) 325 | 326 | local purchaseDecision, purchaseId = context.marketplaceService:onProductPurchased(USER_ID, PRODUCT_ID) 327 | 328 | local expected = { coins = 200, recentPurchases = { "d", "e", purchaseId } } 329 | 330 | assertEqual(purchaseDecision, Enum.ProductPurchaseDecision.PurchaseGranted) 331 | assertEqual(Sift.Dictionary.equalsDeep(context.documents[player]:read(), expected), true) 332 | end) 333 | end) 334 | 335 | x.test("handles product granted but save failing", function(context) 336 | local player = context.addPlayer(USER_ID) 337 | local document = context.waitForDocument(player) 338 | 339 | context.dataStoreService.errors:addSimulatedErrors(1000) 340 | 341 | local purchaseDecision, purchaseId = context.marketplaceService:onProductPurchased(USER_ID, PRODUCT_ID) 342 | 343 | local expected = { coins = 200, recentPurchases = { purchaseId } } 344 | 345 | assertEqual(purchaseDecision, Enum.ProductPurchaseDecision.NotProcessedYet) 346 | assertEqual(Sift.Dictionary.equalsDeep(document:read(), expected), true) 347 | end) 348 | end 349 | -------------------------------------------------------------------------------- /src/Document.test.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | local Promise = require(ReplicatedStorage.Packages.Promise) 4 | 5 | local function defaultOptions() 6 | return { 7 | validate = function(data) 8 | return typeof(data.foo) == "string", "foo must be a string" 9 | end, 10 | defaultData = { foo = "bar" }, 11 | } 12 | end 13 | 14 | return function(x) 15 | local assertEqual = x.assertEqual 16 | local shouldThrow = x.shouldThrow 17 | 18 | x.test("it should not merge close into save when save is running", function(context) 19 | local document = context.lapis.createCollection("collection", defaultOptions()):load("doc"):expect() 20 | 21 | -- It's not safe to merge saves when UpdateAsync is running. 22 | -- This will yield the UpdateAsync call until stopYield is called. 23 | context.dataStoreService.yield:startYield() 24 | 25 | local save = document:save() 26 | document:write({ foo = "new" }) 27 | local close = document:close() 28 | 29 | context.dataStoreService.yield:stopYield() 30 | 31 | Promise.all({ save, close }):expect() 32 | 33 | local saved = context.read("collection", "doc") 34 | 35 | -- If data.foo == "bar", that means the close was merged with the save when it wasn't safe to. 36 | assert(saved.data.foo == "new", "") 37 | end) 38 | 39 | x.test("it should merge pending saves", function(context) 40 | local document = context.lapis.createCollection("collection", defaultOptions()):load("doc"):expect() 41 | 42 | context.dataStoreService.yield:startYield() 43 | 44 | local ongoingSave = document:save() 45 | 46 | local pendingSave = document:save() 47 | local pendingClose = document:close() -- This should override the pending save. 48 | 49 | context.dataStoreService.yield:stopYield() 50 | 51 | local values = Promise.all({ ongoingSave, pendingSave }):expect() 52 | 53 | -- This stops the close if it wasn't merged. 54 | context.dataStoreService.yield:startYield() 55 | 56 | -- Since the following code is resumed by the save promise, we need to wait for the close promise to resolve. 57 | task.wait() 58 | 59 | pendingClose:now("save and close didn't merge"):expect() 60 | 61 | -- save and close should never resolve with a value. 62 | -- It's checked in this test to make sure it works with save merging. 63 | assert(#values == 0, "") 64 | 65 | local saved = context.read("collection", "doc") 66 | 67 | assert(saved.lockId == nil, "") 68 | end) 69 | 70 | x.test("saves data", function(context) 71 | local document = context.lapis.createCollection("12345", defaultOptions()):load("doc"):expect() 72 | 73 | document:write({ 74 | foo = "new value", 75 | }) 76 | 77 | document:save():expect() 78 | 79 | local saved = context.read("12345", "doc") 80 | 81 | assert(typeof(saved) == "table", "") 82 | assert(typeof(saved.lockId) == "string", "") 83 | assert(saved.data.foo == "new value", "") 84 | end) 85 | 86 | x.test("writes the data", function(context) 87 | local document = context.lapis.createCollection("1", defaultOptions()):load("doc"):expect() 88 | 89 | document:write({ 90 | foo = "baz", 91 | }) 92 | 93 | assert(document:read().foo == "baz", "") 94 | end) 95 | 96 | x.test("write throws if data doesn't validate", function(context) 97 | local document = context.lapis.createCollection("2", defaultOptions()):load("doc"):expect() 98 | 99 | shouldThrow(function() 100 | document:write({ 101 | foo = 5, 102 | }) 103 | end, "foo must be a string") 104 | end) 105 | 106 | x.test("methods throw when called on a closed document", function(context) 107 | local document = context.lapis.createCollection("5", defaultOptions()):load("doc"):expect() 108 | 109 | local promise = document:close() 110 | 111 | shouldThrow(function() 112 | document:write({}) 113 | end, "Cannot write to a closed document") 114 | 115 | shouldThrow(function() 116 | document:save() 117 | end, "Cannot save a closed document") 118 | 119 | shouldThrow(function() 120 | document:addUserId(1234) 121 | end, "Cannot add user id to a closed document") 122 | 123 | shouldThrow(function() 124 | document:removeUserId(1234) 125 | end, "Cannot remove user id from a closed document") 126 | 127 | promise:expect() 128 | end) 129 | 130 | x.test("close returns first promise when called again", function(context) 131 | local document = context.lapis.createCollection("col", defaultOptions()):load("doc"):expect() 132 | 133 | local promise = document:close() 134 | 135 | assertEqual(promise, document:close()) 136 | end) 137 | 138 | x.test("loads with default data", function(context) 139 | local document = context.lapis.createCollection("o", defaultOptions()):load("a"):expect() 140 | 141 | assert(document:read().foo == "bar", "") 142 | end) 143 | 144 | x.test("loads with existing data", function(context) 145 | local collection = context.lapis.createCollection("xyz", defaultOptions()) 146 | 147 | context.write("xyz", "xyz", { 148 | foo = "existing", 149 | }) 150 | 151 | local document = collection:load("xyz"):expect() 152 | 153 | assert(document:read().foo == "existing", "") 154 | end) 155 | 156 | x.test("freezes document data", function(context) 157 | local collection = context.lapis.createCollection("collection", { 158 | defaultData = {}, 159 | }) 160 | 161 | context.write("collection", "document", { a = { b = 1 } }) 162 | 163 | local document = collection:load("document"):expect() 164 | 165 | shouldThrow(function() 166 | document:read().a.b = 2 167 | end) 168 | 169 | document:write({ a = { b = 2 } }) 170 | 171 | shouldThrow(function() 172 | document:read().a.b = 3 173 | end) 174 | end) 175 | 176 | x.test("doesn't save data when the lock was stolen", function(context) 177 | local collection = context.lapis.createCollection("hi", defaultOptions()) 178 | 179 | local document = collection:load("hi"):expect() 180 | 181 | context.write("hi", "hi", { 182 | foo = "stolen", 183 | }, "stolenLockId") 184 | 185 | document:write({ 186 | foo = "qux", 187 | }) 188 | 189 | shouldThrow(function() 190 | document:save():expect() 191 | end, "The session lock was stolen") 192 | 193 | assert(context.read("hi", "hi").data.foo == "stolen", "") 194 | 195 | shouldThrow(function() 196 | document:close():expect() 197 | end, "The session lock was stolen") 198 | 199 | assert(context.read("hi", "hi").data.foo == "stolen", "") 200 | end) 201 | 202 | x.test("doesn't throw when the budget is exhausted", function(context) 203 | -- This makes sure the test doesn't pass by retyring after budget is added. 204 | context.lapis.setConfig({ loadAttempts = 1 }) 205 | 206 | local document = context.lapis.createCollection("bye", defaultOptions()):load("bye"):expect() 207 | 208 | context.dataStoreService.budget.budgets[Enum.DataStoreRequestType.GetAsync] = 0 209 | context.dataStoreService.budget.budgets[Enum.DataStoreRequestType.SetIncrementAsync] = 0 210 | context.dataStoreService.budget.budgets[Enum.DataStoreRequestType.UpdateAsync] = 0 211 | 212 | local promise = document:save() 213 | 214 | -- This wait is necessary so that the request is run by Throttle. 215 | task.wait(0.1) 216 | 217 | context.dataStoreService.budget:update() 218 | 219 | promise:expect() 220 | end) 221 | 222 | x.test(":save doesn't resolve with any value", function(context) 223 | local document = context.lapis.createCollection("12345", defaultOptions()):load("doc"):expect() 224 | 225 | local a, b = document:save():expect() 226 | 227 | assert(a == nil, "") 228 | assert(b == nil, "") 229 | end) 230 | 231 | x.test(":close doesn't resolve with any value", function(context) 232 | local document = context.lapis.createCollection("12345", defaultOptions()):load("doc"):expect() 233 | 234 | local a, b = document:close():expect() 235 | 236 | assert(a == nil, "") 237 | assert(b == nil, "") 238 | end) 239 | 240 | x.nested("Document:beforeSave", function() 241 | x.test("throws when yielding", function(context) 242 | local document = context.lapis.createCollection("collection", defaultOptions()):load("document"):expect() 243 | 244 | document:beforeSave(function() 245 | task.wait() 246 | end) 247 | 248 | shouldThrow(function() 249 | document:save():expect() 250 | end, "beforeSave callback threw error: thread is not yieldable") 251 | end) 252 | 253 | x.test("throws when setting twice", function(context) 254 | local document = context.lapis.createCollection("collection", defaultOptions()):load("document"):expect() 255 | 256 | document:beforeSave(function() end) 257 | 258 | shouldThrow(function() 259 | document:beforeSave(function() end) 260 | end, "Document:beforeSave can only be called once") 261 | end) 262 | 263 | x.test("throws when calling close in callback", function(context) 264 | local document = context.lapis.createCollection("collection", defaultOptions()):load("document"):expect() 265 | 266 | document:beforeSave(function() 267 | document:close() 268 | end) 269 | 270 | shouldThrow(function() 271 | document:close():expect() 272 | end, "beforeSave callback threw error") 273 | end) 274 | 275 | x.test("throws when calling save in callback", function(context) 276 | local document = context.lapis.createCollection("collection", defaultOptions()):load("document"):expect() 277 | 278 | document:beforeSave(function() 279 | document:save() 280 | end) 281 | 282 | shouldThrow(function() 283 | document:close():expect() 284 | end, "beforeSave callback threw error") 285 | end) 286 | 287 | x.test("saves new data in document:save", function(context) 288 | local document = context.lapis.createCollection("collection", defaultOptions()):load("document"):expect() 289 | 290 | document:beforeSave(function() 291 | document:read() -- This checks that read doesn't error in the callback. 292 | document:write({ foo = "new" }) 293 | end) 294 | 295 | document:save():expect() 296 | 297 | assertEqual(context.read("collection", "document").data.foo, "new") 298 | end) 299 | 300 | x.test("saves new data in document:close", function(context) 301 | local document = context.lapis.createCollection("collection", defaultOptions()):load("document"):expect() 302 | 303 | document:beforeSave(function() 304 | document:write({ foo = "new" }) 305 | end) 306 | 307 | document:close():expect() 308 | 309 | assertEqual(context.read("collection", "document").data.foo, "new") 310 | end) 311 | end) 312 | 313 | x.nested("Document:beforeClose", function() 314 | x.test("throws when yielding", function(context) 315 | local document = context.lapis.createCollection("collection", defaultOptions()):load("document"):expect() 316 | 317 | document:beforeClose(function() 318 | task.wait() 319 | end) 320 | 321 | shouldThrow(function() 322 | document:close():expect() 323 | end, "beforeClose callback threw error: thread is not yieldable") 324 | end) 325 | 326 | x.test("throws when setting twice", function(context) 327 | local document = context.lapis.createCollection("collection", defaultOptions()):load("document"):expect() 328 | 329 | document:beforeClose(function() end) 330 | 331 | shouldThrow(function() 332 | document:beforeClose(function() end) 333 | end, "Document:beforeClose can only be called once") 334 | end) 335 | 336 | x.test("throws when calling close in callback", function(context) 337 | local document = context.lapis.createCollection("collection", defaultOptions()):load("document"):expect() 338 | 339 | document:beforeClose(function() 340 | document:close() 341 | end) 342 | 343 | shouldThrow(function() 344 | document:close():expect() 345 | end, "beforeClose callback threw error") 346 | end) 347 | 348 | x.test("throws when calling save in callback", function(context) 349 | local document = context.lapis.createCollection("collection", defaultOptions()):load("document"):expect() 350 | 351 | document:beforeClose(function() 352 | document:save() 353 | end) 354 | 355 | shouldThrow(function() 356 | document:close():expect() 357 | end, "beforeClose callback threw error") 358 | end) 359 | 360 | x.test("closes document even if beforeClose errors", function(context) 361 | local collection = context.lapis.createCollection("collection", defaultOptions()) 362 | 363 | local promise = collection:load("document") 364 | local document = promise:expect() 365 | 366 | document:beforeClose(function() 367 | error("error") 368 | end) 369 | 370 | shouldThrow(function() 371 | document:close():expect() 372 | end) 373 | 374 | local secondPromise = collection:load("document") 375 | 376 | assert(secondPromise ~= promise, "collection:load should return a new promise") 377 | 378 | shouldThrow(function() 379 | document:write({ foo = "baz" }) 380 | end, "Cannot write to a closed document") 381 | 382 | -- Ignore the could not acquire lock error. 383 | secondPromise:catch(function() end) 384 | end) 385 | 386 | x.test("saves new data", function(context) 387 | local document = context.lapis.createCollection("collection", defaultOptions()):load("document"):expect() 388 | 389 | document:beforeClose(function() 390 | document:read() -- This checks that read doesn't error in the callback. 391 | 392 | document:write({ foo = "new" }) 393 | end) 394 | 395 | document:close():expect() 396 | 397 | assertEqual(context.read("collection", "document").data.foo, "new") 398 | end) 399 | 400 | x.test("beforeSave runs before beforeClose", function(context) 401 | local document = context.lapis.createCollection("collection", defaultOptions()):load("document"):expect() 402 | 403 | local order = "" 404 | 405 | document:beforeSave(function() 406 | order ..= "s" 407 | end) 408 | 409 | document:beforeClose(function() 410 | order ..= "c" 411 | end) 412 | 413 | document:close():expect() 414 | 415 | assertEqual(order, "sc") 416 | end) 417 | 418 | x.nested("keyInfo", function() 419 | x.test("gets load key info", function(context) 420 | local collection = context.lapis.createCollection("collection", defaultOptions()) 421 | 422 | local before = context.getKeyInfo("collection", "document") 423 | local document = collection:load("document"):expect() 424 | local keyInfo = document:keyInfo() 425 | 426 | assert(before ~= keyInfo, "") 427 | assert(typeof(keyInfo) == "table", "") 428 | assert(keyInfo.Version == "0", "") 429 | end) 430 | 431 | x.test("updating user ids shouldn't affect key info", function(context) 432 | local collection = context.lapis.createCollection("collection", defaultOptions()) 433 | 434 | local document = collection:load("document"):expect() 435 | local keyInfo = document:keyInfo() 436 | 437 | document:addUserId(123) 438 | 439 | assertEqual(#keyInfo:GetUserIds(), 0) 440 | end) 441 | 442 | x.test("key info is updated after :save", function(context) 443 | local document = 444 | context.lapis.createCollection("collection", defaultOptions()):load("document"):expect() 445 | local keyInfo = document:keyInfo() 446 | 447 | document:save():expect() 448 | 449 | local newKeyInfo = document:keyInfo() 450 | 451 | assert(keyInfo ~= newKeyInfo, "") 452 | assert(newKeyInfo.Version == "1", "") 453 | end) 454 | 455 | x.test("key info is updated after :close", function(context) 456 | local document = 457 | context.lapis.createCollection("collection", defaultOptions()):load("document"):expect() 458 | local keyInfo = document:keyInfo() 459 | 460 | document:close():expect() 461 | 462 | local newKeyInfo = document:keyInfo() 463 | 464 | assert(keyInfo ~= newKeyInfo, "") 465 | assert(newKeyInfo.Version == "1", "") 466 | end) 467 | end) 468 | end) 469 | end 470 | -------------------------------------------------------------------------------- /src/init.test.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | local RunService = game:GetService("RunService") 3 | 4 | local DataStoreServiceMock = require(ReplicatedStorage.DevPackages.DataStoreServiceMock) 5 | local Internal = require(script.Parent.Internal) 6 | local Promise = require(script.Parent.Parent.Promise) 7 | 8 | local function defaultOptions() 9 | return { 10 | validate = function(data) 11 | return typeof(data.apples) == "number", "apples should be a number" 12 | end, 13 | defaultData = { 14 | apples = 20, 15 | }, 16 | } 17 | end 18 | 19 | return function(x) 20 | local assertEqual = x.assertEqual 21 | local shouldThrow = x.shouldThrow 22 | 23 | x.beforeEach(function(context) 24 | local dataStoreService = DataStoreServiceMock.manual() 25 | 26 | context.dataStoreService = dataStoreService 27 | 28 | -- We want requests to overflow the throttle queue so that they result in errors. 29 | dataStoreService.budget:setMaxThrottleQueueSize(0) 30 | 31 | context.lapis = Internal.new(false) 32 | context.lapis.setConfig({ dataStoreService = dataStoreService, showRetryWarnings = false }) 33 | 34 | context.write = function(name, key, data, lockId, userIds, metadata) 35 | local dataStore = dataStoreService.dataStores[name]["global"] 36 | 37 | dataStore:write(key, { 38 | migrationVersion = 0, 39 | lockId = lockId, 40 | data = data, 41 | }, userIds, metadata) 42 | end 43 | 44 | context.read = function(name, key) 45 | return dataStoreService.dataStores[name]["global"].data[key] 46 | end 47 | 48 | context.expectUnlocked = function(name, key) 49 | local data = dataStoreService.dataStores[name]["global"].data[key] 50 | 51 | if data ~= nil and data.lockId ~= nil then 52 | error("Document is locked") 53 | end 54 | end 55 | 56 | context.expectLocked = function(name, key) 57 | local data = dataStoreService.dataStores[name]["global"].data[key] 58 | 59 | if data == nil or data.lockId == nil then 60 | error("Document is not locked") 61 | end 62 | end 63 | 64 | context.expectUserIds = function(name, key, targetUserIds) 65 | local keyInfo = dataStoreService.dataStores[name]["global"].keyInfos[key] 66 | 67 | local currentUserIds = if keyInfo ~= nil then keyInfo:GetUserIds() else {} 68 | 69 | if #currentUserIds ~= #targetUserIds then 70 | error("Incorrect user ids length") 71 | end 72 | 73 | for index, value in targetUserIds do 74 | if currentUserIds[index] ~= value then 75 | error("Invalid user id") 76 | end 77 | end 78 | end 79 | 80 | context.getKeyInfo = function(name, key) 81 | return dataStoreService.dataStores[name]["global"].keyInfos[key] 82 | end 83 | end) 84 | 85 | x.test("throws when setting invalid config key", function(context) 86 | shouldThrow(function() 87 | context.lapis.setConfig({ 88 | foo = true, 89 | }) 90 | end, 'Invalid config key "foo"') 91 | end) 92 | 93 | x.test("throws when creating a duplicate collection", function(context) 94 | context.lapis.createCollection("foo", defaultOptions()) 95 | 96 | shouldThrow(function() 97 | context.lapis.createCollection("foo", defaultOptions()) 98 | end, 'Collection "foo" already exists') 99 | end) 100 | 101 | x.test("freezes default data", function(context) 102 | local defaultData = { a = { b = { c = 5 } } } 103 | 104 | context.lapis.createCollection("baz", { 105 | defaultData = defaultData, 106 | }) 107 | 108 | shouldThrow(function() 109 | defaultData.a.b.c = 8 110 | end) 111 | end) 112 | 113 | x.test("validates default data as a table", function(context) 114 | shouldThrow(function() 115 | context.lapis.createCollection("bar", { 116 | validate = function() 117 | return false, "data is invalid" 118 | end, 119 | }) 120 | end, "data is invalid") 121 | end) 122 | 123 | x.test("handles default data erroring", function(context) 124 | local collection = context.lapis.createCollection("collection", { 125 | defaultData = function() 126 | error("foo") 127 | end, 128 | }) 129 | 130 | shouldThrow(function() 131 | collection:load("document"):expect() 132 | end, "'defaultData' threw an error", "foo") 133 | end) 134 | 135 | x.test("validates default data as a function", function(context) 136 | local collection = context.lapis.createCollection("collection", { 137 | defaultData = function() 138 | return {} 139 | end, 140 | validate = function() 141 | return false, "foo" 142 | end, 143 | }) 144 | 145 | shouldThrow(function() 146 | collection:load("document"):expect() 147 | end, "Invalid data:", "foo") 148 | end) 149 | 150 | x.test("default data function should set default data", function(context) 151 | local collection = context.lapis.createCollection("collection", { 152 | defaultData = function() 153 | return "default" 154 | end, 155 | }) 156 | 157 | local document = collection:load("document"):expect() 158 | 159 | assertEqual(document:read(), "default") 160 | end) 161 | 162 | x.test("should pass key to default data", function(context) 163 | local key 164 | local collection = context.lapis.createCollection("collection", { 165 | defaultData = function(passed) 166 | key = passed 167 | return {} 168 | end, 169 | }) 170 | 171 | collection:load("document"):expect() 172 | assertEqual(key, "document") 173 | end) 174 | 175 | x.test("default data function should deep copy data", function(context) 176 | local returned = { {} } 177 | local collection = context.lapis.createCollection("collection", { 178 | defaultData = function() 179 | return returned 180 | end, 181 | }) 182 | 183 | local document = collection:load("document"):expect() 184 | 185 | assert(document:read() ~= returned, "") 186 | assert(document:read()[1] ~= returned[1], "") 187 | end) 188 | 189 | x.test("default data function should freeze data", function(context) 190 | local collection = context.lapis.createCollection("collection", { 191 | defaultData = function() 192 | return {} 193 | end, 194 | }) 195 | 196 | local document = collection:load("document"):expect() 197 | 198 | shouldThrow(function() 199 | document:read().foo = true 200 | end, "readonly") 201 | end) 202 | 203 | x.test("handles validate erroring", function(context) 204 | local created = false 205 | 206 | local collection = context.lapis.createCollection("collection", { 207 | validate = function() 208 | if created then 209 | error("foo") 210 | else 211 | return true 212 | end 213 | end, 214 | }) 215 | 216 | created = true 217 | 218 | context.write("collection", "document", {}) 219 | 220 | shouldThrow(function() 221 | collection:load("document"):expect() 222 | end, "'validate' threw an error", "foo") 223 | end) 224 | 225 | x.test("should not override data if validation fails", function(context) 226 | local collection = context.lapis.createCollection("collection", defaultOptions()) 227 | 228 | context.write("collection", "doc", { apples = "string" }) 229 | 230 | local old = context.read("collection", "doc") 231 | 232 | shouldThrow(function() 233 | collection:load("doc"):expect() 234 | end, "apples should be a number") 235 | 236 | assertEqual(old, context.read("collection", "doc")) 237 | end) 238 | 239 | x.test("should session lock the document", function(context) 240 | local collection = context.lapis.createCollection("collection", defaultOptions()) 241 | local document = collection:load("doc"):expect() 242 | 243 | local otherLapis = Internal.new(false) 244 | otherLapis.setConfig({ dataStoreService = context.dataStoreService, loadAttempts = 1 }) 245 | 246 | local otherCollection = otherLapis.createCollection("collection", defaultOptions()) 247 | 248 | shouldThrow(function() 249 | otherCollection:load("doc"):expect() 250 | end, "Could not acquire lock") 251 | 252 | -- It should keep the session lock when saved. 253 | document:save():expect() 254 | 255 | shouldThrow(function() 256 | otherCollection:load("doc"):expect() 257 | end, "Could not acquire lock") 258 | 259 | -- It should remove the session lock when closed. 260 | document:close():expect() 261 | 262 | otherCollection:load("doc"):expect() 263 | end) 264 | 265 | x.test("load should retry when document is session locked", function(context) 266 | local collection = context.lapis.createCollection("collection", defaultOptions()) 267 | local document = collection:load("doc"):expect() 268 | 269 | local otherLapis = Internal.new(false) 270 | otherLapis.setConfig({ 271 | dataStoreService = context.dataStoreService, 272 | loadAttempts = 4, 273 | loadRetryDelay = 0.5, 274 | showRetryWarnings = false, 275 | }) 276 | 277 | local otherCollection = otherLapis.createCollection("collection", defaultOptions()) 278 | local promise = otherCollection:load("doc") 279 | 280 | -- Wait for the document to attempt to load once. 281 | task.wait(0.1) 282 | 283 | -- Remove the sesssion lock. 284 | document:close():expect() 285 | 286 | promise:expect() 287 | end) 288 | 289 | x.test("second load should fail because of session lock", function(context) 290 | local collection = context.lapis.createCollection("collection", defaultOptions()) 291 | 292 | context.lapis.setConfig({ loadAttempts = 1 }) 293 | 294 | local first = collection:load("document") 295 | local second = collection:load("document") 296 | 297 | first:expect() 298 | 299 | shouldThrow(function() 300 | second:expect() 301 | end, "Could not acquire lock") 302 | end) 303 | 304 | x.test("load returns a new promise when first load fails", function(context) 305 | context.lapis.setConfig({ loadAttempts = 1 }) 306 | context.dataStoreService.errors:addSimulatedErrors(1) 307 | 308 | local collection = context.lapis.createCollection("ghi", defaultOptions()) 309 | 310 | local promise1 = collection:load("ghi") 311 | 312 | shouldThrow(function() 313 | promise1:expect() 314 | end) 315 | 316 | local promise2 = collection:load("ghi") 317 | 318 | assert(promise1 ~= promise2, "load should return new promise") 319 | 320 | promise2:expect() 321 | end) 322 | 323 | x.nested("migrations", function() 324 | x.test("migrates the data", function(context) 325 | local collection = context.lapis.createCollection("migration", { 326 | validate = function(value) 327 | return value == "newData", "value does not equal newData" 328 | end, 329 | defaultData = "newData", 330 | migrations = { 331 | function() 332 | return "newData" 333 | end, 334 | }, 335 | }) 336 | 337 | context.write("migration", "migration", "data") 338 | 339 | collection:load("migration"):expect() 340 | 341 | local readData = collection:read("migration"):expect() 342 | 343 | assertEqual(readData, "newData") 344 | end) 345 | 346 | x.test("error is thrown if a migration returns nil", function(context) 347 | local collection = context.lapis.createCollection("collection", { 348 | defaultData = {}, 349 | migrations = { 350 | function() end, 351 | }, 352 | }) 353 | 354 | context.write("collection", "document", {}) 355 | 356 | shouldThrow(function() 357 | collection:load("document"):expect() 358 | end, "Migration 1 returned 'nil'") 359 | 360 | shouldThrow(function() 361 | collection:read("document"):expect() 362 | end, "Migration 1 returned 'nil'") 363 | end) 364 | 365 | x.test("passes key to migrations", function(context) 366 | local passedKey 367 | local collection = context.lapis.createCollection("collection", { 368 | defaultData = "newData", 369 | migrations = { 370 | function(_, key) 371 | passedKey = key 372 | return "newData" 373 | end, 374 | }, 375 | }) 376 | 377 | context.write("collection", "key", "data") 378 | 379 | collection:read("key"):expect() 380 | assertEqual(passedKey, "key") 381 | passedKey = nil 382 | 383 | collection:load("key"):expect() 384 | assertEqual(passedKey, "key") 385 | end) 386 | 387 | x.test("migrations should allow mutable updates", function(context) 388 | local collection = context.lapis.createCollection("collection", { 389 | validate = function(value) 390 | return typeof(value.coins) == "number" 391 | end, 392 | defaultData = { coins = 0 }, 393 | migrations = { 394 | function(old) 395 | old.coins = 0 396 | 397 | return old 398 | end, 399 | function(old) 400 | old.coins = 100 401 | 402 | return old 403 | end, 404 | }, 405 | }) 406 | 407 | context.write("collection", "document", {}) 408 | 409 | local document = collection:load("document"):expect() 410 | 411 | assertEqual(document:read().coins, 100) 412 | end) 413 | 414 | x.test("data should be frozen after a migration", function(context) 415 | local collection = context.lapis.createCollection("collection", { 416 | validate = function(value) 417 | return typeof(value.coins) == "number" 418 | end, 419 | defaultData = { coins = 0 }, 420 | migrations = { 421 | function(old) 422 | old.coins = 0 423 | return old 424 | end, 425 | }, 426 | }) 427 | 428 | context.write("collection", "document", {}) 429 | 430 | local document = collection:load("document"):expect() 431 | 432 | shouldThrow(function() 433 | document:read().coins = 100 434 | end, "readonly") 435 | end) 436 | 437 | x.test("migrations should work with tables and functions", function(context) 438 | local collection = context.lapis.createCollection("collection", { 439 | defaultData = "a", 440 | migrations = { 441 | { 442 | backwardsCompatible = false, 443 | migrate = function(old) 444 | return old 445 | end, 446 | }, 447 | function(old) 448 | return old 449 | end, 450 | }, 451 | }) 452 | 453 | local dataStore = context.dataStoreService.dataStores.collection.global 454 | dataStore:write("document", { 455 | migrationVersion = 0, 456 | data = "a", 457 | }) 458 | 459 | collection:load("document"):expect() 460 | end) 461 | 462 | x.nested("saved version ahead", function() 463 | x.test( 464 | "throws when migration version is ahead of latest version and is not backwards compatible", 465 | function(context) 466 | local collection = context.lapis.createCollection("collection", { 467 | defaultData = "a", 468 | migrations = { 469 | function(old) 470 | return old 471 | end, 472 | }, 473 | }) 474 | 475 | local dataStore = context.dataStoreService.dataStores.collection.global 476 | dataStore:write("document", { 477 | migrationVersion = 2, 478 | data = "b", 479 | }) 480 | 481 | shouldThrow(function() 482 | collection:load("document"):expect() 483 | end, "Saved migration version 2 is not backwards compatible with version 1") 484 | 485 | shouldThrow(function() 486 | collection:read("document"):expect() 487 | end, "Saved migration version 2 is not backwards compatible with version 1") 488 | end 489 | ) 490 | 491 | x.test("default data gets lastCompatibleVersion", function(context) 492 | local migrate = function(old) 493 | return old 494 | end 495 | 496 | local collection = context.lapis.createCollection("collection", { 497 | defaultData = "a", 498 | migrations = { 499 | { migrate = migrate, backwardsCompatible = true }, 500 | }, 501 | }) 502 | 503 | collection:load("document"):expect():close():expect() 504 | 505 | local otherLapis = Internal.new(false) 506 | otherLapis.setConfig({ dataStoreService = context.dataStoreService, loadAttempts = 1 }) 507 | 508 | local otherCollection = otherLapis.createCollection("collection", { 509 | defaultData = "a", 510 | }) 511 | 512 | -- This would error if lastCompatibleVersion = 0 wasn't saved. 513 | otherCollection:load("document"):expect() 514 | end) 515 | 516 | x.test("handles lastCompatibleVersion == nil", function(context) 517 | local collection = context.lapis.createCollection("collection", { 518 | defaultData = "a", 519 | }) 520 | 521 | local dataStore = context.dataStoreService.dataStores.collection.global 522 | dataStore:write("document", { 523 | migrationVersion = 1, 524 | data = "b", 525 | }) 526 | 527 | shouldThrow(function() 528 | collection:load("document"):expect() 529 | end, "Saved migration version 1 is not backwards compatible with version 0") 530 | 531 | shouldThrow(function() 532 | collection:read("document"):expect() 533 | end, "Saved migration version 1 is not backwards compatible with version 0") 534 | end) 535 | 536 | x.test("migration saves lastCompatibleVersion", function(context) 537 | local function migrate(old) 538 | return old 539 | end 540 | 541 | local collection = context.lapis.createCollection("collection", { 542 | defaultData = "a", 543 | migrations = { 544 | { migrate = migrate, backwardsCompatible = false }, 545 | { migrate = migrate, backwardsCompatible = true }, 546 | { migrate = migrate, backwardsCompatible = true }, 547 | }, 548 | }) 549 | 550 | local dataStore = context.dataStoreService.dataStores.collection.global 551 | dataStore:write("document", { 552 | migrationVersion = 0, 553 | data = "b", 554 | }) 555 | 556 | collection:load("document"):expect():close():expect() 557 | 558 | local lapisWithV0 = Internal.new(false) 559 | lapisWithV0.setConfig({ dataStoreService = context.dataStoreService, loadAttempts = 1 }) 560 | 561 | local collectionWithV0 = lapisWithV0.createCollection("collection", { 562 | defaultData = "a", 563 | }) 564 | 565 | shouldThrow(function() 566 | collectionWithV0:load("document"):expect() 567 | end, "Saved migration version 3 is not backwards compatible with version 0") 568 | 569 | local lapisWithV1 = Internal.new(false) 570 | lapisWithV1.setConfig({ dataStoreService = context.dataStoreService, loadAttempts = 1 }) 571 | 572 | local collectionWithV1 = lapisWithV1.createCollection("collection", { 573 | defaultData = "a", 574 | migrations = { 575 | { migrate = migrate, backwardsCompatible = false }, 576 | { migrate = migrate, backwardsCompatible = true }, 577 | }, 578 | }) 579 | 580 | -- This shouldn't error because v3 is backwards compatible with v1. 581 | collectionWithV1:load("document"):expect() 582 | end) 583 | 584 | x.test("keeps saved version", function(context) 585 | local collection = context.lapis.createCollection("collection", { 586 | defaultData = "a", 587 | }) 588 | 589 | local dataStore = context.dataStoreService.dataStores.collection.global 590 | dataStore:write("document", { 591 | lastCompatibleVersion = 0, 592 | migrationVersion = 1, 593 | data = "b", 594 | }) 595 | 596 | local document = collection:load("document"):expect() 597 | 598 | assertEqual(context.read("collection", "document").migrationVersion, 1) 599 | 600 | document:save("document"):expect() 601 | 602 | assertEqual(context.read("collection", "document").migrationVersion, 1) 603 | 604 | document:close("document"):expect() 605 | 606 | assertEqual(context.read("collection", "document").migrationVersion, 1) 607 | end) 608 | end) 609 | end) 610 | 611 | x.test("closing and immediately opening should return a new document", function(context) 612 | local collection = context.lapis.createCollection("ccc", defaultOptions()) 613 | 614 | local document = collection:load("doc"):expect() 615 | 616 | local close = document:close() 617 | local open = collection:load("doc") 618 | 619 | close:expect() 620 | 621 | local newDocument = open:expect() 622 | 623 | assert(newDocument ~= document, "") 624 | end) 625 | 626 | x.test("closes all document on game:BindToClose", function(context) 627 | local collection = context.lapis.createCollection("collection", defaultOptions()) 628 | 629 | local one = collection:load("one"):expect() 630 | local two = collection:load("two"):expect() 631 | local three = collection:load("three"):expect() 632 | 633 | context.dataStoreService.yield:startYield() 634 | 635 | local thread = task.spawn(function() 636 | context.lapis.autoSave:onGameClose() 637 | end) 638 | 639 | assert(coroutine.status(thread) == "suspended", "onGameClose didn't wait for the documents to finish closing") 640 | 641 | -- Verify each document has been closed. 642 | for _, document in { one, two, three } do 643 | shouldThrow(function() 644 | document:save():expect() 645 | end, "Cannot save a closed document") 646 | end 647 | 648 | context.dataStoreService.yield:stopYield() 649 | 650 | -- Wait for documents to finish saving. 651 | task.wait(0.1) 652 | 653 | assert(coroutine.status(thread) == "dead", "") 654 | end) 655 | 656 | x.nested("user ids", function() 657 | x.test("it uses defaultUserIds on first load", function(context) 658 | local collection = context.lapis.createCollection("collection", defaultOptions()) 659 | 660 | local document = collection:load("document", { 123 }):expect() 661 | context.expectUserIds("collection", "document", { 123 }) 662 | document:close():expect() 663 | context.expectUserIds("collection", "document", { 123 }) 664 | 665 | -- Since the document has already been created, the defaultUserIds should not override the saved ones. 666 | document = collection:load("document", { 321 }):expect() 667 | context.expectUserIds("collection", "document", { 123 }) 668 | document:close():expect() 669 | context.expectUserIds("collection", "document", { 123 }) 670 | end) 671 | 672 | x.test("adds new user ids", function(context) 673 | local collection = context.lapis.createCollection("collection", defaultOptions()) 674 | 675 | local document = collection:load("document", {}):expect() 676 | 677 | document:addUserId(111) 678 | document:addUserId(111) -- It should not add this user id twice. 679 | document:addUserId(222) 680 | 681 | context.expectUserIds("collection", "document", {}) 682 | 683 | document:save():expect() 684 | 685 | context.expectUserIds("collection", "document", { 111, 222 }) 686 | 687 | document:close():expect() 688 | 689 | context.expectUserIds("collection", "document", { 111, 222 }) 690 | end) 691 | 692 | x.test("removes new user ids", function(context) 693 | local collection = context.lapis.createCollection("collection", defaultOptions()) 694 | 695 | local document = collection:load("document", { 333, 444, 555 }):expect() 696 | 697 | document:removeUserId(111) -- It should do nothing if the user id doesn't exist. 698 | document:removeUserId(444) 699 | 700 | context.expectUserIds("collection", "document", { 333, 444, 555 }) 701 | 702 | document:save():expect() 703 | 704 | context.expectUserIds("collection", "document", { 333, 555 }) 705 | 706 | document:close():expect() 707 | 708 | context.expectUserIds("collection", "document", { 333, 555 }) 709 | end) 710 | end) 711 | 712 | x.nested("load during BindToClose", function() 713 | x.test("load infinitely yields after BindToClose", function(context) 714 | local collection = context.lapis.createCollection("collection", defaultOptions()) 715 | 716 | task.spawn(function() 717 | context.lapis.autoSave:onGameClose() 718 | end) 719 | 720 | shouldThrow(function() 721 | collection:load("document"):timeout(0.5):expect() 722 | end, "Timed out") 723 | end) 724 | 725 | x.test("load just before BindToClose", function(context) 726 | local collection = context.lapis.createCollection("collection", defaultOptions()) 727 | 728 | context.dataStoreService.yield:startYield() 729 | 730 | collection:load("document") 731 | 732 | local waited = false 733 | local finished = false 734 | local thread = task.spawn(function() 735 | RunService.PostSimulation:Wait() 736 | RunService.PostSimulation:Wait() 737 | waited = true 738 | context.lapis.autoSave:onGameClose() 739 | finished = true 740 | end) 741 | 742 | while not waited do 743 | task.wait() 744 | end 745 | 746 | context.dataStoreService.yield:stopYield() 747 | 748 | context.dataStoreService.yield:startYield() 749 | assert( 750 | coroutine.status(thread) == "suspended", 751 | "onGameClose didn't wait for the documents to finish closing" 752 | ) 753 | context.dataStoreService.yield:stopYield() 754 | 755 | while not finished do 756 | task.wait() 757 | end 758 | 759 | context.expectUnlocked("collection", "document") 760 | 761 | assert(coroutine.status(thread) == "dead", "") 762 | end) 763 | 764 | x.test("BindToClose should finish if a document fails to load", function(context) 765 | local collection = context.lapis.createCollection("collection", defaultOptions()) 766 | 767 | context.write("collection", "document", "INVALID DATA") 768 | collection:load("document"):catch(function() end) 769 | 770 | -- Wait to close game so that the save request doesn't get cancelled. 771 | task.wait(0.1) 772 | 773 | Promise.try(function() 774 | context.lapis.autoSave:onGameClose() 775 | end) 776 | :timeout(1) 777 | :expect() 778 | end) 779 | end) 780 | 781 | x.nested("freezeData = false", function() 782 | x.test("default data should be deep copied", function(context) 783 | local defaultData = { foo = {} } 784 | 785 | local collection = context.lapis.createCollection("collection", { 786 | freezeData = false, 787 | defaultData = defaultData, 788 | }) 789 | 790 | local document = collection:load("document"):expect() 791 | local data = document:read() 792 | 793 | assert(data ~= defaultData, "") 794 | assert(data.foo ~= defaultData.foo, "") 795 | assert(typeof(data.foo) == "table", "") 796 | end) 797 | 798 | x.test("data should not be frozen", function(context) 799 | local collection = context.lapis.createCollection("collection", { 800 | freezeData = false, 801 | defaultData = {}, 802 | }) 803 | local document = collection:load("document"):expect() 804 | 805 | -- This would error if the data was frozen. 806 | document:read().apples = 1 807 | 808 | -- Make sure write doesn't freeze the data. 809 | document:write(document:read()) 810 | document:read().apples = 1 811 | end) 812 | 813 | x.test("should validate data in save and close", function(context) 814 | local valid = true 815 | local collection = context.lapis.createCollection("collection", { 816 | freezeData = false, 817 | validate = function() 818 | return valid, "data is invalid" 819 | end, 820 | defaultData = {}, 821 | }) 822 | 823 | local document = collection:load("document"):expect() 824 | 825 | valid = false 826 | 827 | shouldThrow(function() 828 | document:save():expect() 829 | end, "data is invalid") 830 | 831 | shouldThrow(function() 832 | document:close():expect() 833 | end, "data is invalid") 834 | end) 835 | 836 | x.test("should handle validate errors data in save and close", function(context) 837 | local throwError = false 838 | local collection = context.lapis.createCollection("collection", { 839 | freezeData = false, 840 | validate = function() 841 | if throwError then 842 | error("foo") 843 | end 844 | 845 | return true 846 | end, 847 | defaultData = {}, 848 | }) 849 | 850 | local document = collection:load("document"):expect() 851 | 852 | throwError = true 853 | 854 | shouldThrow(function() 855 | document:save():expect() 856 | end, "'validate' threw an error", "foo") 857 | 858 | shouldThrow(function() 859 | document:close():expect() 860 | end, "'validate' threw an error", "foo") 861 | end) 862 | end) 863 | 864 | x.nested("Collection:read", function() 865 | x.test("returns nil when there is no data", function(context) 866 | local collection = context.lapis.createCollection("collection", { 867 | defaultData = "data", 868 | }) 869 | 870 | local data, keyInfo = collection:read("key"):expect() 871 | 872 | assertEqual(data, nil) 873 | assertEqual(keyInfo, nil) 874 | end) 875 | 876 | x.test("returns existing data", function(context) 877 | local collection = context.lapis.createCollection("collection", { 878 | defaultData = "data", 879 | }) 880 | 881 | collection:load("key", { 321 }):expect() 882 | 883 | local data, keyInfo = collection:read("key"):expect() 884 | 885 | assertEqual(data, "data") 886 | assertEqual(keyInfo:GetUserIds()[1], 321) 887 | end) 888 | 889 | x.test("throws error when data is invalid", function(context) 890 | local collection = context.lapis.createCollection("collection", { 891 | defaultData = "data", 892 | validate = function(data) 893 | return data == "data", "data was invalid" 894 | end, 895 | }) 896 | 897 | context.write("collection", "key", "INVALID DATA") 898 | 899 | shouldThrow(function() 900 | collection:read("key"):expect() 901 | end, "Invalid data") 902 | end) 903 | 904 | x.test("throws error when validate throws", function(context) 905 | local created = false 906 | local collection = context.lapis.createCollection("collection", { 907 | defaultData = "data", 908 | validate = function() 909 | if created then 910 | error("validate error") 911 | else 912 | return true 913 | end 914 | end, 915 | }) 916 | created = true 917 | 918 | context.write("collection", "key", "data") 919 | 920 | shouldThrow(function() 921 | collection:read("key"):expect() 922 | end, "'validate' threw an error") 923 | end) 924 | end) 925 | 926 | x.nested("Collection:remove", function() 927 | x.test("should remove data", function(context) 928 | local collection = context.lapis.createCollection("collection", defaultOptions()) 929 | 930 | collection:load("document"):expect():close():expect() 931 | 932 | collection:remove("document"):expect() 933 | 934 | assertEqual(context.read("collection", "document"), nil) 935 | end) 936 | 937 | x.test("documents open during remove should fail to save/close", function(context) 938 | local collection = context.lapis.createCollection("collection", defaultOptions()) 939 | 940 | local document = collection:load("document"):expect() 941 | 942 | collection:remove("document"):expect() 943 | 944 | shouldThrow(function() 945 | document:save():expect() 946 | end, "The document was removed") 947 | 948 | shouldThrow(function() 949 | document:close():expect() 950 | end, "The document was removed") 951 | end) 952 | end) 953 | 954 | x.nested("Document:close should still unlock after specific errors", function() 955 | x.test("shouldn't overwrite stolen lock", function(context) 956 | local collection = context.lapis.createCollection("collection", defaultOptions()) 957 | local document = collection:load("document"):expect() 958 | 959 | context.write("collection", "document", { apples = 20 }, "stolen lock") 960 | 961 | document:beforeSave(function() 962 | error("oh no") 963 | end) 964 | 965 | shouldThrow(function() 966 | document:close("document"):expect() 967 | end) 968 | 969 | task.wait(0.1) 970 | 971 | context.expectLocked("collection", "document") 972 | end) 973 | 974 | x.test("doesn't work for session lock stolen error", function(context) 975 | local collection = context.lapis.createCollection("collection", defaultOptions()) 976 | local document = collection:load("document"):expect() 977 | 978 | context.write("collection", "document", { apples = 20 }, "another lock id") 979 | 980 | document:write({ apples = 100 }) 981 | 982 | shouldThrow(function() 983 | document:close("document"):expect() 984 | end) 985 | 986 | task.wait(0.1) 987 | 988 | context.expectLocked("collection", "document") 989 | assertEqual(context.read("collection", "document").data.apples, 20) -- Only the lock should have changed. 990 | end) 991 | 992 | x.test("beforeSave error", function(context) 993 | local collection = context.lapis.createCollection("collection", defaultOptions()) 994 | 995 | local document = collection:load("document"):expect() 996 | 997 | document:write({ apples = 100 }) 998 | 999 | document:beforeSave(function() 1000 | error("oh no") 1001 | end) 1002 | 1003 | shouldThrow(function() 1004 | document:close("document"):expect() 1005 | end) 1006 | 1007 | task.wait(0.1) 1008 | 1009 | context.expectUnlocked("collection", "document") 1010 | assertEqual(context.read("collection", "document").data.apples, 20) -- Only the lock should have changed. 1011 | end) 1012 | 1013 | x.test("beforeClose error", function(context) 1014 | local collection = context.lapis.createCollection("collection", defaultOptions()) 1015 | 1016 | local document = collection:load("document"):expect() 1017 | 1018 | document:write({ apples = 100 }) 1019 | 1020 | document:beforeClose(function() 1021 | error("oh no") 1022 | end) 1023 | 1024 | shouldThrow(function() 1025 | document:close("document"):expect() 1026 | end) 1027 | 1028 | task.wait(0.1) 1029 | 1030 | context.expectUnlocked("collection", "document") 1031 | assertEqual(context.read("collection", "document").data.apples, 20) -- Only the lock should have changed. 1032 | end) 1033 | 1034 | x.test("validate error", function(context) 1035 | local collection = context.lapis.createCollection("collection", { 1036 | validate = function(data) 1037 | return typeof(data.apples) == "number", "apples should be a number" 1038 | end, 1039 | defaultData = { apples = 20 }, 1040 | freezeData = false, 1041 | }) 1042 | 1043 | local document = collection:load("document"):expect() 1044 | 1045 | document:read().apples = nil 1046 | 1047 | shouldThrow(function() 1048 | document:close("document"):expect() 1049 | end) 1050 | 1051 | task.wait(0.1) 1052 | 1053 | context.expectUnlocked("collection", "document") 1054 | assertEqual(context.read("collection", "document").data.apples, 20) -- Only the lock should have changed. 1055 | end) 1056 | 1057 | x.test("validate threw error", function(context) 1058 | local loaded = false 1059 | local collection = context.lapis.createCollection("collection", { 1060 | validate = function() 1061 | if loaded then 1062 | error("oh no") 1063 | end 1064 | return true 1065 | end, 1066 | defaultData = { apples = 20 }, 1067 | freezeData = false, 1068 | }) 1069 | 1070 | local document = collection:load("document"):expect() 1071 | 1072 | loaded = true 1073 | 1074 | shouldThrow(function() 1075 | document:close("document"):expect() 1076 | end) 1077 | 1078 | task.wait(0.1) 1079 | 1080 | context.expectUnlocked("collection", "document") 1081 | assertEqual(context.read("collection", "document").data.apples, 20) -- Only the lock should have changed. 1082 | end) 1083 | 1084 | x.test("onGameClose should wait for the lock to remove", function(context) 1085 | local collection = context.lapis.createCollection("collection", defaultOptions()) 1086 | local document = collection:load("document"):expect() 1087 | 1088 | document:beforeSave(function() 1089 | error("oh no") 1090 | end) 1091 | 1092 | context.dataStoreService.yield:startYield() 1093 | 1094 | shouldThrow(function() 1095 | document:close("document"):expect() 1096 | end) 1097 | 1098 | local thread = task.spawn(function() 1099 | context.lapis.autoSave:onGameClose() 1100 | end) 1101 | 1102 | assert(coroutine.status(thread) == "suspended", "onGameClose didn't wait for locks to be removed") 1103 | 1104 | context.dataStoreService.yield:stopYield() 1105 | 1106 | -- Wait for locks to be removed. 1107 | task.wait(0.1) 1108 | 1109 | assert(coroutine.status(thread) == "dead", "") 1110 | end) 1111 | 1112 | x.test("should preserve userids/metadata", function(context) 1113 | local collection = context.lapis.createCollection("collection", defaultOptions()) 1114 | local document = collection:load("document"):expect() 1115 | 1116 | document:beforeSave(function() 1117 | error("oh no") 1118 | end) 1119 | 1120 | context.write( 1121 | "collection", 1122 | "document", 1123 | document:read(), 1124 | context.read("collection", "document").lockId, 1125 | { 1234 }, 1126 | { foo = "bar" } 1127 | ) 1128 | 1129 | shouldThrow(function() 1130 | document:close("document"):expect() 1131 | end) 1132 | 1133 | task.wait(0.1) 1134 | 1135 | local keyInfo = context.getKeyInfo("collection", "document") 1136 | 1137 | assertEqual(keyInfo:GetUserIds()[1], 1234) 1138 | assertEqual(keyInfo:GetMetadata().foo, "bar") 1139 | end) 1140 | end) 1141 | 1142 | x.test("preserves metadata", function(context) 1143 | local collection = context.lapis.createCollection("collection", defaultOptions()) 1144 | 1145 | context.write("collection", "document", { apples = 30 }, nil, nil, { foo = "bar" }) 1146 | 1147 | local function verifyMetadata() 1148 | local keyInfo = context.getKeyInfo("collection", "document") 1149 | assertEqual(keyInfo:GetMetadata().foo, "bar") 1150 | end 1151 | 1152 | local document = collection:load("document"):expect() 1153 | verifyMetadata() 1154 | 1155 | document:save():expect() 1156 | verifyMetadata() 1157 | 1158 | document:close():expect() 1159 | verifyMetadata() 1160 | end) 1161 | end 1162 | --------------------------------------------------------------------------------