@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,539 @@
1
+ --!nonstrict
2
+ --[[
3
+ Characterization coverage for the DataStoreStage staging layer, exercised through a real root
4
+ (`DataStore.new(DataStoreMock.new(), key)`) since a bare stage has no load parent. Stores here are
5
+ non-session-locking, so staging is deterministic.
6
+
7
+ @class DataStoreStage.spec.lua
8
+ ]]
9
+ local require = require(script.Parent.loader).load(script)
10
+
11
+ local DataStore = require("DataStore")
12
+ local DataStoreMock = require("DataStoreMock")
13
+ local Jest = require("Jest")
14
+ local Promise = require("Promise")
15
+ local PromiseTestUtils = require("PromiseTestUtils")
16
+ local Rx = require("Rx")
17
+ local ValueObject = require("ValueObject")
18
+
19
+ local describe = Jest.Globals.describe
20
+ local expect = Jest.Globals.expect
21
+ local it = Jest.Globals.it
22
+
23
+ describe("DataStoreStage staging (through a DataStore root)", function()
24
+ describe("Store / Load", function()
25
+ it("should Load back a value that was Stored", function()
26
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
27
+ dataStore:Store("coins", 25)
28
+
29
+ local promise = dataStore:Load("coins")
30
+ if not PromiseTestUtils.awaitSettled(promise) then
31
+ expect("hung").toEqual("settled")
32
+ return
33
+ end
34
+
35
+ local ok, value = promise:Yield()
36
+ expect(ok).toEqual(true)
37
+ expect(value).toEqual(25)
38
+
39
+ dataStore:Destroy()
40
+ end)
41
+
42
+ it("should Load a deep-equal table that was Stored", function()
43
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
44
+ dataStore:Store("profile", { level = 3, name = "Egg" })
45
+
46
+ local promise = dataStore:Load("profile")
47
+ if not PromiseTestUtils.awaitSettled(promise) then
48
+ expect("hung").toEqual("settled")
49
+ return
50
+ end
51
+
52
+ local ok, value = promise:Yield()
53
+ expect(ok).toEqual(true)
54
+ expect(value).toEqual({ level = 3, name = "Egg" })
55
+
56
+ dataStore:Destroy()
57
+ end)
58
+
59
+ it("should return the default value for a missing key", function()
60
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
61
+
62
+ local promise = dataStore:Load("missing", "fallback")
63
+ if not PromiseTestUtils.awaitSettled(promise) then
64
+ expect("hung").toEqual("settled")
65
+ return
66
+ end
67
+
68
+ expect((promise:Wait())).toEqual("fallback")
69
+
70
+ dataStore:Destroy()
71
+ end)
72
+ end)
73
+
74
+ describe("LoadAll", function()
75
+ it("should return the whole staged view", function()
76
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
77
+ dataStore:Store("coins", 5)
78
+ dataStore:Store("gems", 10)
79
+
80
+ local promise = dataStore:LoadAll()
81
+ if not PromiseTestUtils.awaitSettled(promise) then
82
+ expect("hung").toEqual("settled")
83
+ return
84
+ end
85
+
86
+ local ok, all = promise:Yield()
87
+ expect(ok).toEqual(true)
88
+ expect(all).toEqual({ coins = 5, gems = 10 })
89
+
90
+ dataStore:Destroy()
91
+ end)
92
+
93
+ it("should return the default when the stage is empty", function()
94
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
95
+
96
+ local promise = dataStore:LoadAll("empty")
97
+ if not PromiseTestUtils.awaitSettled(promise) then
98
+ expect("hung").toEqual("settled")
99
+ return
100
+ end
101
+
102
+ expect((promise:Wait())).toEqual("empty")
103
+
104
+ dataStore:Destroy()
105
+ end)
106
+ end)
107
+
108
+ describe("GetSubStore", function()
109
+ it("should return the same substore instance for the same key", function()
110
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
111
+
112
+ local first = dataStore:GetSubStore("inv")
113
+ local second = dataStore:GetSubStore("inv")
114
+ expect(first).toEqual(second)
115
+
116
+ dataStore:Destroy()
117
+ end)
118
+
119
+ it("should surface a substore value under its key in the parent LoadAll", function()
120
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
121
+ dataStore:GetSubStore("inv"):Store("sword", true)
122
+
123
+ local promise = dataStore:LoadAll()
124
+ if not PromiseTestUtils.awaitSettled(promise) then
125
+ expect("hung").toEqual("settled")
126
+ return
127
+ end
128
+
129
+ local ok, all = promise:Yield()
130
+ expect(ok).toEqual(true)
131
+ expect(all).toEqual({ inv = { sword = true } })
132
+
133
+ dataStore:Destroy()
134
+ end)
135
+
136
+ it("should support deep nesting (substore of a substore)", function()
137
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
138
+ dataStore:GetSubStore("a"):GetSubStore("b"):Store("c", 1)
139
+
140
+ local promise = dataStore:LoadAll()
141
+ if not PromiseTestUtils.awaitSettled(promise) then
142
+ expect("hung").toEqual("settled")
143
+ return
144
+ end
145
+
146
+ local ok, all = promise:Yield()
147
+ expect(ok).toEqual(true)
148
+ expect(all).toEqual({ a = { b = { c = 1 } } })
149
+
150
+ dataStore:Destroy()
151
+ end)
152
+ end)
153
+
154
+ describe("View priority", function()
155
+ it("should prioritize staged save data over loaded base data", function()
156
+ local mock = DataStoreMock.new()
157
+ -- Seed base data BEFORE constructing the reader so it loads through getAsync.
158
+ mock:SetRaw("player_1", { coins = 1, gems = 2 })
159
+
160
+ local dataStore = DataStore.new(mock, "player_1")
161
+
162
+ -- Base data loads first
163
+ local basePromise = dataStore:Load("coins")
164
+ if not PromiseTestUtils.awaitSettled(basePromise) then
165
+ expect("hung").toEqual("settled")
166
+ return
167
+ end
168
+ expect((basePromise:Wait())).toEqual(1)
169
+
170
+ -- Staged value overrides base; untouched base key stays visible
171
+ dataStore:Store("coins", 999)
172
+
173
+ local promise = dataStore:LoadAll()
174
+ if not PromiseTestUtils.awaitSettled(promise) then
175
+ expect("hung").toEqual("settled")
176
+ return
177
+ end
178
+
179
+ local ok, all = promise:Yield()
180
+ expect(ok).toEqual(true)
181
+ expect(all).toEqual({ coins = 999, gems = 2 })
182
+
183
+ dataStore:Destroy()
184
+ end)
185
+ end)
186
+
187
+ describe("Overwrite / OverwriteMerge / Wipe", function()
188
+ it("should replace the whole staged view with Overwrite", function()
189
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
190
+ dataStore:Store("a", 1)
191
+ dataStore:Store("b", 2)
192
+
193
+ dataStore:Overwrite({ c = 3 })
194
+
195
+ local promise = dataStore:LoadAll()
196
+ if not PromiseTestUtils.awaitSettled(promise) then
197
+ expect("hung").toEqual("settled")
198
+ return
199
+ end
200
+
201
+ local ok, all = promise:Yield()
202
+ expect(ok).toEqual(true)
203
+ expect(all).toEqual({ c = 3 })
204
+
205
+ dataStore:Destroy()
206
+ end)
207
+
208
+ it("should clear the staged view with Overwrite(nil)", function()
209
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
210
+ dataStore:Store("a", 1)
211
+
212
+ dataStore:Overwrite(nil)
213
+
214
+ local promise = dataStore:LoadAll("cleared")
215
+ if not PromiseTestUtils.awaitSettled(promise) then
216
+ expect("hung").toEqual("settled")
217
+ return
218
+ end
219
+
220
+ expect((promise:Wait())).toEqual("cleared")
221
+
222
+ dataStore:Destroy()
223
+ end)
224
+
225
+ it("should clear even loaded base data with Wipe", function()
226
+ local mock = DataStoreMock.new()
227
+ mock:SetRaw("player_1", { coins = 5 })
228
+
229
+ local dataStore = DataStore.new(mock, "player_1")
230
+
231
+ -- Force base to load first
232
+ local basePromise = dataStore:Load("coins")
233
+ if not PromiseTestUtils.awaitSettled(basePromise) then
234
+ expect("hung").toEqual("settled")
235
+ return
236
+ end
237
+ expect((basePromise:Wait())).toEqual(5)
238
+
239
+ dataStore:Wipe()
240
+
241
+ local promise = dataStore:LoadAll("wiped")
242
+ if not PromiseTestUtils.awaitSettled(promise) then
243
+ expect("hung").toEqual("settled")
244
+ return
245
+ end
246
+
247
+ expect((promise:Wait())).toEqual("wiped")
248
+
249
+ dataStore:Destroy()
250
+ end)
251
+
252
+ it("should merge without wiping missing keys via OverwriteMerge", function()
253
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
254
+ dataStore:Store("a", 1)
255
+ dataStore:Store("b", 2)
256
+
257
+ dataStore:OverwriteMerge({ b = 3, c = 4 })
258
+
259
+ local promise = dataStore:LoadAll()
260
+ if not PromiseTestUtils.awaitSettled(promise) then
261
+ expect("hung").toEqual("settled")
262
+ return
263
+ end
264
+
265
+ local ok, all = promise:Yield()
266
+ expect(ok).toEqual(true)
267
+ expect(all).toEqual({ a = 1, b = 3, c = 4 })
268
+
269
+ dataStore:Destroy()
270
+ end)
271
+ end)
272
+
273
+ describe("Delete", function()
274
+ it("should remove a key so it no longer loads", function()
275
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
276
+ dataStore:Store("a", 1)
277
+ dataStore:Store("b", 2)
278
+
279
+ dataStore:Delete("a")
280
+
281
+ local promise = dataStore:LoadAll()
282
+ if not PromiseTestUtils.awaitSettled(promise) then
283
+ expect("hung").toEqual("settled")
284
+ return
285
+ end
286
+
287
+ local ok, all = promise:Yield()
288
+ expect(ok).toEqual(true)
289
+ expect(all).toEqual({ b = 2 })
290
+
291
+ dataStore:Destroy()
292
+ end)
293
+ end)
294
+
295
+ describe("Observe", function()
296
+ it("should emit the initial value for a key", function()
297
+ local mock = DataStoreMock.new()
298
+ mock:SetRaw("player_1", { coins = 7 })
299
+
300
+ local dataStore = DataStore.new(mock, "player_1")
301
+
302
+ local promise = Rx.toPromise(dataStore:Observe("coins", 0))
303
+ if not PromiseTestUtils.awaitSettled(promise) then
304
+ expect("hung").toEqual("settled")
305
+ return
306
+ end
307
+
308
+ local ok, value = promise:Yield()
309
+ expect(ok).toEqual(true)
310
+ expect(value).toEqual(7)
311
+
312
+ dataStore:Destroy()
313
+ end)
314
+
315
+ it("should emit the default for a missing key", function()
316
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
317
+
318
+ local promise = Rx.toPromise(dataStore:Observe("coins", 0))
319
+ if not PromiseTestUtils.awaitSettled(promise) then
320
+ expect("hung").toEqual("settled")
321
+ return
322
+ end
323
+
324
+ expect((promise:Wait())).toEqual(0)
325
+
326
+ dataStore:Destroy()
327
+ end)
328
+
329
+ it("should fire the observer with the new value after a Store on a key", function()
330
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
331
+
332
+ local captured
333
+ local sub = dataStore:Observe("coins", 0):Subscribe(function(value)
334
+ captured = value
335
+ end)
336
+
337
+ dataStore:Store("coins", 42)
338
+
339
+ expect(PromiseTestUtils.awaitValue(function()
340
+ return captured == 42
341
+ end)).toEqual(true)
342
+
343
+ sub:Destroy()
344
+ dataStore:Destroy()
345
+ end)
346
+
347
+ it("should fire the whole-view observer with the new snapshot after a Store", function()
348
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
349
+
350
+ -- The initial emission of an empty store is nil, so count emissions (not value) to
351
+ -- confirm the Changed connection is established before we Store.
352
+ local emissions = 0
353
+ local captured
354
+ local sub = dataStore:Observe():Subscribe(function(snapshot)
355
+ emissions += 1
356
+ captured = snapshot
357
+ end)
358
+
359
+ expect(PromiseTestUtils.awaitValue(function()
360
+ return emissions >= 1
361
+ end)).toEqual(true)
362
+
363
+ dataStore:Store("coins", 1)
364
+
365
+ expect(PromiseTestUtils.awaitValue(function()
366
+ return type(captured) == "table" and captured.coins == 1
367
+ end)).toEqual(true)
368
+
369
+ sub:Destroy()
370
+ dataStore:Destroy()
371
+ end)
372
+ end)
373
+
374
+ describe("PromiseKeyList / PromiseKeySet", function()
375
+ it("should report the set of staged keys", function()
376
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
377
+ dataStore:Store("a", 1)
378
+ dataStore:Store("b", 2)
379
+
380
+ local promise = dataStore:PromiseKeySet()
381
+ if not PromiseTestUtils.awaitSettled(promise) then
382
+ expect("hung").toEqual("settled")
383
+ return
384
+ end
385
+
386
+ local ok, keys = promise:Yield()
387
+ expect(ok).toEqual(true)
388
+ expect(keys).toEqual({ a = true, b = true })
389
+
390
+ dataStore:Destroy()
391
+ end)
392
+
393
+ it("should report the list of staged keys", function()
394
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
395
+ dataStore:Store("a", 1)
396
+ dataStore:Store("b", 2)
397
+
398
+ local promise = dataStore:PromiseKeyList()
399
+ if not PromiseTestUtils.awaitSettled(promise) then
400
+ expect("hung").toEqual("settled")
401
+ return
402
+ end
403
+
404
+ local ok, list = promise:Yield()
405
+ expect(ok).toEqual(true)
406
+
407
+ -- Order is not guaranteed; compare as a set
408
+ local asSet = {}
409
+ for _, key in list do
410
+ asSet[key] = true
411
+ end
412
+ expect(asSet).toEqual({ a = true, b = true })
413
+
414
+ dataStore:Destroy()
415
+ end)
416
+ end)
417
+
418
+ describe("StoreOnValueChange", function()
419
+ it("should stage the value whenever the ValueObject changes", function()
420
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
421
+
422
+ -- Ensure the stage is loaded before wiring up the value object
423
+ if not PromiseTestUtils.awaitSettled(dataStore:Load("level")) then
424
+ expect("hung").toEqual("settled")
425
+ return
426
+ end
427
+
428
+ local valueObject = ValueObject.new(0)
429
+ dataStore:StoreOnValueChange("level", valueObject)
430
+
431
+ -- Construction does not stage; a change does
432
+ valueObject.Value = 7
433
+
434
+ local promise = dataStore:LoadAll()
435
+ if not PromiseTestUtils.awaitSettled(promise) then
436
+ expect("hung").toEqual("settled")
437
+ return
438
+ end
439
+
440
+ local ok, all = promise:Yield()
441
+ expect(ok).toEqual(true)
442
+ expect(all.level).toEqual(7)
443
+
444
+ dataStore:Destroy()
445
+ end)
446
+ end)
447
+
448
+ describe("Persistence round-trip", function()
449
+ it("should persist a staged value across a fresh DataStore on the same mock", function()
450
+ local mock = DataStoreMock.new()
451
+
452
+ local writer = DataStore.new(mock, "player_1")
453
+ writer:Store("coins", 5)
454
+
455
+ local savePromise = writer:Save()
456
+ if not PromiseTestUtils.awaitSettled(savePromise) then
457
+ expect("hung").toEqual("settled")
458
+ return
459
+ end
460
+ expect((savePromise:Yield())).toEqual(true)
461
+
462
+ local reader = DataStore.new(mock, "player_1")
463
+ local loadPromise = reader:Load("coins")
464
+ if not PromiseTestUtils.awaitSettled(loadPromise) then
465
+ expect("hung").toEqual("settled")
466
+ return
467
+ end
468
+
469
+ local ok, value = loadPromise:Yield()
470
+ expect(ok).toEqual(true)
471
+ expect(value).toEqual(5)
472
+
473
+ writer:Destroy()
474
+ reader:Destroy()
475
+ end)
476
+ end)
477
+
478
+ describe("Deep-copy isolation", function()
479
+ it("should not reflect mutations to the input table after Store", function()
480
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
481
+
482
+ local input = { count = 1 }
483
+ dataStore:Store("data", input)
484
+
485
+ -- Mutating the caller's table must not change the frozen staged copy
486
+ input.count = 999
487
+
488
+ local promise = dataStore:Load("data")
489
+ if not PromiseTestUtils.awaitSettled(promise) then
490
+ expect("hung").toEqual("settled")
491
+ return
492
+ end
493
+
494
+ local ok, value = promise:Yield()
495
+ expect(ok).toEqual(true)
496
+ expect(value).toEqual({ count = 1 })
497
+
498
+ dataStore:Destroy()
499
+ end)
500
+ end)
501
+
502
+ describe("PromiseInvokeSavingCallbacks with promise-returning callbacks", function()
503
+ it("resolves when a callback's returned promise resolves", function()
504
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
505
+ dataStore:AddSavingCallback(function()
506
+ return Promise.resolved()
507
+ end)
508
+
509
+ local outcome = PromiseTestUtils.awaitOutcome(dataStore:PromiseInvokeSavingCallbacks())
510
+ expect(outcome).toEqual("resolved")
511
+
512
+ dataStore:Destroy()
513
+ end)
514
+
515
+ it("rejects when a callback's returned promise rejects, so the save fails", function()
516
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
517
+ dataStore:AddSavingCallback(function()
518
+ return Promise.rejected("removing callback boom")
519
+ end)
520
+
521
+ local outcome, err = PromiseTestUtils.awaitOutcome(dataStore:PromiseInvokeSavingCallbacks())
522
+ expect(outcome).toEqual("rejected")
523
+ expect(string.find(tostring(err), "removing callback boom", 1, true) ~= nil).toEqual(true)
524
+
525
+ dataStore:Destroy()
526
+ end)
527
+
528
+ it("never settles when a callback's returned promise never resolves, so the save hangs", function()
529
+ local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
530
+ dataStore:AddSavingCallback(function()
531
+ return Promise.new()
532
+ end)
533
+
534
+ expect(PromiseTestUtils.awaitSettled(dataStore:PromiseInvokeSavingCallbacks(), 0.25)).toEqual(false)
535
+
536
+ dataStore:Destroy()
537
+ end)
538
+ end)
539
+ end)