@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,399 @@
1
+ --!nonstrict
2
+ --[[
3
+ Characterization coverage for DataStoreWriter's merge/diff/write API -- what actually gets
4
+ persisted when new data, delete tokens, and nested sub-writers are layered onto an original.
5
+ @class DataStoreWriter.spec.lua
6
+ ]]
7
+ local require = require(script.Parent.loader).load(script)
8
+
9
+ local DataStoreDeleteToken = require("DataStoreDeleteToken")
10
+ local DataStoreWriter = require("DataStoreWriter")
11
+ local Jest = require("Jest")
12
+
13
+ local describe = Jest.Globals.describe
14
+ local expect = Jest.Globals.expect
15
+ local it = Jest.Globals.it
16
+
17
+ -- Recursively freeze so snapshots satisfy the "must be frozen" asserts on Set*Snapshot.
18
+ local function deepFreeze<T>(tab: T): T
19
+ if type(tab) == "table" and not table.isfrozen(tab :: any) then
20
+ for _, value in tab :: any do
21
+ deepFreeze(value)
22
+ end
23
+ table.freeze(tab :: any)
24
+ end
25
+ return tab
26
+ end
27
+
28
+ describe("DataStoreWriter.new", function()
29
+ it("should construct with a debugName and expose the ClassName", function()
30
+ local writer = DataStoreWriter.new("test")
31
+
32
+ expect(writer).never.toBeNil()
33
+ expect(writer.ClassName).toEqual("DataStoreWriter")
34
+ end)
35
+
36
+ it("should error when constructed without a debugName", function()
37
+ expect(function()
38
+ DataStoreWriter.new()
39
+ end).toThrow("No debugName")
40
+ end)
41
+
42
+ it("should start with empty/unset state", function()
43
+ local writer = DataStoreWriter.new("test")
44
+
45
+ expect(writer:GetDataToSave()).toBeNil()
46
+ expect(writer:GetUserIdList()).toBeNil()
47
+ expect(writer:GetSubWritersMap()).toEqual({})
48
+ expect(writer:IsCompleteWipe()).toEqual(false)
49
+ end)
50
+ end)
51
+
52
+ describe("DataStoreWriter:SetSaveDataSnapshot / GetDataToSave", function()
53
+ it("should store and return a scalar save value", function()
54
+ local writer = DataStoreWriter.new("test")
55
+ writer:SetSaveDataSnapshot(5)
56
+
57
+ expect(writer:GetDataToSave()).toEqual(5)
58
+ end)
59
+
60
+ it("should store a boolean save value (false is not treated as unset)", function()
61
+ local writer = DataStoreWriter.new("test")
62
+ writer:SetSaveDataSnapshot(false)
63
+
64
+ expect(writer:GetDataToSave()).toEqual(false)
65
+ end)
66
+
67
+ it("should deep-copy a frozen table snapshot rather than storing the reference", function()
68
+ local writer = DataStoreWriter.new("test")
69
+ local source = table.freeze({ coins = 5, gems = 3 })
70
+ writer:SetSaveDataSnapshot(source)
71
+
72
+ expect(writer:GetDataToSave()).toEqual({ coins = 5, gems = 3 })
73
+ expect((writer:GetDataToSave() == source)).toEqual(false)
74
+ end)
75
+
76
+ it("should deep-copy nested tables inside the snapshot", function()
77
+ local writer = DataStoreWriter.new("test")
78
+ writer:SetSaveDataSnapshot(deepFreeze({ stats = { hp = 10, mp = 5 } }))
79
+
80
+ expect(writer:GetDataToSave()).toEqual({ stats = { hp = 10, mp = 5 } })
81
+ end)
82
+
83
+ it("should reject an unfrozen table snapshot", function()
84
+ local writer = DataStoreWriter.new("test")
85
+
86
+ expect(function()
87
+ writer:SetSaveDataSnapshot({ coins = 5 })
88
+ end).toThrow("saveDataSnapshot should be frozen")
89
+ end)
90
+
91
+ it("should store the delete token as a complete wipe", function()
92
+ local writer = DataStoreWriter.new("test")
93
+ writer:SetSaveDataSnapshot(DataStoreDeleteToken)
94
+
95
+ expect((writer:GetDataToSave() == DataStoreDeleteToken)).toEqual(true)
96
+ expect(writer:IsCompleteWipe()).toEqual(true)
97
+ end)
98
+ end)
99
+
100
+ describe("DataStoreWriter:IsCompleteWipe", function()
101
+ it("should be false when unset", function()
102
+ local writer = DataStoreWriter.new("test")
103
+ expect(writer:IsCompleteWipe()).toEqual(false)
104
+ end)
105
+
106
+ it("should be false for a scalar save value", function()
107
+ local writer = DataStoreWriter.new("test")
108
+ writer:SetSaveDataSnapshot(5)
109
+ expect(writer:IsCompleteWipe()).toEqual(false)
110
+ end)
111
+
112
+ it("should be true only for the delete token", function()
113
+ local writer = DataStoreWriter.new("test")
114
+ writer:SetSaveDataSnapshot(DataStoreDeleteToken)
115
+ expect(writer:IsCompleteWipe()).toEqual(true)
116
+ end)
117
+ end)
118
+
119
+ describe("DataStoreWriter:AddSubWriter / GetWriter / GetSubWritersMap", function()
120
+ it("should register and retrieve a sub-writer by string name", function()
121
+ local parent = DataStoreWriter.new("parent")
122
+ local child = DataStoreWriter.new("child")
123
+ parent:AddSubWriter("inventory", child)
124
+
125
+ expect((parent:GetWriter("inventory") == child)).toEqual(true)
126
+ expect((parent:GetSubWritersMap()["inventory"] == child)).toEqual(true)
127
+ end)
128
+
129
+ it("should return nil for an unknown sub-writer name", function()
130
+ local parent = DataStoreWriter.new("parent")
131
+ expect(parent:GetWriter("missing")).toBeNil()
132
+ end)
133
+
134
+ it("should error when adding two writers under the same name", function()
135
+ local parent = DataStoreWriter.new("parent")
136
+ parent:AddSubWriter("inventory", DataStoreWriter.new("a"))
137
+
138
+ expect(function()
139
+ parent:AddSubWriter("inventory", DataStoreWriter.new("b"))
140
+ end).toThrow("Writer already exists for name")
141
+ end)
142
+
143
+ it("should error when adding a nil writer", function()
144
+ local parent = DataStoreWriter.new("parent")
145
+ expect(function()
146
+ parent:AddSubWriter("inventory", nil)
147
+ end).toThrow("Bad writer")
148
+ end)
149
+
150
+ it("should error when the name is neither string nor number", function()
151
+ local parent = DataStoreWriter.new("parent")
152
+ expect(function()
153
+ parent:AddSubWriter({}, DataStoreWriter.new("child"))
154
+ end).toThrow("Bad name")
155
+ end)
156
+
157
+ it("should accept a numeric name but leave it unreachable through GetWriter", function()
158
+ -- Sharp edge: AddSubWriter permits number keys, yet GetWriter asserts a string name.
159
+ local parent = DataStoreWriter.new("parent")
160
+ local child = DataStoreWriter.new("child")
161
+ parent:AddSubWriter(1, child)
162
+
163
+ expect((parent:GetSubWritersMap()[1] == child)).toEqual(true)
164
+ expect(function()
165
+ parent:GetWriter(1)
166
+ end).toThrow("Bad name")
167
+ end)
168
+ end)
169
+
170
+ describe("DataStoreWriter:SetUserIdList / GetUserIdList", function()
171
+ it("should round-trip a user id list", function()
172
+ local writer = DataStoreWriter.new("test")
173
+ writer:SetUserIdList({ 1, 2, 3 })
174
+
175
+ expect(writer:GetUserIdList()).toEqual({ 1, 2, 3 })
176
+ end)
177
+
178
+ it("should return nil before a list is set", function()
179
+ local writer = DataStoreWriter.new("test")
180
+ expect(writer:GetUserIdList()).toBeNil()
181
+ end)
182
+
183
+ it("should allow clearing the list back to nil", function()
184
+ local writer = DataStoreWriter.new("test")
185
+ writer:SetUserIdList({ 1 })
186
+ writer:SetUserIdList(nil)
187
+
188
+ expect(writer:GetUserIdList()).toBeNil()
189
+ end)
190
+
191
+ it("should reject a non-table user id list", function()
192
+ local writer = DataStoreWriter.new("test")
193
+ expect(function()
194
+ writer:SetUserIdList(5)
195
+ end).toThrow("Bad userIdList")
196
+ end)
197
+ end)
198
+
199
+ describe("DataStoreWriter:SetFullBaseDataSnapshot", function()
200
+ it("should accept a frozen table", function()
201
+ local writer = DataStoreWriter.new("test")
202
+ expect(function()
203
+ writer:SetFullBaseDataSnapshot(table.freeze({ coins = 5 }))
204
+ end).never.toThrow()
205
+ end)
206
+
207
+ it("should reject an unfrozen table", function()
208
+ local writer = DataStoreWriter.new("test")
209
+ expect(function()
210
+ writer:SetFullBaseDataSnapshot({ coins = 5 })
211
+ end).toThrow("fullBaseDataSnapshot should be frozen")
212
+ end)
213
+
214
+ it("should reject the delete token", function()
215
+ local writer = DataStoreWriter.new("test")
216
+ expect(function()
217
+ writer:SetFullBaseDataSnapshot(DataStoreDeleteToken)
218
+ end).toThrow("fullBaseDataSnapshot should not be symbol")
219
+ end)
220
+ end)
221
+
222
+ describe("DataStoreWriter:WriteMerge", function()
223
+ it("should return the original unchanged when nothing is staged", function()
224
+ local writer = DataStoreWriter.new("test")
225
+
226
+ expect((writer:WriteMerge(5))).toEqual(5)
227
+ expect((writer:WriteMerge(nil))).toBeNil()
228
+ expect((writer:WriteMerge({ coins = 1 }))).toEqual({ coins = 1 })
229
+ end)
230
+
231
+ it("should return the delete token when the save is a complete wipe", function()
232
+ local writer = DataStoreWriter.new("test")
233
+ writer:SetSaveDataSnapshot(DataStoreDeleteToken)
234
+
235
+ expect((writer:WriteMerge({ coins = 1 }) == DataStoreDeleteToken)).toEqual(true)
236
+ end)
237
+
238
+ it("should replace the original entirely with a scalar save value", function()
239
+ local writer = DataStoreWriter.new("test")
240
+ writer:SetSaveDataSnapshot(10)
241
+
242
+ expect((writer:WriteMerge({ coins = 1 }))).toEqual(10)
243
+ expect((writer:WriteMerge(nil))).toEqual(10)
244
+ end)
245
+
246
+ it("should merge a table save on top of the original, keeping untouched keys", function()
247
+ local writer = DataStoreWriter.new("test")
248
+ writer:SetSaveDataSnapshot(table.freeze({ coins = 5 }))
249
+
250
+ expect((writer:WriteMerge({ coins = 1, gems = 2 }))).toEqual({ coins = 5, gems = 2 })
251
+ end)
252
+
253
+ it("should remove a key when the save carries a delete token for it", function()
254
+ local writer = DataStoreWriter.new("test")
255
+ writer:SetSaveDataSnapshot(table.freeze({ coins = DataStoreDeleteToken }))
256
+
257
+ expect((writer:WriteMerge({ coins = 1, gems = 2 }))).toEqual({ gems = 2 })
258
+ end)
259
+
260
+ it("should swap a scalar original to a table when the save is a table", function()
261
+ local writer = DataStoreWriter.new("test")
262
+ writer:SetSaveDataSnapshot(table.freeze({ coins = 5 }))
263
+
264
+ expect((writer:WriteMerge(5))).toEqual({ coins = 5 })
265
+ end)
266
+
267
+ it("should treat an empty table save as a no-op merge", function()
268
+ local writer = DataStoreWriter.new("test")
269
+ writer:SetSaveDataSnapshot(table.freeze({}))
270
+
271
+ expect((writer:WriteMerge({ coins = 1 }))).toEqual({ coins = 1 })
272
+ end)
273
+
274
+ it("should not mutate the original table passed in", function()
275
+ local writer = DataStoreWriter.new("test")
276
+ writer:SetSaveDataSnapshot(table.freeze({ coins = 5 }))
277
+ local original = { coins = 1, gems = 2 }
278
+ writer:WriteMerge(original)
279
+
280
+ expect(original).toEqual({ coins = 1, gems = 2 })
281
+ end)
282
+ end)
283
+
284
+ describe("DataStoreWriter:WriteMerge with sub-writers", function()
285
+ it("should merge a nested sub-writer's save into its key", function()
286
+ local parent = DataStoreWriter.new("parent")
287
+ local child = DataStoreWriter.new("child")
288
+ child:SetSaveDataSnapshot(table.freeze({ sword = true }))
289
+ parent:AddSubWriter("inventory", child)
290
+
291
+ local result = parent:WriteMerge({ coins = 5, inventory = { shield = true } })
292
+ expect(result).toEqual({ coins = 5, inventory = { shield = true, sword = true } })
293
+ end)
294
+
295
+ it("should remove the key when a sub-writer resolves to a delete token", function()
296
+ local parent = DataStoreWriter.new("parent")
297
+ local child = DataStoreWriter.new("child")
298
+ child:SetSaveDataSnapshot(DataStoreDeleteToken)
299
+ parent:AddSubWriter("inventory", child)
300
+
301
+ local result = parent:WriteMerge({ coins = 5, inventory = { x = 1 } })
302
+ expect(result).toEqual({ coins = 5 })
303
+ end)
304
+
305
+ it("should build up a table from a scalar original when sub-writers are present", function()
306
+ local parent = DataStoreWriter.new("parent")
307
+ local child = DataStoreWriter.new("child")
308
+ child:SetSaveDataSnapshot(table.freeze({ a = 1 }))
309
+ parent:AddSubWriter("sub", child)
310
+
311
+ expect((parent:WriteMerge(5))).toEqual({ sub = { a = 1 } })
312
+ end)
313
+ end)
314
+
315
+ describe("DataStoreWriter:ComputeDiffSnapshot", function()
316
+ it("should emit only the changed key against the base", function()
317
+ local writer = DataStoreWriter.new("test")
318
+ writer:SetFullBaseDataSnapshot(table.freeze({ coins = 5, gems = 3 }))
319
+
320
+ expect((writer:ComputeDiffSnapshot({ coins = 5, gems = 10 }))).toEqual({ gems = 10 })
321
+ end)
322
+
323
+ it("should emit a delete token for a key removed from the incoming data", function()
324
+ local writer = DataStoreWriter.new("test")
325
+ writer:SetFullBaseDataSnapshot(table.freeze({ coins = 5 }))
326
+
327
+ local diff = writer:ComputeDiffSnapshot({})
328
+ expect((diff.coins == DataStoreDeleteToken)).toEqual(true)
329
+ end)
330
+
331
+ it("should emit a newly added key", function()
332
+ local writer = DataStoreWriter.new("test")
333
+ writer:SetFullBaseDataSnapshot(table.freeze({ coins = 5 }))
334
+
335
+ expect((writer:ComputeDiffSnapshot({ coins = 5, gems = 1 }))).toEqual({ gems = 1 })
336
+ end)
337
+
338
+ it("should return nil when the incoming table matches the base", function()
339
+ local writer = DataStoreWriter.new("test")
340
+ writer:SetFullBaseDataSnapshot(table.freeze({ coins = 5 }))
341
+
342
+ expect((writer:ComputeDiffSnapshot({ coins = 5 }))).toBeNil()
343
+ end)
344
+
345
+ it("should diff nested tables and emit only the changed leaf", function()
346
+ local writer = DataStoreWriter.new("test")
347
+ writer:SetFullBaseDataSnapshot(deepFreeze({ stats = { hp = 10, mp = 5 } }))
348
+
349
+ expect((writer:ComputeDiffSnapshot({ stats = { hp = 10, mp = 8 } }))).toEqual({ stats = { mp = 8 } })
350
+ end)
351
+
352
+ it("should return a frozen diff snapshot", function()
353
+ local writer = DataStoreWriter.new("test")
354
+ writer:SetFullBaseDataSnapshot(table.freeze({ coins = 5 }))
355
+
356
+ expect((table.isfrozen(writer:ComputeDiffSnapshot({ coins = 10 })))).toEqual(true)
357
+ end)
358
+
359
+ it("should treat an empty incoming with no base or writers as a full delete", function()
360
+ local writer = DataStoreWriter.new("test")
361
+
362
+ expect((writer:ComputeDiffSnapshot({}) == DataStoreDeleteToken)).toEqual(true)
363
+ end)
364
+
365
+ it("should recurse into sub-writers, diffing against their own base", function()
366
+ local parent = DataStoreWriter.new("parent")
367
+ parent:SetFullBaseDataSnapshot(table.freeze({ coins = 5 }))
368
+
369
+ local child = DataStoreWriter.new("child")
370
+ child:SetFullBaseDataSnapshot(table.freeze({ sword = true }))
371
+ parent:AddSubWriter("inv", child)
372
+
373
+ local diff = parent:ComputeDiffSnapshot({ coins = 5, inv = { sword = true, shield = true } })
374
+ expect(diff).toEqual({ inv = { shield = true } })
375
+ end)
376
+
377
+ it("should diff a scalar incoming against a scalar base", function()
378
+ local writer = DataStoreWriter.new("test")
379
+ writer:SetFullBaseDataSnapshot(5)
380
+
381
+ expect((writer:ComputeDiffSnapshot(5))).toBeNil()
382
+ expect((writer:ComputeDiffSnapshot(10))).toEqual(10)
383
+ end)
384
+
385
+ it("should reject the delete token as incoming", function()
386
+ local writer = DataStoreWriter.new("test")
387
+ expect(function()
388
+ writer:ComputeDiffSnapshot(DataStoreDeleteToken)
389
+ end).toThrow("Incoming value should not be DataStoreDeleteToken")
390
+ end)
391
+
392
+ it("should throw on a scalar incoming when the base is unset", function()
393
+ -- Sharp edge: the unset sentinel is a symbol, and the scalar path asserts non-symbol.
394
+ local writer = DataStoreWriter.new("test")
395
+ expect(function()
396
+ writer:ComputeDiffSnapshot(5)
397
+ end).toThrow("original should not be symbol")
398
+ end)
399
+ end)
@@ -0,0 +1,99 @@
1
+ --!nonstrict
2
+ --[[
3
+ How a misbehaving removing callback affects (a) whether the player's data is saved on leave, and
4
+ (b) whether the session lock is released. SaveAndCloseSession is what releases the lock, so when
5
+ it is skipped the departing session's lock lingers until it goes stale. Several of these are
6
+ genuine data-integrity failure modes, so pinning them documents the contract callers must honor.
7
+
8
+ @class PlayerDataStoreManager.RemovalCallbacks.spec.lua
9
+ ]]
10
+ local require = require(script.Parent.loader).load(script)
11
+
12
+ local DataStoreTestUtils = require("DataStoreTestUtils")
13
+ local Jest = require("Jest")
14
+ local Promise = require("Promise")
15
+ local PromiseTestUtils = require("PromiseTestUtils")
16
+
17
+ local describe = Jest.Globals.describe
18
+ local expect = Jest.Globals.expect
19
+ local it = Jest.Globals.it
20
+
21
+ describe("PlayerDataStoreManager removal matrix (misbehaving removing callbacks)", function()
22
+ it("well-behaved callback: saves the data AND releases the lock", function()
23
+ local controller = DataStoreTestUtils.setupDataStoreManager()
24
+ controller.manager:AddRemovingCallback(function()
25
+ return Promise.resolved()
26
+ end)
27
+
28
+ expect(controller.storeAndAwaitLock()).toEqual(true)
29
+ controller.manager:RemovePlayerDataStore(1)
30
+
31
+ expect(PromiseTestUtils.awaitValue(function()
32
+ local raw = controller.mock:GetRaw("user_1")
33
+ return raw ~= nil and raw.coins == 5
34
+ end, 10)).toEqual(true)
35
+ -- SaveAndCloseSession stripped the lock as it wrote.
36
+ expect(controller.mock:GetRaw("user_1").lock).toEqual(nil)
37
+
38
+ controller:destroy()
39
+ end)
40
+
41
+ describe("failure modes", function()
42
+ it("rejecting callback: skips the save (data loss) AND leaves the lock held", function()
43
+ local controller = DataStoreTestUtils.setupDataStoreManager()
44
+ controller.manager:AddRemovingCallback(function()
45
+ return Promise.rejected("removing callback failed")
46
+ end)
47
+
48
+ expect(controller.storeAndAwaitLock()).toEqual(true)
49
+ controller.manager:RemovePlayerDataStore(1)
50
+
51
+ -- The rejected callback short-circuits PromiseUtils.all before SaveAndCloseSession: coins are
52
+ -- never persisted, and the lock is never released.
53
+ expect(PromiseTestUtils.awaitValue(function()
54
+ local raw = controller.mock:GetRaw("user_1")
55
+ return raw ~= nil and raw.coins == 5
56
+ end, 3)).toEqual(false)
57
+ expect(controller.mock:GetRaw("user_1").lock ~= nil).toEqual(true)
58
+
59
+ controller:destroy()
60
+ end)
61
+
62
+ it("throwing callback: the synchronous throw escapes removal (lock held, stuck)", function()
63
+ local controller = DataStoreTestUtils.setupDataStoreManager()
64
+ controller.manager:AddRemovingCallback(function()
65
+ error("removing callback boom")
66
+ end)
67
+
68
+ expect(controller.storeAndAwaitLock()).toEqual(true)
69
+
70
+ -- There is no pcall around removing callbacks, so a synchronous throw escapes removal entirely.
71
+ expect(function()
72
+ controller.manager:RemovePlayerDataStore(1)
73
+ end).toThrow("removing callback boom")
74
+ expect(controller.mock:GetRaw("user_1").lock ~= nil).toEqual(true)
75
+
76
+ controller:destroy()
77
+ end)
78
+
79
+ it("yielding callback: removal blocks forever (no save, lock held)", function()
80
+ local controller = DataStoreTestUtils.setupDataStoreManager()
81
+ controller.manager:AddRemovingCallback(function()
82
+ return Promise.new()
83
+ end)
84
+
85
+ expect(controller.storeAndAwaitLock()).toEqual(true)
86
+ controller.manager:RemovePlayerDataStore(1)
87
+
88
+ -- SaveAndCloseSession is gated behind the yielding callback, so neither the save nor the lock
89
+ -- release ever happen.
90
+ expect(PromiseTestUtils.awaitValue(function()
91
+ local raw = controller.mock:GetRaw("user_1")
92
+ return raw ~= nil and raw.coins == 5
93
+ end, 2)).toEqual(false)
94
+ expect(controller.mock:GetRaw("user_1").lock ~= nil).toEqual(true)
95
+
96
+ controller:destroy()
97
+ end)
98
+ end)
99
+ end)
@@ -55,6 +55,7 @@ local Players = game:GetService("Players")
55
55
  local RunService = game:GetService("RunService")
