@quenty/datastore 13.40.0 → 13.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/package.json +12 -12
  3. package/src/Server/DataStore.HookErrors.spec.lua +92 -0
  4. package/src/Server/DataStore.LoadErrors.spec.lua +133 -0
  5. package/src/Server/DataStore.Reentrance.spec.lua +119 -0
  6. package/src/Server/DataStore.SessionLock.spec.lua +478 -0
  7. package/src/Server/DataStore.SessionMessaging.spec.lua +71 -0
  8. package/src/Server/DataStore.TwoServerLock.spec.lua +190 -0
  9. package/src/Server/DataStore.lua +89 -18
  10. package/src/Server/DataStore.spec.lua +237 -0
  11. package/src/Server/DataStoreLockHelper.spec.lua +239 -0
  12. package/src/Server/DataStoreMessageHelper.spec.lua +134 -0
  13. package/src/Server/DataStoreNonRetryableLoadError.lua +61 -0
  14. package/src/Server/DataStoreTestUtils.lua +206 -0
  15. package/src/Server/GameDataStoreService.lua +26 -0
  16. package/src/Server/GameDataStoreService.spec.lua +215 -0
  17. package/src/Server/Mocks/DataStoreMock.lua +405 -0
  18. package/src/Server/Mocks/DataStoreMock.spec.lua +384 -0
  19. package/src/Server/Modules/DataStoreSnapshotUtils.spec.lua +91 -0
  20. package/src/Server/Modules/DataStoreStage.lua +8 -2
  21. package/src/Server/Modules/DataStoreStage.spec.lua +539 -0
  22. package/src/Server/Modules/DataStoreWriter.spec.lua +399 -0
  23. package/src/Server/PlayerDataStoreManager.RemovalCallbacks.spec.lua +99 -0
  24. package/src/Server/PlayerDataStoreManager.lua +36 -4
  25. package/src/Server/PlayerDataStoreManager.spec.lua +202 -0
  26. package/src/Server/PlayerDataStoreService.lua +18 -0
  27. package/src/Server/PlayerDataStoreService.spec.lua +218 -0
  28. package/src/Server/PrivateServerDataStoreService.lua +23 -1
  29. package/src/Server/PrivateServerDataStoreService.spec.lua +242 -0
  30. package/src/Server/Utility/DataStorePromises.lua +18 -5
  31. package/src/Server/Utility/DataStorePromises.spec.lua +363 -0
  32. package/src/Shared/Utility/DataStoreStringUtils.spec.lua +1 -1
  33. package/src/jest.config.lua +3 -0
