@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,384 @@
1
+ --!nonstrict
2
+ --[[
3
+ Sanity coverage for the DataStoreMock itself, so tests that rely on it can trust its
4
+ datastore-faithful behavior (deep-copy round-tripping, UpdateAsync transform semantics,
5
+ and failure injection).
6
+
7
+ Note: the mock's GetAsync/UpdateAsync/RemoveAsync return `(value, keyInfo)`; jest-lua's
8
+ `expect` takes exactly one argument, so those results are wrapped in parens to keep only
9
+ the value.
10
+
11
+ @class DataStoreMock.spec.lua
12
+ ]]
13
+ local require = require(script.Parent.loader).load(script)
14
+
15
+ local DataStoreMock = require("DataStoreMock")
16
+ local Jest = require("Jest")
17
+
18
+ local describe = Jest.Globals.describe
19
+ local expect = Jest.Globals.expect
20
+ local it = Jest.Globals.it
21
+
22
+ describe("DataStoreMock.isDataStoreMock", function()
23
+ it("should be true for a mock", function()
24
+ expect(DataStoreMock.isDataStoreMock(DataStoreMock.new())).toEqual(true)
25
+ end)
26
+
27
+ it("should be false for a plain table", function()
28
+ expect(DataStoreMock.isDataStoreMock({})).toEqual(false)
29
+ end)
30
+
31
+ it("should be false for a table with a different metatable", function()
32
+ expect(DataStoreMock.isDataStoreMock(setmetatable({}, { __index = {} }))).toEqual(false)
33
+ end)
34
+
35
+ it("should be false for nil and primitives", function()
36
+ expect(DataStoreMock.isDataStoreMock(nil)).toEqual(false)
37
+ expect(DataStoreMock.isDataStoreMock(5)).toEqual(false)
38
+ expect(DataStoreMock.isDataStoreMock("str")).toEqual(false)
39
+ expect(DataStoreMock.isDataStoreMock(true)).toEqual(false)
40
+ end)
41
+ end)
42
+
43
+ describe("DataStoreMock:GetAsync", function()
44
+ it("should return nil for an unset key", function()
45
+ local store = DataStoreMock.new()
46
+ expect((store:GetAsync("missing"))).toEqual(nil)
47
+ end)
48
+
49
+ it("should return the value set via SetAsync", function()
50
+ local store = DataStoreMock.new()
51
+ store:SetAsync("key", 42)
52
+ expect((store:GetAsync("key"))).toEqual(42)
53
+ end)
54
+
55
+ it("should return a keyInfo as its second result", function()
56
+ local store = DataStoreMock.new()
57
+ store:SetAsync("key", 1)
58
+
59
+ local _, keyInfo = store:GetAsync("key")
60
+ expect(keyInfo).never.toBeNil()
61
+ expect(type(keyInfo.GetUserIds)).toEqual("function")
62
+ expect(type(keyInfo.GetMetadata)).toEqual("function")
63
+ end)
64
+
65
+ it("should return a deep copy so mutating the result does not affect storage", function()
66
+ local store = DataStoreMock.new()
67
+ store:SetAsync("key", { coins = 5 })
68
+
69
+ local first = store:GetAsync("key")
70
+ first.coins = 999
71
+
72
+ local second = store:GetAsync("key")
73
+ expect(second.coins).toEqual(5)
74
+ end)
75
+
76
+ it("should return independent copies across nested tables", function()
77
+ local store = DataStoreMock.new()
78
+ store:SetAsync("key", { nested = { list = { 1, 2, 3 } } })
79
+
80
+ local first = store:GetAsync("key")
81
+ table.insert(first.nested.list, 4)
82
+
83
+ local second = store:GetAsync("key")
84
+ expect(#second.nested.list).toEqual(3)
85
+ end)
86
+ end)
87
+
88
+ describe("DataStoreMock:SetAsync", function()
89
+ it("should overwrite an existing value", function()
90
+ local store = DataStoreMock.new()
91
+ store:SetAsync("key", "first")
92
+ store:SetAsync("key", "second")
93
+ expect((store:GetAsync("key"))).toEqual("second")
94
+ end)
95
+
96
+ it("should not alias the stored value to the caller's table", function()
97
+ local store = DataStoreMock.new()
98
+ local value = { coins = 5 }
99
+ store:SetAsync("key", value)
100
+ value.coins = 999
101
+ expect((store:GetAsync("key")).coins).toEqual(5)
102
+ end)
103
+
104
+ it("should record associated userIds", function()
105
+ local store = DataStoreMock.new()
106
+ store:SetAsync("key", 1, { 111, 222 })
107
+
108
+ local _, keyInfo = store:GetAsync("key")
109
+ expect(keyInfo:GetUserIds()).toEqual({ 111, 222 })
110
+ end)
111
+ end)
112
+
113
+ describe("DataStoreMock:UpdateAsync", function()
114
+ it("should pass the current value to the transform", function()
115
+ local store = DataStoreMock.new()
116
+ store:SetAsync("key", 1)
117
+
118
+ local seen
119
+ store:UpdateAsync("key", function(current)
120
+ seen = current
121
+ return current + 1
122
+ end)
123
+
124
+ expect(seen).toEqual(1)
125
+ expect((store:GetAsync("key"))).toEqual(2)
126
+ end)
127
+
128
+ it("should pass nil to the transform for an unset key", function()
129
+ local store = DataStoreMock.new()
130
+
131
+ local seen = "unset"
132
+ store:UpdateAsync("key", function(current)
133
+ seen = current
134
+ return 1
135
+ end)
136
+
137
+ expect(seen).toEqual(nil)
138
+ expect((store:GetAsync("key"))).toEqual(1)
139
+ end)
140
+
141
+ it("should return the newly written value", function()
142
+ local store = DataStoreMock.new()
143
+ local written = store:UpdateAsync("key", function()
144
+ return { coins = 3 }
145
+ end)
146
+ expect(written.coins).toEqual(3)
147
+ end)
148
+
149
+ it("should cancel the update when the transform returns nil", function()
150
+ local store = DataStoreMock.new()
151
+ store:SetAsync("key", "original")
152
+
153
+ store:UpdateAsync("key", function()
154
+ return nil
155
+ end)
156
+
157
+ expect((store:GetAsync("key"))).toEqual("original")
158
+ end)
159
+
160
+ it("should deep copy the current value so the transform cannot mutate storage in place", function()
161
+ local store = DataStoreMock.new()
162
+ store:SetAsync("key", { coins = 5 })
163
+
164
+ store:UpdateAsync("key", function(current)
165
+ current.coins = 999
166
+ return nil -- cancel, so only an in-place mutation could leak
167
+ end)
168
+
169
+ expect((store:GetAsync("key")).coins).toEqual(5)
170
+ end)
171
+
172
+ it("should pass a keyInfo to the transform", function()
173
+ local store = DataStoreMock.new()
174
+ store:SetAsync("key", 1, { 42 })
175
+
176
+ local seenUserIds
177
+ store:UpdateAsync("key", function(_current, keyInfo)
178
+ seenUserIds = keyInfo:GetUserIds()
179
+ return 2
180
+ end)
181
+
182
+ expect(seenUserIds).toEqual({ 42 })
183
+ end)
184
+ end)
185
+
186
+ describe("DataStoreMock:RemoveAsync", function()
187
+ it("should return the removed value and clear the key", function()
188
+ local store = DataStoreMock.new()
189
+ store:SetAsync("key", "value")
190
+
191
+ expect((store:RemoveAsync("key"))).toEqual("value")
192
+ expect((store:GetAsync("key"))).toEqual(nil)
193
+ end)
194
+
195
+ it("should return nil when removing a missing key", function()
196
+ local store = DataStoreMock.new()
197
+ expect((store:RemoveAsync("missing"))).toEqual(nil)
198
+ end)
199
+ end)
200
+
201
+ describe("DataStoreMock:IncrementAsync", function()
202
+ it("should increment from zero", function()
203
+ local store = DataStoreMock.new()
204
+ expect(store:IncrementAsync("key", 5)).toEqual(5)
205
+ expect(store:IncrementAsync("key", 3)).toEqual(8)
206
+ end)
207
+
208
+ it("should default the delta to 1", function()
209
+ local store = DataStoreMock.new()
210
+ expect(store:IncrementAsync("key")).toEqual(1)
211
+ end)
212
+
213
+ it("should throw when incrementing a non-number value", function()
214
+ local store = DataStoreMock.new()
215
+ store:SetAsync("key", "not a number")
216
+ expect(function()
217
+ store:IncrementAsync("key", 1)
218
+ end).toThrow("Cannot increment non-number value")
219
+ end)
220
+ end)
221
+
222
+ describe("DataStoreMock failure injection", function()
223
+ it("should throw on every request while failing all requests", function()
224
+ local store = DataStoreMock.new()
225
+ store:FailAllRequests()
226
+
227
+ expect(function()
228
+ store:GetAsync("key")
229
+ end).toThrow("509")
230
+ expect(function()
231
+ store:UpdateAsync("key", function()
232
+ return 1
233
+ end)
234
+ end).toThrow("509")
235
+ expect(function()
236
+ store:SetAsync("key", 1)
237
+ end).toThrow("509")
238
+ expect(function()
239
+ store:RemoveAsync("key")
240
+ end).toThrow("509")
241
+ end)
242
+
243
+ it("should throw the 509 message by default", function()
244
+ local store = DataStoreMock.new()
245
+ store:FailAllRequests()
246
+
247
+ local ok, err = pcall(function()
248
+ store:GetAsync("key")
249
+ end)
250
+ expect(ok).toEqual(false)
251
+ expect(string.find(tostring(err), "509", 1, true) ~= nil).toEqual(true)
252
+ end)
253
+
254
+ it("should throw a custom message when provided", function()
255
+ local store = DataStoreMock.new()
256
+ store:FailAllRequests("custom boom")
257
+
258
+ local ok, err = pcall(function()
259
+ store:GetAsync("key")
260
+ end)
261
+ expect(ok).toEqual(false)
262
+ expect(string.find(tostring(err), "custom boom", 1, true) ~= nil).toEqual(true)
263
+ end)
264
+
265
+ it("should recover after StopFailing", function()
266
+ local store = DataStoreMock.new()
267
+ store:FailAllRequests()
268
+ store:StopFailing()
269
+
270
+ store:SetAsync("key", 1)
271
+ expect((store:GetAsync("key"))).toEqual(1)
272
+ end)
273
+
274
+ it("should fail only the next N requests then recover", function()
275
+ local store = DataStoreMock.new()
276
+ store:FailNextRequests(2)
277
+
278
+ expect(function()
279
+ store:GetAsync("key")
280
+ end).toThrow("509")
281
+ expect(function()
282
+ store:GetAsync("key")
283
+ end).toThrow("509")
284
+
285
+ -- Third request succeeds
286
+ store:SetAsync("key", "ok")
287
+ expect((store:GetAsync("key"))).toEqual("ok")
288
+ end)
289
+
290
+ it("should not fail when FailNextRequests count is zero", function()
291
+ local store = DataStoreMock.new()
292
+ store:FailNextRequests(0)
293
+ store:SetAsync("key", 1)
294
+ expect((store:GetAsync("key"))).toEqual(1)
295
+ end)
296
+
297
+ it("should support a custom error injector targeting a specific method", function()
298
+ local store = DataStoreMock.new()
299
+ store:SetErrorInjector(function(ctx)
300
+ if ctx.method == "UpdateAsync" then
301
+ return "no updates allowed"
302
+ end
303
+ return nil
304
+ end)
305
+
306
+ -- Reads are fine
307
+ expect(function()
308
+ store:GetAsync("key")
309
+ end).never.toThrow()
310
+
311
+ -- Updates fail
312
+ expect(function()
313
+ store:UpdateAsync("key", function()
314
+ return 1
315
+ end)
316
+ end).toThrow("no updates allowed")
317
+ end)
318
+
319
+ it("should still count failed requests", function()
320
+ local store = DataStoreMock.new()
321
+ store:FailAllRequests()
322
+
323
+ pcall(function()
324
+ store:GetAsync("key")
325
+ end)
326
+ pcall(function()
327
+ store:GetAsync("key")
328
+ end)
329
+
330
+ expect(store:GetCallCount("GetAsync")).toEqual(2)
331
+ expect(store:GetCallCount()).toEqual(2)
332
+ end)
333
+
334
+ it("should not mutate storage when a request fails", function()
335
+ local store = DataStoreMock.new()
336
+ store:SetAsync("key", "original")
337
+ store:FailAllRequests()
338
+
339
+ pcall(function()
340
+ store:SetAsync("key", "should not persist")
341
+ end)
342
+
343
+ store:StopFailing()
344
+ expect((store:GetAsync("key"))).toEqual("original")
345
+ end)
346
+ end)
347
+
348
+ describe("DataStoreMock call counting", function()
349
+ it("should count per-method and in total", function()
350
+ local store = DataStoreMock.new()
351
+ store:GetAsync("a")
352
+ store:GetAsync("b")
353
+ store:SetAsync("a", 1)
354
+
355
+ expect(store:GetCallCount("GetAsync")).toEqual(2)
356
+ expect(store:GetCallCount("SetAsync")).toEqual(1)
357
+ expect(store:GetCallCount("UpdateAsync")).toEqual(0)
358
+ expect(store:GetCallCount()).toEqual(3)
359
+ end)
360
+ end)
361
+
362
+ describe("DataStoreMock:SetRaw / GetRaw", function()
363
+ it("should seed and read without triggering failures", function()
364
+ local store = DataStoreMock.new()
365
+ store:FailAllRequests()
366
+
367
+ store:SetRaw("key", { coins = 7 })
368
+ expect(store:GetRaw("key").coins).toEqual(7)
369
+ -- Raw access does not count as a datastore call
370
+ expect(store:GetCallCount()).toEqual(0)
371
+ end)
372
+
373
+ it("should deep copy on SetRaw and GetRaw", function()
374
+ local store = DataStoreMock.new()
375
+ local seed = { coins = 1 }
376
+ store:SetRaw("key", seed)
377
+ seed.coins = 999
378
+
379
+ local read = store:GetRaw("key")
380
+ read.coins = 555
381
+
382
+ expect(store:GetRaw("key").coins).toEqual(1)
383
+ end)
384
+ end)
@@ -0,0 +1,91 @@
1
+ --!nonstrict
2
+ --[[
3
+ Characterization tests for DataStoreSnapshotUtils.
4
+ @class DataStoreSnapshotUtils.spec.lua
5
+ ]]
6
+ local require = require(script.Parent.loader).load(script)
7
+
8
+ local DataStoreDeleteToken = require("DataStoreDeleteToken")
9
+ local DataStoreSnapshotUtils = require("DataStoreSnapshotUtils")
10
+ local Jest = require("Jest")
11
+
12
+ local describe = Jest.Globals.describe
13
+ local expect = Jest.Globals.expect
14
+ local it = Jest.Globals.it
15
+
16
+ describe("DataStoreSnapshotUtils.isEmptySnapshot(snapshot)", function()
17
+ it("should return true for an empty table", function()
18
+ expect(DataStoreSnapshotUtils.isEmptySnapshot({})).toEqual(true)
19
+ end)
20
+
21
+ it("should return false for a table with array entries", function()
22
+ expect(DataStoreSnapshotUtils.isEmptySnapshot({ 1, 2, 3 })).toEqual(false)
23
+ end)
24
+
25
+ it("should return false for a table with a single dictionary entry", function()
26
+ expect(DataStoreSnapshotUtils.isEmptySnapshot({ key = "value" })).toEqual(false)
27
+ end)
28
+
29
+ it("should return false for a table with a false value", function()
30
+ expect(DataStoreSnapshotUtils.isEmptySnapshot({ key = false })).toEqual(false)
31
+ end)
32
+
33
+ it("should return false for a nested table", function()
34
+ expect(DataStoreSnapshotUtils.isEmptySnapshot({ nested = {} })).toEqual(false)
35
+ end)
36
+
37
+ it("should return false for a table holding an empty nested table by index", function()
38
+ expect(DataStoreSnapshotUtils.isEmptySnapshot({ {} })).toEqual(false)
39
+ end)
40
+
41
+ it("should return true for a frozen empty table", function()
42
+ expect(DataStoreSnapshotUtils.isEmptySnapshot(table.freeze({}))).toEqual(true)
43
+ end)
44
+
45
+ it("should return false for a frozen non-empty table", function()
46
+ expect(DataStoreSnapshotUtils.isEmptySnapshot(table.freeze({ key = "value" }))).toEqual(false)
47
+ end)
48
+
49
+ it("should return false for the delete token", function()
50
+ expect(DataStoreSnapshotUtils.isEmptySnapshot(DataStoreDeleteToken)).toEqual(false)
51
+ end)
52
+
53
+ it("should return false for nil", function()
54
+ expect(DataStoreSnapshotUtils.isEmptySnapshot(nil)).toEqual(false)
55
+ end)
56
+
57
+ it("should return false for a string", function()
58
+ expect(DataStoreSnapshotUtils.isEmptySnapshot("")).toEqual(false)
59
+ expect(DataStoreSnapshotUtils.isEmptySnapshot("hello")).toEqual(false)
60
+ end)
61
+
62
+ it("should return false for a number", function()
63
+ expect(DataStoreSnapshotUtils.isEmptySnapshot(0)).toEqual(false)
64
+ expect(DataStoreSnapshotUtils.isEmptySnapshot(5)).toEqual(false)
65
+ end)
66
+
67
+ it("should return false for a boolean", function()
68
+ expect(DataStoreSnapshotUtils.isEmptySnapshot(true)).toEqual(false)
69
+ expect(DataStoreSnapshotUtils.isEmptySnapshot(false)).toEqual(false)
70
+ end)
71
+
72
+ it("should return false for a function", function()
73
+ expect(DataStoreSnapshotUtils.isEmptySnapshot(function() end)).toEqual(false)
74
+ end)
75
+
76
+ it("should return a boolean type, never nil", function()
77
+ expect(DataStoreSnapshotUtils.isEmptySnapshot({})).never.toBeNil()
78
+ expect(DataStoreSnapshotUtils.isEmptySnapshot(nil)).never.toBeNil()
79
+ expect(type(DataStoreSnapshotUtils.isEmptySnapshot({}))).toEqual("boolean")
80
+ expect(type(DataStoreSnapshotUtils.isEmptySnapshot(nil))).toEqual("boolean")
81
+ end)
82
+
83
+ it("should not throw for any input type", function()
84
+ expect(function()
85
+ DataStoreSnapshotUtils.isEmptySnapshot(nil)
86
+ DataStoreSnapshotUtils.isEmptySnapshot({})
87
+ DataStoreSnapshotUtils.isEmptySnapshot(DataStoreDeleteToken)
88
+ DataStoreSnapshotUtils.isEmptySnapshot("string")
89
+ end).never.toThrow()
90
+ end)
91
+ end)
@@ -710,8 +710,14 @@ function DataStoreStage.PromiseInvokeSavingCallbacks(self: DataStoreStage)
710
710
  local removingPromises: { Promise.Promise<()> } = {}
711
711
 
712
712
  for _, func in self._savingCallbacks do
713
- local result = func()
714
- if Promise.isPromise(result) then
713
+ -- Isolate the callback so a throw fails the save cleanly, preserving the stack trace.
714
+ local ok, result = xpcall(func, function(err)
715
+ return debug.traceback(tostring(err), 2)
716
+ end)
717
+ if not ok then
718
+ warn(`[DataStoreStage] - Saving callback errored: {result}`)
719
+ table.insert(removingPromises, Promise.rejected(result) :: any)
720
+ elseif Promise.isPromise(result) then
715
721
  table.insert(removingPromises, result :: any)
716
722
  end
717
723
  end