56
56
 
57
57
  local BaseObject = require("BaseObject")
58
+ local BindToCloseService = require("BindToCloseService")
58
59
  local DataStore = require("DataStore")
59
60
  local Maid = require("Maid")
60
61
  local PendingPromiseTracker = require("PendingPromiseTracker")
@@ -90,6 +91,9 @@ export type PlayerDataStoreManager =
90
91
  --[=[
91
92
  Constructs a new PlayerDataStoreManager.
92
93
 
94
+ Unless `skipBindingToClose` is true, this resolves [BindToCloseService] from the serviceBag to
95
+ save on game close, so that service must be registered before the serviceBag starts.
96
+
93
97
  @param robloxDataStore DataStore
94
98
  @param keyGenerator (player) -> string -- Function that takes in a player, and outputs a key
95
99
  @param skipBindingToClose boolean?
@@ -125,19 +129,47 @@ function PlayerDataStoreManager.new(
125
129
  self:_removePlayerDataStore(player.UserId)
126
130
  end))
127
131
 
132
+ -- On teardown (e.g. a hot-reloaded ServiceBag, or unit tests) flush and destroy any datastores we
133
+ -- still own. See _flushAndDestroyAll.
134
+ self._maid:GiveTask(function()
135
+ self:_flushAndDestroyAll()
136
+ end)
137
+
128
138
  if skipBindingToClose ~= true then
129
- game:BindToClose(function()
139
+ -- Route through BindToCloseService so the callback is unregistered on :Destroy()
140
+ -- (unlike a raw game:BindToClose, which can never be unbound and would leak on hot reload).
141
+ local bindToCloseService = self._serviceBag:GetService(BindToCloseService) :: any
142
+ self._maid:GiveTask(bindToCloseService:RegisterPromiseOnCloseCallback(function()
130
143
  if self._disableSavingInStudio then
131
- return
144
+ return Promise.resolved()
132
145
  end
133
146
 
134
- self:PromiseAllSaves():Wait()
135
- end)
147
+ return self:PromiseAllSaves()
148
+ end))
136
149
  end
137
150
 
138
151
  return self
139
152
  end
140
153
 
154
+ --[=[
155
+ Flushes and tears down every datastore we still own. Runs on manager teardown (a hot-reloaded
156
+ ServiceBag, or a unit test). Save() is a best-effort synchronous write: the underlying UpdateAsync
157
+ request is dispatched before Destroy() cancels the promise, so a live server usually honors it, but
158
+ it is not guaranteed. A store whose load failed rejects, so the rejection is swallowed. Stores handed
159
+ off gracefully via _removePlayerDataStore have already been pulled out of _datastores, so this only
160
+ covers the ones nothing else cleaned up.
161
+ ]=]
162
+ function PlayerDataStoreManager._flushAndDestroyAll(self: PlayerDataStoreManager): ()
163
+ for userId, datastore in self._datastores do
164
+ -- Cast past the DataStore intersection type: the solver otherwise blows up ("code too complex")
165
+ -- resolving :Save()/:Destroy() through it.
166
+ local store = datastore :: any
167
+ store:Save():Catch(function() end)
168
+ store:Destroy()
169
+ self._datastores[userId] = nil
170
+ end
171
+ end
172
+
141
173
  --[=[
142
174
  For if you want to disable saving in studio for faster close time!
143
175
  ]=]