@@ -0,0 +1,202 @@
1
+ --!nonstrict
2
+ --[[
3
+ Integration coverage for PlayerDataStoreManager against a mocked Roblox datastore. The manager
4
+ auto-enables session locking and session messaging on every DataStore it creates, so even a load
5
+ does an UpdateAsync round-trip through the mock. Tests use numeric userIds, never real Players.
6
+
7
+ @class PlayerDataStoreManager.spec.lua
8
+ ]]
9
+ local require = require(script.Parent.loader).load(script)
10
+
11
+ local DataStoreTestUtils = require("DataStoreTestUtils")
12
+ local Jest = require("Jest")
13
+ local PromiseTestUtils = require("PromiseTestUtils")
14
+
15
+ local describe = Jest.Globals.describe
16
+ local expect = Jest.Globals.expect
17
+ local it = Jest.Globals.it
18
+
19
+ -- Asserts the promise settled within the timeout and returns whether it is now safe to :Yield(), so
20
+ -- a hung promise fails the test instead of freezing the runner.
21
+ local function expectSettled(promise, timeout: number?): boolean
22
+ local settled = PromiseTestUtils.awaitSettled(promise, timeout)
23
+ expect(settled).toEqual(true)
24
+ return settled
25
+ end
26
+
27
+ describe("PlayerDataStoreManager.GetDataStore", function()
28
+ it("should return a datastore for a fresh user", function()
29
+ local controller = DataStoreTestUtils.setupDataStoreManager()
30
+
31
+ local dataStore = controller.manager:GetDataStore(1)
32
+ expect(dataStore).never.toBeNil()
33
+
34
+ controller:destroy()
35
+ end)
36
+
37
+ it("should cache the datastore per user", function()
38
+ local controller = DataStoreTestUtils.setupDataStoreManager()
39
+
40
+ local first = controller.manager:GetDataStore(1)
41
+ local second = controller.manager:GetDataStore(1)
42
+ expect(first).toEqual(second)
43
+
44
+ local other = controller.manager:GetDataStore(2)
45
+ expect((first == other)).toEqual(false)
46
+
47
+ controller:destroy()
48
+ end)
49
+
50
+ it("should apply the key generator", function()
51
+ local controller = DataStoreTestUtils.setupDataStoreManager()
52
+
53
+ local dataStore = controller.manager:GetDataStore(1)
54
+ expect((dataStore:GetKey())).toEqual("user_1")
55
+
56
+ controller:destroy()
57
+ end)
58
+ end)
59
+
60
+ describe("PlayerDataStoreManager.PromiseDataStore", function()
61
+ it("should resolve the datastore and load successfully against a healthy mock", function()
62
+ local controller = DataStoreTestUtils.setupDataStoreManager()
63
+
64
+ local promise = controller.manager:PromiseDataStore(1)
65
+ if not expectSettled(promise, 10) then
66
+ controller:destroy()
67
+ return
68
+ end
69
+
70
+ local ok, dataStore = promise:Yield()
71
+ expect(ok).toEqual(true)
72
+ expect(dataStore).never.toBeNil()
73
+
74
+ local loadPromise = dataStore:PromiseLoadSuccessful()
75
+ if not expectSettled(loadPromise, 10) then
76
+ controller:destroy()
77
+ return
78
+ end
79
+ expect((loadPromise:Wait())).toEqual(true)
80
+
81
+ controller:destroy()
82
+ end)
83
+ end)
84
+
85
+ describe("PlayerDataStoreManager persistence", function()
86
+ it("should round-trip a stored value across a removal/reload", function()
87
+ local controller = DataStoreTestUtils.setupDataStoreManager()
88
+
89
+ local dataStore = controller.manager:GetDataStore(1)
90
+ dataStore:Store("coins", 5)
91
+
92
+ -- Removal saves (SaveAndCloseSession) then closes the session, asynchronously.
93
+ controller.manager:RemovePlayerDataStore(1)
94
+
95
+ -- PromiseDataStore waits for the in-progress removal to finish before handing back a
96
+ -- fresh datastore for the same user.
97
+ local promise = controller.manager:PromiseDataStore(1)
98
+ if not expectSettled(promise, 10) then
99
+ controller:destroy()
100
+ return
101
+ end
102
+
103
+ local ok, reloaded = promise:Yield()
104
+ expect(ok).toEqual(true)
105
+
106
+ local loadPromise = reloaded:Load("coins")
107
+ if not expectSettled(loadPromise, 10) then
108
+ controller:destroy()
109
+ return
110
+ end
111
+
112
+ local loadOk, value = loadPromise:Yield()
113
+ expect(loadOk).toEqual(true)
114
+ expect(value).toEqual(5)
115
+
116
+ controller:destroy()
117
+ end)
118
+ end)
119
+
120
+ describe("PlayerDataStoreManager.AddRemovingCallback", function()
121
+ it("should invoke the removing callback when a user's datastore is removed", function()
122
+ local controller = DataStoreTestUtils.setupDataStoreManager()
123
+
124
+ local ran = false
125
+ controller.manager:AddRemovingCallback(function()
126
+ ran = true
127
+ end)
128
+
129
+ controller.manager:GetDataStore(1)
130
+
131
+ -- Drain the removal to be sure the callback fired.
132
+ local promise = controller.manager:PromiseAllSaves()
133
+ if not expectSettled(promise, 10) then
134
+ controller:destroy()
135
+ return
136
+ end
137
+ expect((promise:Yield())).toEqual(true)
138
+
139
+ expect(ran).toEqual(true)
140
+
141
+ controller:destroy()
142
+ end)
143
+ end)
144
+
145
+ describe("PlayerDataStoreManager.PromiseAllSaves", function()
146
+ it("should resolve after removing all datastores and flushing pending saves", function()
147
+ local controller = DataStoreTestUtils.setupDataStoreManager()
148
+
149
+ controller.manager:GetDataStore(1):Store("coins", 1)
150
+ controller.manager:GetDataStore(2):Store("coins", 2)
151
+
152
+ local promise = controller.manager:PromiseAllSaves()
153
+ if not expectSettled(promise, 10) then
154
+ controller:destroy()
155
+ return
156
+ end
157
+ expect((promise:Yield())).toEqual(true)
158
+
159
+ controller:destroy()
160
+ end)
161
+ end)
162
+
163
+ describe("PlayerDataStoreManager teardown", function()
164
+ it("destroys the datastores it still owns when the manager is destroyed", function()
165
+ local controller = DataStoreTestUtils.setupDataStoreManager()
166
+
167
+ local dataStore = controller.manager:GetDataStore(1)
168
+ if not expectSettled(dataStore:PromiseLoadSuccessful(), 10) then
169
+ controller:destroy()
170
+ return
171
+ end
172
+
173
+ controller.manager:Destroy()
174
+
175
+ -- BaseObject.Destroy clears the metatable, so a torn-down store reads a nil metatable.
176
+ expect(getmetatable(dataStore)).toBeNil()
177
+
178
+ controller:destroy()
179
+ end)
180
+
181
+ it("flushes staged data synchronously to the underlying store when destroyed", function()
182
+ local controller = DataStoreTestUtils.setupDataStoreManager()
183
+
184
+ local dataStore = controller.manager:GetDataStore(1)
185
+ if not expectSettled(dataStore:PromiseLoadSuccessful(), 10) then
186
+ controller:destroy()
187
+ return
188
+ end
189
+
190
+ dataStore:Store("coins", 5)
191
+
192
+ -- Destroy must fire a synchronous Save before tearing each store down, so the staged value is
193
+ -- already persisted the instant Destroy() returns -- asserted with no awaiting.
194
+ controller.manager:Destroy()
195
+
196
+ local raw = controller.mock:GetRaw("user_1")
197
+ expect(raw).never.toBeNil()
198
+ expect(raw.coins).toEqual(5)
199
+
200
+ controller:destroy()
201
+ end)
202
+ end)
@@ -27,6 +27,7 @@ export type PlayerDataStoreService = typeof(setmetatable(
27
27
  _dataStoreManagerPromise: Promise.Promise<PlayerDataStoreManager.PlayerDataStoreManager>,
28
28
  _bindToCloseService: any,
29
29
  _promiseStarted: Promise.Promise<()>,
30
+ _robloxDataStoreOverride: any?,
30
31
  },
31
32
  {} :: typeof({ __index = PlayerDataStoreService })
32
33
  ))
