@quenty/datastore 13.39.1 → 13.41.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.
- package/CHANGELOG.md +8 -0
- package/package.json +9 -9
- package/src/Server/DataStore.HookErrors.spec.lua +92 -0
- package/src/Server/DataStore.LoadErrors.spec.lua +133 -0
- package/src/Server/DataStore.Reentrance.spec.lua +119 -0
- package/src/Server/DataStore.SessionLock.spec.lua +478 -0
- package/src/Server/DataStore.SessionMessaging.spec.lua +71 -0
- package/src/Server/DataStore.TwoServerLock.spec.lua +190 -0
- package/src/Server/DataStore.lua +89 -18
- package/src/Server/DataStore.spec.lua +237 -0
- package/src/Server/DataStoreLockHelper.spec.lua +239 -0
- package/src/Server/DataStoreMessageHelper.spec.lua +134 -0
- package/src/Server/DataStoreNonRetryableLoadError.lua +61 -0
- package/src/Server/DataStoreTestUtils.lua +206 -0
- package/src/Server/GameDataStoreService.lua +26 -0
- package/src/Server/GameDataStoreService.spec.lua +215 -0
- package/src/Server/Mocks/DataStoreMock.lua +405 -0
- package/src/Server/Mocks/DataStoreMock.spec.lua +384 -0
- package/src/Server/Modules/DataStoreSnapshotUtils.spec.lua +91 -0
- package/src/Server/Modules/DataStoreStage.lua +8 -2
- package/src/Server/Modules/DataStoreStage.spec.lua +539 -0
- package/src/Server/Modules/DataStoreWriter.spec.lua +399 -0
- package/src/Server/PlayerDataStoreManager.RemovalCallbacks.spec.lua +99 -0
- package/src/Server/PlayerDataStoreManager.lua +36 -4
- package/src/Server/PlayerDataStoreManager.spec.lua +202 -0
- package/src/Server/PlayerDataStoreService.lua +18 -0
- package/src/Server/PlayerDataStoreService.spec.lua +218 -0
- package/src/Server/PrivateServerDataStoreService.lua +23 -1
- package/src/Server/PrivateServerDataStoreService.spec.lua +242 -0
- package/src/Server/Utility/DataStorePromises.lua +18 -5
- package/src/Server/Utility/DataStorePromises.spec.lua +363 -0
- 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
|
-
|
|
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()
|
|
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
|
]=]
|