@@ -91,6 +92,20 @@ function PlayerDataStoreService.SetDataStoreScope(self: PlayerDataStoreService,
91
92
  self._dataStoreScope = dataStoreScope
92
93
  end
93
94
 
95
+ --[=[
96
+ Injects the underlying datastore the manager wraps, instead of resolving a real one. Accepts
97
+ a real datastore or a [DataStoreMock]. Intended for testing; must be called before the manager
98
+ is first built.
99
+
100
+ @param robloxDataStore DataStore | DataStoreMock
101
+ ]=]
102
+ function PlayerDataStoreService.SetRobloxDataStore(self: PlayerDataStoreService, robloxDataStore: any): ()
103
+ assert(DataStorePromises.isDataStore(robloxDataStore), "Bad robloxDataStore")
104
+ assert(not self._dataStoreManagerPromise, "Already built manager, cannot override")
105
+
106
+ self._robloxDataStoreOverride = robloxDataStore
107
+ end
108
+
94
109
  --[=[
95
110
  Gets the datastore for the player.
96
111
 
@@ -138,6 +153,9 @@ function PlayerDataStoreService.PromiseManager(
138
153
 
139
154
  self._dataStoreManagerPromise = self._promiseStarted
140
155
  :Then(function()
156
+ if self._robloxDataStoreOverride then
157
+ return self._robloxDataStoreOverride
158
+ end
141
159
  return DataStorePromises.promiseDataStore(self._dataStoreName, self._dataStoreScope)
142
160
  end)
143
161
  :Then(function(dataStore)
@@ -0,0 +1,218 @@
1
+ --!nonstrict
2
+ --[[
3
+ Integration coverage for PlayerDataStoreService, the ServiceBag-driven wrapper around one
4
+ PlayerDataStoreManager, with the underlying datastore injected via the SetRobloxDataStore test
5
+ seam. The manager auto-enables session locking, so even a load does an UpdateAsync round-trip
6
+ through the mock. Tests use numeric userIds, never real Players.
7
+
8
+ @class PlayerDataStoreService.spec.lua
9
+ ]]
10
+ local require = require(script.Parent.loader).load(script)
11
+
12
+ local DataStoreMock = require("DataStoreMock")
13
+ local Jest = require("Jest")
14
+ local Maid = require("Maid")
15
+ local PromiseTestUtils = require("PromiseTestUtils")
16
+ local ServiceBag = require("ServiceBag")
17
+
18
+ local describe = Jest.Globals.describe
19
+ local expect = Jest.Globals.expect
20
+ local it = Jest.Globals.it
21
+
22
+ local function setup(mock)
23
+ local maid = Maid.new()
24
+
25
+ local serviceBag = maid:Add(ServiceBag.new())
26
+ local service = serviceBag:GetService(require("PlayerDataStoreService"))
27
+ serviceBag:Init()
28
+
29
+ if mock then
30
+ service:SetRobloxDataStore(mock)
31
+ serviceBag:Start()
32
+ end
33
+
34
+ return {
35
+ service = service,
36
+ mock = mock,
37
+ destroy = function()
38
+ maid:DoCleaning()
39
+ end,
40
+ }
41
+ end
42
+
43
+ describe("PlayerDataStoreService.PromiseDataStore", function()
44
+ it("should resolve a datastore and load successfully against a healthy mock", function()
45
+ local controller = setup(DataStoreMock.new())
46
+
47
+ local promise = controller.service:PromiseDataStore(1)
48
+ if not PromiseTestUtils.awaitSettled(promise, 10) then
49
+ expect("hung").toEqual("settled")
50
+ controller:destroy()
51
+ return
52
+ end
53
+
54
+ local ok, dataStore = promise:Yield()
55
+ expect(ok).toEqual(true)
56
+ expect(dataStore).never.toBeNil()
57
+
58
+ local loadPromise = dataStore:PromiseLoadSuccessful()
59
+ if not PromiseTestUtils.awaitSettled(loadPromise, 10) then
60
+ expect("hung").toEqual("settled")
61
+ controller:destroy()
62
+ return
63
+ end
64
+ expect((loadPromise:Wait())).toEqual(true)
65
+
66
+ controller:destroy()
67
+ end)
68
+ end)
69
+
70
+ describe("PlayerDataStoreService configuration guards", function()
71
+ it("should throw when SetDataStoreName is called after start", function()
72
+ local controller = setup(DataStoreMock.new())
73
+
74
+ expect(function()
75
+ controller.service:SetDataStoreName("X")
76
+ end).toThrow("Already started, cannot configure")
77
+
78
+ controller:destroy()
79
+ end)
80
+
81
+ it("should throw when SetDataStoreScope is called after start", function()
82
+ local controller = setup(DataStoreMock.new())
83
+
84
+ expect(function()
85
+ controller.service:SetDataStoreScope("X")
86
+ end).toThrow("Already started, cannot configure")
87
+
88
+ controller:destroy()
89
+ end)
90
+
91
+ it("should throw when SetRobloxDataStore is called after the manager is built", function()
92
+ local controller = setup(DataStoreMock.new())
93
+
94
+ -- Building the manager (via PromiseDataStore) locks out further overrides.
95
+ local promise = controller.service:PromiseDataStore(1)
96
+ if not PromiseTestUtils.awaitSettled(promise, 10) then
97
+ expect("hung").toEqual("settled")
98
+ controller:destroy()
99
+ return
100
+ end
101
+
102
+ expect(function()
103
+ controller.service:SetRobloxDataStore(controller.mock)
104
+ end).toThrow("Already built manager")
105
+
106
+ controller:destroy()
107
+ end)
108
+
109
+ it("should throw when SetRobloxDataStore is given a bad datastore", function()
110
+ local controller = setup()
111
+
112
+ expect(function()
113
+ controller.service:SetRobloxDataStore(nil)
114
+ end).toThrow("Bad robloxDataStore")
115
+
116
+ expect(function()
117
+ controller.service:SetRobloxDataStore({})
118
+ end).toThrow("Bad robloxDataStore")
119
+
120
+ controller:destroy()
121
+ end)
122
+ end)
123
+
124
+ describe("PlayerDataStoreService.PromiseAddRemovingCallback", function()
125
+ it("should resolve after registering the removing callback", function()
126
+ local controller = setup(DataStoreMock.new())
127
+
128
+ local promise = controller.service:PromiseAddRemovingCallback(function() end)
129
+ if not PromiseTestUtils.awaitSettled(promise, 10) then
130
+ expect("hung").toEqual("settled")
131
+ controller:destroy()
132
+ return
133
+ end
134
+ expect((promise:Yield())).toEqual(true)
135
+
136
+ controller:destroy()
137
+ end)
138
+ end)
139
+
140
+ describe("PlayerDataStoreService failure handling", function()
141
+ it("surfaces a datastore failure to the player fast instead of hanging", function()
142
+ local mock = DataStoreMock.new()
143
+ mock:FailAllRequests()
144
+
145
+ local controller = setup(mock)
146
+
147
+ -- PromiseDataStore resolves synchronously; the session-locked load underneath is what must
148
+ -- settle rather than hang against a failing datastore.
149
+ local promise = controller.service:PromiseDataStore(1)
150
+ if not PromiseTestUtils.awaitSettled(promise, 10) then
151
+ expect("hung").toEqual("settled")
152
+ controller:destroy()
153
+ return
154
+ end
155
+
156
+ local ok, dataStore = promise:Yield()
157
+ expect(ok).toEqual(true)
158
+ expect(dataStore).never.toBeNil()
159
+
160
+ local loadPromise = dataStore:PromiseLoadSuccessful()
161
+ expect(PromiseTestUtils.awaitSettled(loadPromise, 5)).toEqual(true)
162
+ expect((loadPromise:Wait())).toEqual(false)
163
+
164
+ controller:destroy()
165
+ end)
166
+ end)
167
+
168
+ describe("PlayerDataStoreService teardown", function()
169
+ it("destroys the datastore its manager owns when the service is destroyed", function()
170
+ local controller = setup(DataStoreMock.new())
171
+
172
+ local promise = controller.service:PromiseDataStore(1)
173
+ if not PromiseTestUtils.awaitSettled(promise, 10) then
174
+ expect("hung").toEqual("settled")
175
+ controller:destroy()
176
+ return
177
+ end
178
+ local _ok, dataStore = promise:Yield()
179
+
180
+ if not PromiseTestUtils.awaitSettled(dataStore:PromiseLoadSuccessful(), 10) then
181
+ expect("load hung").toEqual("load settled")
182
+ controller:destroy()
183
+ return
184
+ end
185
+
186
+ controller:destroy()
187
+
188
+ expect(getmetatable(dataStore)).toBeNil()
189
+ end)
190
+
191
+ it("flushes staged data synchronously to the underlying store when destroyed", function()
192
+ local controller = setup(DataStoreMock.new())
193
+
194
+ local promise = controller.service:PromiseDataStore(1)
195
+ if not PromiseTestUtils.awaitSettled(promise, 10) then
196
+ expect("hung").toEqual("settled")
197
+ controller:destroy()
198
+ return
199
+ end
200
+ local _ok, dataStore = promise:Yield()
201
+
202
+ if not PromiseTestUtils.awaitSettled(dataStore:PromiseLoadSuccessful(), 10) then
203
+ expect("load hung").toEqual("load settled")
204
+ controller:destroy()
205
+ return
206
+ end
207
+
208
+ dataStore:Store("coins", 7)
209
+
210
+ -- Destroy must fire a synchronous Save before tearing the store down.
211
+ controller:destroy()
212
+
213
+ -- keyGenerator maps userId 1 -> "1"
214
+ local raw = controller.mock:GetRaw("1")
215
+ expect(raw).never.toBeNil()
216
+ expect(raw.coins).toEqual(7)
217
+ end)
218
+ end)
@@ -51,7 +51,7 @@ function PrivateServerDataStoreService.PromiseDataStore(
51
51
  end
52
52
 
53
53
  self._dataStorePromise = self:_promiseRobloxDataStore():Then(function(robloxDataStore)
54
- local dataStore = self._maid:Add(DataStore.new(robloxDataStore, self:_getKey()))
54
+ local dataStore = DataStore.new(robloxDataStore, self:_getKey())
55
55
 
56
56
  if game.PrivateServerOwnerId ~= 0 then
57
57
  dataStore:Store("LastPrivateServerOwnerId", game.PrivateServerOwnerId)
@@ -61,6 +61,14 @@ function PrivateServerDataStoreService.PromiseDataStore(
61
61
  return dataStore:Save()
62
62
  end))
63
63
 
64
+ -- On service teardown (hot reload / tests) flush and destroy the store. Save() is a best-effort
65
+ -- synchronous write before Destroy() cancels it.
66
+ self._maid:GiveTask(function()
67
+ -- Best-effort: swallow a rejection (e.g. the load failed) so it is not uncaught.
68
+ dataStore:Save():Catch(function() end)
69
+ dataStore:Destroy()
70
+ end)
71
+
64
72
  return dataStore
65
73
  end)
66
74
  assert(self._dataStorePromise, "Typechecking assertion")
@@ -77,6 +85,20 @@ function PrivateServerDataStoreService.SetCustomKey(self: PrivateServerDataStore
77
85
  self._customKey = customKey
78
86
  end
79
87
 
88
+ --[=[
89
+ Injects the underlying datastore to use instead of resolving a real one. Accepts a real
90
+ datastore or a [DataStoreMock]. Intended for testing; must be called before the datastore
91
+ is first resolved.
92
+
93
+ @param robloxDataStore DataStore | DataStoreMock
94
+ ]=]
95
+ function PrivateServerDataStoreService.SetRobloxDataStore(self: PrivateServerDataStoreService, robloxDataStore: any): ()
96
+ assert(DataStorePromises.isDataStore(robloxDataStore), "Bad robloxDataStore")
97
+ assert(not self._robloxDataStorePromise, "Already resolved robloxDataStore, cannot override")
98
+
99
+ self._robloxDataStorePromise = Promise.resolved(robloxDataStore)
100
+ end
101
+
80
102
  function PrivateServerDataStoreService._promiseRobloxDataStore(
81
103
  self: PrivateServerDataStoreService
82
104
  ): Promise.Promise<any>