@quenty/datastore 13.44.0 → 13.46.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 +13 -0
- package/package.json +14 -13
- package/src/Server/DataStore.LoadErrors.spec.lua +0 -8
- package/src/Server/DataStore.Overflow.spec.lua +0 -12
- package/src/Server/DataStore.Reentrance.spec.lua +3 -15
- package/src/Server/DataStore.SessionLock.spec.lua +0 -34
- package/src/Server/DataStore.TwoServerLock.spec.lua +1 -19
- package/src/Server/DataStore.spec.lua +0 -7
- package/src/Server/DataStoreLockHelper.spec.lua +0 -12
- package/src/Server/DataStoreMessageHelper.spec.lua +0 -10
- package/src/Server/GameDataStoreService.lua +5 -2
- package/src/Server/GameDataStoreService.spec.lua +0 -9
- package/src/Server/InMemoryDataStore.lua +73 -0
- package/src/Server/InMemoryDataStore.spec.lua +231 -0
- package/src/Server/Modules/DataStoreStage.spec.lua +0 -14
- package/src/Server/PlayerDataStoreManager.RemovalCallbacks.spec.lua +0 -11
- package/src/Server/PlayerDataStoreManager.lua +15 -6
- package/src/Server/PlayerDataStoreManager.spec.lua +39 -13
- package/src/Server/PlayerDataStoreService.spec.lua +0 -10
- package/src/Server/PrivateServerDataStoreService.lua +5 -2
- package/src/Server/PrivateServerDataStoreService.spec.lua +0 -9
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,19 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# [13.46.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.45.0...@quenty/datastore@13.46.0) (2026-07-23)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- Add baseline player-mock and support across Nevermore for mocked players. ([567d121](https://github.com/Quenty/NevermoreEngine/commit/567d121ffc014b42391554088189a1a6296dda83))
|
|
11
|
+
- Improve stuff ([ae11166](https://github.com/Quenty/NevermoreEngine/commit/ae1116636cea882a51754b0a74cb5a641bad9ae4))
|
|
12
|
+
|
|
13
|
+
# [13.45.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.44.0...@quenty/datastore@13.45.0) (2026-07-21)
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
- Add ephemeral save slots ([16bd91b](https://github.com/Quenty/NevermoreEngine/commit/16bd91b87943a65165245cba90d44274585903d6))
|
|
18
|
+
|
|
6
19
|
# [13.44.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.43.0...@quenty/datastore@13.44.0) (2026-07-21)
|
|
7
20
|
|
|
8
21
|
### Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quenty/datastore",
|
|
3
|
-
"version": "13.
|
|
3
|
+
"version": "13.46.0",
|
|
4
4
|
"description": "Quenty's Datastore implementation for Roblox",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Roblox",
|
|
@@ -29,26 +29,27 @@
|
|
|
29
29
|
"Quenty"
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@quenty/baseobject": "10.
|
|
33
|
-
"@quenty/bindtocloseservice": "8.
|
|
32
|
+
"@quenty/baseobject": "10.15.0",
|
|
33
|
+
"@quenty/bindtocloseservice": "8.36.0",
|
|
34
34
|
"@quenty/loader": "10.11.0",
|
|
35
|
-
"@quenty/maid": "3.
|
|
35
|
+
"@quenty/maid": "3.11.0",
|
|
36
36
|
"@quenty/math": "2.7.5",
|
|
37
|
-
"@quenty/messagingserviceutils": "7.
|
|
38
|
-
"@quenty/nevermore-test-runner": "1.
|
|
39
|
-
"@quenty/pagesutils": "5.
|
|
40
|
-
"@quenty/
|
|
41
|
-
"@quenty/
|
|
42
|
-
"@quenty/
|
|
43
|
-
"@quenty/
|
|
37
|
+
"@quenty/messagingserviceutils": "7.25.0",
|
|
38
|
+
"@quenty/nevermore-test-runner": "1.5.0",
|
|
39
|
+
"@quenty/pagesutils": "5.22.0",
|
|
40
|
+
"@quenty/playermock": "1.1.0",
|
|
41
|
+
"@quenty/promise": "10.21.0",
|
|
42
|
+
"@quenty/promisemaid": "5.21.0",
|
|
43
|
+
"@quenty/rx": "13.31.0",
|
|
44
|
+
"@quenty/servicebag": "11.20.0",
|
|
44
45
|
"@quenty/signal": "7.13.1",
|
|
45
46
|
"@quenty/symbol": "3.5.2",
|
|
46
47
|
"@quenty/table": "3.9.2",
|
|
47
|
-
"@quenty/valueobject": "13.
|
|
48
|
+
"@quenty/valueobject": "13.34.0",
|
|
48
49
|
"@quentystudios/jest-lua": "3.10.0-quenty.2"
|
|
49
50
|
},
|
|
50
51
|
"publishConfig": {
|
|
51
52
|
"access": "public"
|
|
52
53
|
},
|
|
53
|
-
"gitHead": "
|
|
54
|
+
"gitHead": "1de37218a2bedb8e3f8614a2e09bba9eddc812da"
|
|
54
55
|
}
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
--!nonstrict
|
|
2
2
|
--[[
|
|
3
|
-
Characterizes the shared load promise and the auto-save loop. The load view is cached
|
|
4
|
-
(PromiseViewUpToDate returns one shared promise), so every Load/LoadAll/Store/Observe attaches
|
|
5
|
-
its own continuation to the same promise; these tests pin that a settled load fans out its
|
|
6
|
-
resolution or rejection to every consumer, including ones attached mid-yield.
|
|
7
|
-
|
|
8
3
|
@class DataStoreLoadErrors.spec.lua
|
|
9
4
|
]]
|
|
10
5
|
local require = require(script.Parent.loader).load(script)
|
|
@@ -94,7 +89,6 @@ describe("auto-save loop", function()
|
|
|
94
89
|
local dataStore = controller.newDataStore("key")
|
|
95
90
|
dataStore:SetAutoSaveTimeSeconds(0.2)
|
|
96
91
|
|
|
97
|
-
-- Store triggers the initial load; once loaded, the auto-save loop starts and flushes.
|
|
98
92
|
dataStore:Store("coins", 5)
|
|
99
93
|
|
|
100
94
|
local saved = PromiseTestUtils.awaitValue(function()
|
|
@@ -119,8 +113,6 @@ describe("auto-save loop", function()
|
|
|
119
113
|
|
|
120
114
|
dataStore:Destroy()
|
|
121
115
|
|
|
122
|
-
-- After destroy the loop is gone: record the call count, wait past several auto-save
|
|
123
|
-
-- intervals, and confirm no new saves land.
|
|
124
116
|
local before = controller.mock:GetCallCount("UpdateAsync")
|
|
125
117
|
local settled = PromiseTestUtils.awaitValue(function()
|
|
126
118
|
return false
|
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
--!nonstrict
|
|
2
2
|
--[[
|
|
3
|
-
Integration coverage for the overflow-save failure: when a key accumulates more data than Roblox
|
|
4
|
-
can serialize under its per-key size ceiling, the write throws and the save must fail loudly rather
|
|
5
|
-
than silently drop the data or corrupt what was already stored. Driven through the full DataStore
|
|
6
|
-
stack against a DataStoreMock configured with a small [DataStoreMock.SetMaxValueLength] so the
|
|
7
|
-
overflow triggers without a multi-megabyte payload.
|
|
8
|
-
|
|
9
3
|
@class DataStore.Overflow.spec.lua
|
|
10
4
|
]]
|
|
11
5
|
local require = require(script.Parent.loader).load(script)
|
|
@@ -19,8 +13,6 @@ local describe = Jest.Globals.describe
|
|
|
19
13
|
local expect = Jest.Globals.expect
|
|
20
14
|
local it = Jest.Globals.it
|
|
21
15
|
|
|
22
|
-
-- Asserts the promise settled within the timeout, so a hung promise fails the test (here) instead of
|
|
23
|
-
-- freezing the runner on the following :Yield().
|
|
24
16
|
local function expectSettled(promise, timeout: number?)
|
|
25
17
|
expect(PromiseTestUtils.awaitSettled(promise, timeout)).toEqual(true)
|
|
26
18
|
end
|
|
@@ -49,7 +41,6 @@ describe("DataStore overflow save", function()
|
|
|
49
41
|
writer:Store("data", "known-good")
|
|
50
42
|
expectSettled(writer:Save())
|
|
51
43
|
|
|
52
|
-
-- Now grow the same key past the ceiling and try to save again.
|
|
53
44
|
controller.mock:SetMaxValueLength(1024)
|
|
54
45
|
writer:Store("data", string.rep("A", 8192))
|
|
55
46
|
|
|
@@ -57,7 +48,6 @@ describe("DataStore overflow save", function()
|
|
|
57
48
|
expectSettled(savePromise, 5)
|
|
58
49
|
expect((savePromise:Yield())).toEqual(false)
|
|
59
50
|
|
|
60
|
-
-- The failed write must not have clobbered the stored value.
|
|
61
51
|
controller.mock:SetMaxValueLength(nil)
|
|
62
52
|
local reader = controller.newDataStore()
|
|
63
53
|
local loadPromise = reader:Load("data")
|
|
@@ -95,8 +85,6 @@ describe("DataStore overflow save", function()
|
|
|
95
85
|
|
|
96
86
|
expectSettled(dataStore:Load("data"))
|
|
97
87
|
|
|
98
|
-
-- The whole key value -- every substore merged together -- is what gets serialized, so a single
|
|
99
|
-
-- oversized substore entry overflows the entire save.
|
|
100
88
|
controller.mock:SetMaxValueLength(1024)
|
|
101
89
|
dataStore:GetSubStore("inventory"):Store("blob", string.rep("A", 8192))
|
|
102
90
|
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
--!nonstrict
|
|
2
2
|
--[[
|
|
3
|
-
Re-entrance and cancellation: when a datastore request is in flight (yielding) and its owning
|
|
4
|
-
maid is torn down (the DataStore is destroyed / the player leaves), the request thread must be
|
|
5
|
-
cancelled and never resume to write. A session-locked load can legitimately stay pending for a
|
|
6
|
-
long time while a lock command is outstanding.
|
|
7
|
-
|
|
8
3
|
@class DataStoreReentrance.spec.lua
|
|
9
4
|
]]
|
|
10
5
|
local require = require(script.Parent.loader).load(script)
|
|
@@ -20,18 +15,15 @@ local it = Jest.Globals.it
|
|
|
20
15
|
describe("in-flight request cancellation (maid teardown)", function()
|
|
21
16
|
it("cancels a yielding load thread when the DataStore is destroyed (no leaked thread)", function()
|
|
22
17
|
local controller = DataStoreTestUtils.setup()
|
|
23
|
-
controller.mock:BlockRequests()
|
|
18
|
+
controller.mock:BlockRequests()
|
|
24
19
|
|
|
25
20
|
local dataStore = controller.newSessionLockedStore()
|
|
26
21
|
local promise = dataStore:PromiseLoadSuccessful()
|
|
27
22
|
|
|
28
23
|
expect(PromiseTestUtils.awaitSettled(promise, 1)).toEqual(false)
|
|
29
24
|
|
|
30
|
-
-- Destroy while the request thread is yielding -- its maid must cancel it.
|
|
31
25
|
dataStore:Destroy()
|
|
32
26
|
|
|
33
|
-
-- A properly cancelled thread never resumes, so the lock is never written. A leaked thread
|
|
34
|
-
-- would resume on unblock and write the lock envelope.
|
|
35
27
|
controller.mock:UnblockRequests()
|
|
36
28
|
local everWrote = PromiseTestUtils.awaitValue(function()
|
|
37
29
|
return controller.mock:GetRaw("player_1") ~= nil
|
|
@@ -44,7 +36,6 @@ describe("in-flight request cancellation (maid teardown)", function()
|
|
|
44
36
|
it("cancels a yielding save thread when the DataStore is destroyed", function()
|
|
45
37
|
local controller = DataStoreTestUtils.setup()
|
|
46
38
|
|
|
47
|
-
-- Load cleanly first (acquires the lock), then block so the SAVE request hangs in flight.
|
|
48
39
|
local dataStore = controller.newSessionLockedStore()
|
|
49
40
|
local loadPromise = dataStore:PromiseLoadSuccessful()
|
|
50
41
|
if not PromiseTestUtils.awaitSettled(loadPromise, 10) then
|
|
@@ -62,7 +53,6 @@ describe("in-flight request cancellation (maid teardown)", function()
|
|
|
62
53
|
dataStore:Destroy()
|
|
63
54
|
controller.mock:UnblockRequests()
|
|
64
55
|
|
|
65
|
-
-- The cancelled save thread must not resume and perform another UpdateAsync write.
|
|
66
56
|
local extraWrites = PromiseTestUtils.awaitValue(function()
|
|
67
57
|
return controller.mock:GetCallCount("UpdateAsync") > versionsBefore + 1
|
|
68
58
|
end, 2)
|
|
@@ -75,12 +65,11 @@ end)
|
|
|
75
65
|
describe("lock command that does not settle", function()
|
|
76
66
|
it("keeps the load pending while the lock command is outstanding, then completes when it settles", function()
|
|
77
67
|
local controller = DataStoreTestUtils.setup()
|
|
78
|
-
controller.mock:BlockRequests()
|
|
68
|
+
controller.mock:BlockRequests()
|
|
79
69
|
|
|
80
70
|
local dataStore = controller.newSessionLockedStore()
|
|
81
71
|
local promise = dataStore:PromiseLoadSuccessful()
|
|
82
72
|
|
|
83
|
-
-- Must NOT spuriously resolve or error while the lock command is outstanding.
|
|
84
73
|
expect(PromiseTestUtils.awaitSettled(promise, 2)).toEqual(false)
|
|
85
74
|
|
|
86
75
|
controller.mock:UnblockRequests()
|
|
@@ -101,10 +90,9 @@ describe("lock command that does not settle", function()
|
|
|
101
90
|
local first = controller.newSessionLockedStore()
|
|
102
91
|
local firstPromise = first:PromiseLoadSuccessful()
|
|
103
92
|
expect(PromiseTestUtils.awaitSettled(firstPromise, 1)).toEqual(false)
|
|
104
|
-
first:Destroy()
|
|
93
|
+
first:Destroy()
|
|
105
94
|
controller.mock:UnblockRequests()
|
|
106
95
|
|
|
107
|
-
-- The cancelled attempt left no lock, so a fresh session acquires cleanly.
|
|
108
96
|
local second = controller.newSessionLockedStore()
|
|
109
97
|
local secondPromise = second:PromiseLoadSuccessful()
|
|
110
98
|
if not PromiseTestUtils.awaitSettled(secondPromise, 10) then
|
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
--!nonstrict
|
|
2
2
|
--[[
|
|
3
|
-
Deep characterization of the session-lock state machine (DataStoreLockHelper) and the
|
|
4
|
-
cross-server scenarios it governs: validate/invalidate a lock, steal a crashed session's lock,
|
|
5
|
-
block a live session, and prevent data duplication when a session is stolen. Two servers are
|
|
6
|
-
modeled as two DataStore objects sharing one DataStoreMock, with distinct SessionIds. The
|
|
7
|
-
blocking cases assume RunService:IsStudio() == false (true in the cloud test runner); in Studio,
|
|
8
|
-
ALWAYS_STEAL_LOCKS_IN_STUDIO makes every foreign lock stealable.
|
|
9
|
-
|
|
10
3
|
@class DataStoreSessionLock.spec.lua
|
|
11
4
|
]]
|
|
12
5
|
local require = require(script.Parent.loader).load(script)
|
|
@@ -27,7 +20,6 @@ local function foreignSession(sessionId: string?)
|
|
|
27
20
|
}
|
|
28
21
|
end
|
|
29
22
|
|
|
30
|
-
-- Builds a stored profile locked by an arbitrary session.
|
|
31
23
|
local function lockedBy(session, lastUpdateTime: number?, data: { [string]: any }?)
|
|
32
24
|
local profile = {}
|
|
33
25
|
if data then
|
|
@@ -98,7 +90,6 @@ describe("DataStoreLockHelper.AcquireLock", function()
|
|
|
98
90
|
local result = helper:AcquireLock(lockedBy(foreignSession(), os.time() - 700, { coins = 9 }), false)
|
|
99
91
|
expect(result.isValid).toEqual(true)
|
|
100
92
|
expect(result.stolenLockFromSession.SessionId).toEqual("foreign-session-id")
|
|
101
|
-
-- The crashed session's data survives the steal.
|
|
102
93
|
expect(result.unlockedProfile.coins).toEqual(9)
|
|
103
94
|
controller:destroy()
|
|
104
95
|
end)
|
|
@@ -274,14 +265,12 @@ describe("session lock cross-server scenarios (full DataStore)", function()
|
|
|
274
265
|
it("blocks a new session's load while another session holds a fresh lock", function()
|
|
275
266
|
local controller = DataStoreTestUtils.setup()
|
|
276
267
|
|
|
277
|
-
-- A fresh, live foreign lock is present in the datastore.
|
|
278
268
|
controller.mock:SetRaw("player_1", lockedBy(foreignSession(), os.time(), { coins = 1 }))
|
|
279
269
|
|
|
280
270
|
local dataStore = controller.newDataStore()
|
|
281
271
|
dataStore:SetSessionLockingEnabled(true)
|
|
282
272
|
dataStore:SetUserIdList({ 1 })
|
|
283
273
|
|
|
284
|
-
-- The load is legitimately blocked (retrying to acquire), so it must NOT settle quickly.
|
|
285
274
|
local promise = dataStore:PromiseLoadSuccessful()
|
|
286
275
|
expect(PromiseTestUtils.awaitSettled(promise, 3)).toEqual(false)
|
|
287
276
|
|
|
@@ -289,12 +278,8 @@ describe("session lock cross-server scenarios (full DataStore)", function()
|
|
|
289
278
|
end)
|
|
290
279
|
|
|
291
280
|
it("acquires the lock once the holding session releases it (retry resolves genuine contention)", function()
|
|
292
|
-
-- GUARD for the load-hang fix: a blocked load must still RESOLVE via retry when the holder
|
|
293
|
-
-- releases -- only genuine op FAILURES should fail fast, not lock contention (a successful op
|
|
294
|
-
-- that returns a locked profile).
|
|
295
281
|
local controller = DataStoreTestUtils.setup()
|
|
296
282
|
|
|
297
|
-
-- Session A acquires and holds the lock.
|
|
298
283
|
local sessionA = controller.newDataStore()
|
|
299
284
|
sessionA:SetSessionLockingEnabled(true)
|
|
300
285
|
sessionA:SetUserIdList({ 1 })
|
|
@@ -306,8 +291,6 @@ describe("session lock cross-server scenarios (full DataStore)", function()
|
|
|
306
291
|
end
|
|
307
292
|
expect((loadA:Yield())).toEqual(true)
|
|
308
293
|
|
|
309
|
-
-- Session B starts loading; A's fresh lock blocks it, so B is retrying (not yet settled). Use a
|
|
310
|
-
-- tiny retry backoff so the test exercises the retry quickly instead of the ~6.5s production one.
|
|
311
294
|
local sessionB = controller.newDataStore()
|
|
312
295
|
sessionB:SetSessionLockingEnabled(true)
|
|
313
296
|
sessionB:SetUserIdList({ 1 })
|
|
@@ -315,7 +298,6 @@ describe("session lock cross-server scenarios (full DataStore)", function()
|
|
|
315
298
|
local loadB = sessionB:PromiseLoadSuccessful()
|
|
316
299
|
expect(PromiseTestUtils.awaitSettled(loadB, 0.5)).toEqual(false)
|
|
317
300
|
|
|
318
|
-
-- A releases the lock; B's next retry attempt should acquire it and the load resolves.
|
|
319
301
|
local closeA = sessionA:SaveAndCloseSession()
|
|
320
302
|
if not PromiseTestUtils.awaitSettled(closeA, 10) then
|
|
321
303
|
expect("A close hung").toEqual("A close settled")
|
|
@@ -348,10 +330,8 @@ describe("session lock cross-server scenarios (full DataStore)", function()
|
|
|
348
330
|
end
|
|
349
331
|
expect((loadA:Yield())).toEqual(true)
|
|
350
332
|
|
|
351
|
-
-- Another live session steals the lock and writes its own value directly into the datastore.
|
|
352
333
|
controller.mock:SetRaw("player_1", lockedBy(foreignSession("winner-session"), os.time(), { coins = 20 }))
|
|
353
334
|
|
|
354
|
-
-- Session A tries to save its own (now-orphaned) change.
|
|
355
335
|
sessionA:Store("coins", 10)
|
|
356
336
|
local saveA = sessionA:Save()
|
|
357
337
|
if not PromiseTestUtils.awaitSettled(saveA, 10) then
|
|
@@ -360,8 +340,6 @@ describe("session lock cross-server scenarios (full DataStore)", function()
|
|
|
360
340
|
return
|
|
361
341
|
end
|
|
362
342
|
|
|
363
|
-
-- A's write was cancelled, so the datastore still holds the winner's data -- not A's, and not
|
|
364
|
-
-- a merged/duplicated mix.
|
|
365
343
|
local raw = controller.mock:GetRaw("player_1")
|
|
366
344
|
expect(raw.coins).toEqual(20)
|
|
367
345
|
expect(raw.lock.ActiveSession.SessionId).toEqual("winner-session")
|
|
@@ -387,8 +365,6 @@ describe("session lock edge cases and failure modes", function()
|
|
|
387
365
|
end
|
|
388
366
|
expect((first:Yield())).toEqual(true)
|
|
389
367
|
|
|
390
|
-
-- The load is cached (_firstLoadPromise), so exactly one UpdateAsync acquires the lock -- a
|
|
391
|
-
-- second acquire would risk two "owners" of the session.
|
|
392
368
|
expect(controller.mock:GetCallCount("UpdateAsync")).toEqual(1)
|
|
393
369
|
|
|
394
370
|
controller:destroy()
|
|
@@ -419,8 +395,6 @@ describe("session lock edge cases and failure modes", function()
|
|
|
419
395
|
return
|
|
420
396
|
end
|
|
421
397
|
|
|
422
|
-
-- No corruption/duplication: the stored value is a valid number still owned by us, and the
|
|
423
|
-
-- last staged value won.
|
|
424
398
|
local raw = controller.mock:GetRaw("player_1")
|
|
425
399
|
expect(type(raw.coins)).toEqual("number")
|
|
426
400
|
expect(raw.coins).toEqual(2)
|
|
@@ -432,14 +406,9 @@ end)
|
|
|
432
406
|
|
|
433
407
|
describe("why session locking exists (unlocked stores can duplicate)", function()
|
|
434
408
|
it("allows item duplication under a read-before-store race, without session locking", function()
|
|
435
|
-
-- The classic dupe that session locking prevents: two servers load the same profile, one
|
|
436
|
-
-- trades an item away and saves, but the other loaded BEFORE that save (or is a crashed
|
|
437
|
-
-- server's stale session) and writes its stale view back -- restoring the traded-away item.
|
|
438
|
-
-- This is EXPECTED behavior for unlocked stores; it is the whole motivation for locking.
|
|
439
409
|
local controller = DataStoreTestUtils.setup()
|
|
440
410
|
controller.mock:SetRaw("player_1", { items = { "rare_sword" } })
|
|
441
411
|
|
|
442
|
-
-- Two servers, NO session locking, both load the same starting state (B reads before A stores).
|
|
443
412
|
local serverA = controller.newDataStore()
|
|
444
413
|
local serverB = controller.newDataStore()
|
|
445
414
|
|
|
@@ -453,7 +422,6 @@ describe("why session locking exists (unlocked stores can duplicate)", function(
|
|
|
453
422
|
expect((aLoad:Wait())).toEqual({ "rare_sword" })
|
|
454
423
|
expect((bLoad:Wait())).toEqual({ "rare_sword" })
|
|
455
424
|
|
|
456
|
-
-- Server A: the player trades the sword away, and A saves it gone.
|
|
457
425
|
serverA:Store("items", {})
|
|
458
426
|
if not PromiseTestUtils.awaitSettled(serverA:Save(), 5) then
|
|
459
427
|
expect("A save hung").toEqual("A save settled")
|
|
@@ -462,7 +430,6 @@ describe("why session locking exists (unlocked stores can duplicate)", function(
|
|
|
462
430
|
end
|
|
463
431
|
expect(controller.mock:GetRaw("player_1").items).toEqual({})
|
|
464
432
|
|
|
465
|
-
-- Server B still holds the stale sword and writes it back on its own save.
|
|
466
433
|
serverB:Store("items", { "rare_sword" })
|
|
467
434
|
if not PromiseTestUtils.awaitSettled(serverB:Save(), 5) then
|
|
468
435
|
expect("B save hung").toEqual("B save settled")
|
|
@@ -470,7 +437,6 @@ describe("why session locking exists (unlocked stores can duplicate)", function(
|
|
|
470
437
|
return
|
|
471
438
|
end
|
|
472
439
|
|
|
473
|
-
-- The traded-away sword is back: duplicated. An unlocked store cannot prevent this.
|
|
474
440
|
expect(controller.mock:GetRaw("player_1").items).toEqual({ "rare_sword" })
|
|
475
441
|
|
|
476
442
|
controller:destroy()
|
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
--!nonstrict
|
|
2
2
|
--[[
|
|
3
|
-
Two servers fighting over one player's session-locked key: two DataStore objects (server A and
|
|
4
|
-
server B) share one DataStoreMock with distinct SessionIds, exactly like two live game servers.
|
|
5
|
-
This exercises the full cross-server lock lifecycle -- clean handoff via close, crash recovery via
|
|
6
|
-
stale-lock steal, a concurrent-load race, the MessagingService graceful-close handshake, and
|
|
7
|
-
session-stolen data integrity.
|
|
8
|
-
|
|
9
3
|
@class DataStoreTwoServerLock.spec.lua
|
|
10
4
|
]]
|
|
11
5
|
local require = require(script.Parent.loader).load(script)
|
|
@@ -49,7 +43,6 @@ describe("two servers: clean handoff and crash recovery", function()
|
|
|
49
43
|
it("recovers a crashed server's saved data by stealing its stale lock", function()
|
|
50
44
|
local controller = DataStoreTestUtils.setup()
|
|
51
45
|
|
|
52
|
-
-- A acquires, saves coins under its lock, then "crashes" (no clean close).
|
|
53
46
|
local serverA = controller.newServer()
|
|
54
47
|
expect(controller.awaitOwn(serverA)).toEqual(true)
|
|
55
48
|
serverA:Store("coins", 7)
|
|
@@ -59,12 +52,10 @@ describe("two servers: clean handoff and crash recovery", function()
|
|
|
59
52
|
return
|
|
60
53
|
end
|
|
61
54
|
|
|
62
|
-
-- Age A's lock so it looks like a long-dead (crashed) server, then abandon A.
|
|
63
55
|
local raw = controller.mock:GetRaw(KEY)
|
|
64
56
|
raw.lock.LastUpdateTime = os.time() - 1000000
|
|
65
57
|
controller.mock:SetRaw(KEY, raw)
|
|
66
58
|
|
|
67
|
-
-- B loads: steals the stale lock and recovers A's saved coins.
|
|
68
59
|
local serverB = controller.newServer()
|
|
69
60
|
local coins = serverB:Load("coins")
|
|
70
61
|
if not PromiseTestUtils.awaitSettled(coins, 10) then
|
|
@@ -87,14 +78,13 @@ describe("two servers: clean handoff and crash recovery", function()
|
|
|
87
78
|
local loadA = serverA:PromiseLoadSuccessful()
|
|
88
79
|
local loadB = serverB:PromiseLoadSuccessful()
|
|
89
80
|
|
|
90
|
-
-- One acquires quickly; the other is blocked (retrying against the winner's fresh lock).
|
|
91
81
|
expect(PromiseTestUtils.awaitValue(function()
|
|
92
82
|
return not loadA:IsPending() or not loadB:IsPending()
|
|
93
83
|
end, 5)).toEqual(true)
|
|
94
84
|
|
|
95
85
|
local aSettled = not loadA:IsPending()
|
|
96
86
|
local bSettled = not loadB:IsPending()
|
|
97
|
-
expect(aSettled ~= bSettled).toEqual(true)
|
|
87
|
+
expect(aSettled ~= bSettled).toEqual(true)
|
|
98
88
|
|
|
99
89
|
local owner = controller.mock:GetRaw(KEY).lock.ActiveSession.SessionId
|
|
100
90
|
expect(owner == serverA:GetSessionId() or owner == serverB:GetSessionId()).toEqual(true)
|
|
@@ -108,7 +98,6 @@ describe("two servers: clean handoff and crash recovery", function()
|
|
|
108
98
|
local serverA = controller.newServer()
|
|
109
99
|
expect(controller.awaitOwn(serverA)).toEqual(true)
|
|
110
100
|
|
|
111
|
-
-- B takes over by writing its own lock + data directly (as if it stole the session).
|
|
112
101
|
controller.mock:SetRaw(KEY, {
|
|
113
102
|
coins = 100,
|
|
114
103
|
lock = {
|
|
@@ -117,7 +106,6 @@ describe("two servers: clean handoff and crash recovery", function()
|
|
|
117
106
|
},
|
|
118
107
|
})
|
|
119
108
|
|
|
120
|
-
-- A tries to save its own value; the write is cancelled (SessionStolen), B's data survives.
|
|
121
109
|
local stolen = nil
|
|
122
110
|
serverA.SessionStolen:Connect(function(session)
|
|
123
111
|
stolen = session
|
|
@@ -145,7 +133,6 @@ describe("two servers: MessagingService graceful close", function()
|
|
|
145
133
|
|
|
146
134
|
local _serverB, helperB = controller.newServer({ messaging = true })
|
|
147
135
|
|
|
148
|
-
-- B gracefully asks A (the lock holder) to close.
|
|
149
136
|
local graceful = helperB:PromiseCloseSessionGraceful(game.PlaceId, game.JobId, serverA:GetSessionId())
|
|
150
137
|
if not PromiseTestUtils.awaitSettled(graceful, 15) then
|
|
151
138
|
expect("graceful close hung").toEqual("resolved")
|
|
@@ -154,7 +141,6 @@ describe("two servers: MessagingService graceful close", function()
|
|
|
154
141
|
end
|
|
155
142
|
expect((graceful:Yield())).toEqual(true)
|
|
156
143
|
|
|
157
|
-
-- A honored the request and released its lock.
|
|
158
144
|
expect(PromiseTestUtils.awaitValue(function()
|
|
159
145
|
local raw = controller.mock:GetRaw(KEY)
|
|
160
146
|
return raw ~= nil and raw.lock == nil
|
|
@@ -166,13 +152,9 @@ describe("two servers: MessagingService graceful close", function()
|
|
|
166
152
|
it("evicts the holder during a messaging-enabled load and then acquires (production flow)", function()
|
|
167
153
|
local controller = DataStoreTestUtils.setup()
|
|
168
154
|
|
|
169
|
-
-- A holds the lock and will honor a graceful close request.
|
|
170
155
|
local serverA = controller.newServer({ messaging = true, autoCloseOnRequest = true })
|
|
171
156
|
expect(controller.awaitOwn(serverA)).toEqual(true)
|
|
172
157
|
|
|
173
|
-
-- B loads with messaging enabled: blocked by A's fresh lock, its load asks A to close, then
|
|
174
|
-
-- (after the propagation delay) retries and acquires. This is the real cross-server handoff.
|
|
175
|
-
-- Use a tiny propagation delay so the test does not wait the production 5s.
|
|
176
158
|
local serverB = controller.newServer({ messaging = true })
|
|
177
159
|
serverB:SetSessionMessagingCloseDelaySeconds(0.1)
|
|
178
160
|
local loadB = serverB:PromiseLoadSuccessful()
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
--!nonstrict
|
|
2
2
|
--[[
|
|
3
|
-
Integration coverage for DataStore against a mocked Roblox datastore: the load/save round-trip
|
|
4
|
-
and how it behaves when datastore operations fail (e.g. the 509 Personal-RCC block).
|
|
5
|
-
|
|
6
3
|
@class DataStore.spec.lua
|
|
7
4
|
]]
|
|
8
5
|
local require = require(script.Parent.loader).load(script)
|
|
@@ -16,8 +13,6 @@ local describe = Jest.Globals.describe
|
|
|
16
13
|
local expect = Jest.Globals.expect
|
|
17
14
|
local it = Jest.Globals.it
|
|
18
15
|
|
|
19
|
-
-- Asserts the promise settled within the timeout, so a hung promise fails the test (here) instead of
|
|
20
|
-
-- freezing the runner on the following :Yield().
|
|
21
16
|
local function expectSettled(promise, timeout: number?)
|
|
22
17
|
expect(PromiseTestUtils.awaitSettled(promise, timeout)).toEqual(true)
|
|
23
18
|
end
|
|
@@ -225,8 +220,6 @@ describe("DataStore with session locking", function()
|
|
|
225
220
|
dataStore:SetSessionLockingEnabled(true)
|
|
226
221
|
dataStore:SetUserIdList({ 1 })
|
|
227
222
|
|
|
228
|
-
-- PromiseLoadSuccessful maps the error to a boolean, so read the rejection from
|
|
229
|
-
-- PromiseViewUpToDate to assert the underlying datastore error propagates.
|
|
230
223
|
local outcome, err = PromiseTestUtils.awaitOutcome(dataStore:PromiseViewUpToDate())
|
|
231
224
|
|
|
232
225
|
expect(outcome).toEqual("rejected")
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
--!nonstrict
|
|
2
2
|
--[[
|
|
3
|
-
Characterization coverage for DataStore session locking, exercised through a real DataStore
|
|
4
|
-
against a mocked Roblox datastore. It pins the observable behaviour of the lock lifecycle: a
|
|
5
|
-
healthy acquire wraps the stored profile in a lock envelope, SaveAndCloseSession releases it,
|
|
6
|
-
user data survives a lock/unlock round-trip, and a stale lock left by a dead session is stolen.
|
|
7
|
-
|
|
8
3
|
@class DataStoreLockHelper.spec.lua
|
|
9
4
|
]]
|
|
10
5
|
local require = require(script.Parent.loader).load(script)
|
|
@@ -68,7 +63,6 @@ describe("DataStore session locking", function()
|
|
|
68
63
|
end
|
|
69
64
|
expect((closePromise:Yield())).toEqual(true)
|
|
70
65
|
|
|
71
|
-
-- Closing strips the lock envelope back off the stored value.
|
|
72
66
|
local raw = controller.mock:GetRaw("player_1")
|
|
73
67
|
expect(type(raw)).toEqual("table")
|
|
74
68
|
expect(raw.lock).toEqual(nil)
|
|
@@ -125,10 +119,6 @@ describe("DataStore session locking", function()
|
|
|
125
119
|
it("steals a stale lock left by a dead session", function()
|
|
126
120
|
local controller = DataStoreTestUtils.setup()
|
|
127
121
|
|
|
128
|
-
-- Seed a lock owned by a long-dead session. LastUpdateTime is far enough in the past that
|
|
129
|
-
-- os.time() - LastUpdateTime exceeds GetAutoSaveTimeSeconds() * 2.1 (default 300 * 2.1 = 630s),
|
|
130
|
-
-- so the lock is stolen on the first acquire attempt (no retry backoff). Seed user data too,
|
|
131
|
-
-- to prove it survives the steal.
|
|
132
122
|
controller.mock:SetRaw("player_1", {
|
|
133
123
|
coins = 7,
|
|
134
124
|
lock = {
|
|
@@ -156,7 +146,6 @@ describe("DataStore session locking", function()
|
|
|
156
146
|
local raw = controller.mock:GetRaw("player_1")
|
|
157
147
|
expect(raw.lock.ActiveSession.SessionId).toEqual(dataStore:GetSessionId())
|
|
158
148
|
|
|
159
|
-
-- The dead session's user data survived the steal.
|
|
160
149
|
local loadPromise = dataStore:Load("coins")
|
|
161
150
|
if not PromiseTestUtils.awaitSettled(loadPromise, 10) then
|
|
162
151
|
expect("hung").toEqual("settled")
|
|
@@ -183,7 +172,6 @@ describe("DataStore session locking", function()
|
|
|
183
172
|
end
|
|
184
173
|
expect((loadPromise:Yield())).toEqual(true)
|
|
185
174
|
|
|
186
|
-
-- Another session steals the lock out from under us directly in the datastore.
|
|
187
175
|
controller.mock:SetRaw("player_1", {
|
|
188
176
|
coins = 1,
|
|
189
177
|
lock = {
|
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
--!nonstrict
|
|
2
2
|
--[[
|
|
3
|
-
Coverage for DataStoreMessageHelper, the coordinator that drives graceful cross-server
|
|
4
|
-
session-close over MessagingService. The full request/complete handshake needs a second server
|
|
5
|
-
to answer, so cross-server behaviour is characterized rather than asserted; what a single server
|
|
6
|
-
can verify (construction, the "cannot message self" guard, messaging wiring, and the
|
|
7
|
-
MessagingServiceUtils.toHumanReadable formatter) is exercised here.
|
|
8
|
-
|
|
9
3
|
@class DataStoreMessageHelper.spec.lua
|
|
10
4
|
]]
|
|
11
5
|
local require = require(script.Parent.loader).load(script)
|
|
@@ -53,7 +47,6 @@ describe("DataStoreMessageHelper.new", function()
|
|
|
53
47
|
local controller = DataStoreTestUtils.setup()
|
|
54
48
|
local helper = controller.newMessageHelper()
|
|
55
49
|
|
|
56
|
-
-- The test destroys the helper itself; the maid then skips it, so there is no double-Destroy.
|
|
57
50
|
expect(function()
|
|
58
51
|
helper:Destroy()
|
|
59
52
|
end).never.toThrow()
|
|
@@ -81,8 +74,6 @@ end)
|
|
|
81
74
|
|
|
82
75
|
describe("DataStoreMessageHelper.PromiseCloseSessionGraceful", function()
|
|
83
76
|
it("should return a promise (cross-server outcome is not unit-testable single-server)", function()
|
|
84
|
-
-- The graceful handshake needs a second server to answer, so we only assert it returns a
|
|
85
|
-
-- promise; its settlement is environment dependent and covered by real multi-server usage.
|
|
86
77
|
local controller = DataStoreTestUtils.setup()
|
|
87
78
|
local helper = controller.newMessageHelper()
|
|
88
79
|
|
|
@@ -97,7 +88,6 @@ describe("DataStore.SetSessionMessagingEnabled wiring", function()
|
|
|
97
88
|
it("should enable then disable session messaging without erroring", function()
|
|
98
89
|
local controller = DataStoreTestUtils.setup()
|
|
99
90
|
local dataStore = controller.newDataStore()
|
|
100
|
-
-- Messaging is documented to work alongside session locking; enable both like real usage.
|
|
101
91
|
dataStore:SetSessionLockingEnabled(true)
|
|
102
92
|
dataStore:SetUserIdList({ 1 })
|
|
103
93
|
|
|
@@ -83,8 +83,11 @@ function GameDataStoreService.PromiseDataStore(self: GameDataStoreService): Prom
|
|
|
83
83
|
if not dataStore.Destroy then
|
|
84
84
|
return
|
|
85
85
|
end
|
|
86
|
-
--
|
|
87
|
-
|
|
86
|
+
-- A failed load makes Save() reject unconditionally; skip it so teardown does not
|
|
87
|
+
-- manufacture a guaranteed rejection.
|
|
88
|
+
if not dataStore:DidLoadFail() then
|
|
89
|
+
dataStore:Save()
|
|
90
|
+
end
|
|
88
91
|
dataStore:Destroy()
|
|
89
92
|
end)
|
|
90
93
|
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
--!nonstrict
|
|
2
2
|
--[[
|
|
3
|
-
Integration coverage for GameDataStoreService wired through a real ServiceBag, with the underlying
|
|
4
|
-
datastore injected via the SetRobloxDataStore test seam. It is not session-locking, so a failing
|
|
5
|
-
load rejects promptly rather than hanging.
|
|
6
|
-
|
|
7
3
|
@class GameDataStoreService.spec.lua
|
|
8
4
|
]]
|
|
9
5
|
local require = require(script.Parent.loader).load(script)
|
|
@@ -98,7 +94,6 @@ describe("GameDataStoreService persistence", function()
|
|
|
98
94
|
end
|
|
99
95
|
expect((savePromise:Yield())).toEqual(true)
|
|
100
96
|
|
|
101
|
-
-- Non-session-locking, so the raw persisted value is the plain data table.
|
|
102
97
|
local raw = controller.mock:GetRaw("version1")
|
|
103
98
|
expect(raw).never.toBeNil()
|
|
104
99
|
expect(raw.motd).toEqual("hello")
|
|
@@ -121,7 +116,6 @@ describe("GameDataStoreService.SetRobloxDataStore", function()
|
|
|
121
116
|
it("should throw on a non-datastore argument", function()
|
|
122
117
|
local controller = setup()
|
|
123
118
|
|
|
124
|
-
-- isDataStore is validated before the already-resolved check, so a bad arg throws regardless.
|
|
125
119
|
expect(function()
|
|
126
120
|
controller.service:SetRobloxDataStore({})
|
|
127
121
|
end).toThrow("Bad robloxDataStore")
|
|
@@ -151,7 +145,6 @@ describe("GameDataStoreService failure handling", function()
|
|
|
151
145
|
local ok, dataStore = promise:Yield()
|
|
152
146
|
expect(ok).toEqual(true)
|
|
153
147
|
|
|
154
|
-
-- Non-session-locking: a failing load rejects promptly, so this settles false rather than hanging.
|
|
155
148
|
local loadPromise = dataStore:PromiseLoadSuccessful()
|
|
156
149
|
if not PromiseTestUtils.awaitSettled(loadPromise, 5) then
|
|
157
150
|
expect("hung").toEqual("settled")
|
|
@@ -204,8 +197,6 @@ describe("GameDataStoreService teardown", function()
|
|
|
204
197
|
|
|
205
198
|
dataStore:Store("motd", "goodbye")
|
|
206
199
|
|
|
207
|
-
-- Destroy must fire a synchronous Save before tearing the store down, so the value is
|
|
208
|
-
-- already in the store the instant destroy() returns -- asserted with no awaiting.
|
|
209
200
|
controller:destroy()
|
|
210
201
|
|
|
211
202
|
local raw = controller.mock:GetRaw("version1")
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[=[
|
|
3
|
+
A [DataStoreStage] root that lives entirely in memory: it is never backed by a Roblox datastore, so
|
|
4
|
+
nothing it holds is ever written or read across sessions. Reads resolve to whatever has been staged in
|
|
5
|
+
memory (defaults when unset) and writes stay local to this object, vanishing when it is destroyed.
|
|
6
|
+
|
|
7
|
+
Every read/write/substore/observe method comes from [DataStoreStage] unchanged -- the base class computes
|
|
8
|
+
its view purely from in-memory snapshots. The only thing a stage normally needs a parent for is
|
|
9
|
+
[DataStoreStage.PromiseViewUpToDate] (loading its base layer from the datastore above it); a root has no
|
|
10
|
+
parent, so this class resolves that immediately against its own view. That single override is the whole
|
|
11
|
+
difference between this and [DataStore], which loads/saves through Roblox.
|
|
12
|
+
|
|
13
|
+
Use it wherever code wants a real store surface for data that must not persist -- e.g. a throwaway session
|
|
14
|
+
slot -- without paying for [DataStore]'s load, save, autosave, and session-locking machinery.
|
|
15
|
+
|
|
16
|
+
```lua
|
|
17
|
+
local store = InMemoryDataStore.new()
|
|
18
|
+
store:Store("coins", 5)
|
|
19
|
+
print(store:Load("coins"):Yield()) -- 5, never touches a datastore
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
@server
|
|
23
|
+
@class InMemoryDataStore
|
|
24
|
+
]=]
|
|
25
|
+
|
|
26
|
+
local require = require(script.Parent.loader).load(script)
|
|
27
|
+
|
|
28
|
+
local DataStoreStage = require("DataStoreStage")
|
|
29
|
+
local Promise = require("Promise")
|
|
30
|
+
|
|
31
|
+
local InMemoryDataStore = setmetatable({}, DataStoreStage)
|
|
32
|
+
InMemoryDataStore.ClassName = "InMemoryDataStore"
|
|
33
|
+
InMemoryDataStore.__index = InMemoryDataStore
|
|
34
|
+
|
|
35
|
+
export type InMemoryDataStore =
|
|
36
|
+
typeof(setmetatable({} :: {}, {} :: typeof({ __index = InMemoryDataStore })))
|
|
37
|
+
& DataStoreStage.DataStoreStage
|
|
38
|
+
|
|
39
|
+
--[=[
|
|
40
|
+
Constructs a new in-memory data store.
|
|
41
|
+
|
|
42
|
+
@param loadName (string | number)? -- diagnostic name only (see [DataStoreStage.GetFullPath]); defaults to "InMemoryDataStore"
|
|
43
|
+
@return InMemoryDataStore
|
|
44
|
+
]=]
|
|
45
|
+
function InMemoryDataStore.new(loadName: (string | number)?): InMemoryDataStore
|
|
46
|
+
local self: InMemoryDataStore =
|
|
47
|
+
setmetatable(DataStoreStage.new(loadName or "InMemoryDataStore") :: any, InMemoryDataStore)
|
|
48
|
+
|
|
49
|
+
return self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
--[=[
|
|
53
|
+
The view is always exactly what has been staged in memory, so it is never out of date: there is no
|
|
54
|
+
parent or datastore to sync from. Overriding this (the base class errors on a parentless stage) is what
|
|
55
|
+
makes every inherited read work in-memory.
|
|
56
|
+
|
|
57
|
+
@return Promise
|
|
58
|
+
]=]
|
|
59
|
+
function InMemoryDataStore.PromiseViewUpToDate(_self: InMemoryDataStore): Promise.Promise<()>
|
|
60
|
+
return Promise.resolved()
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
--[=[
|
|
64
|
+
A no-op that resolves: there is no backing datastore to flush to. Present so this is a drop-in root for
|
|
65
|
+
code that expects to be able to call `:Save()` on its store.
|
|
66
|
+
|
|
67
|
+
@return Promise
|
|
68
|
+
]=]
|
|
69
|
+
function InMemoryDataStore.Save(_self: InMemoryDataStore): Promise.Promise<()>
|
|
70
|
+
return Promise.resolved()
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
return InMemoryDataStore
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
--!nonstrict
|
|
2
|
+
--[[
|
|
3
|
+
@class InMemoryDataStore.spec.lua
|
|
4
|
+
]]
|
|
5
|
+
local require = require(script.Parent.loader).load(script)
|
|
6
|
+
|
|
7
|
+
local DataStoreTestUtils = require("DataStoreTestUtils")
|
|
8
|
+
local InMemoryDataStore = require("InMemoryDataStore")
|
|
9
|
+
local Jest = require("Jest")
|
|
10
|
+
local Maid = require("Maid")
|
|
11
|
+
local PromiseTestUtils = require("PromiseTestUtils")
|
|
12
|
+
|
|
13
|
+
local describe = Jest.Globals.describe
|
|
14
|
+
local expect = Jest.Globals.expect
|
|
15
|
+
local it = Jest.Globals.it
|
|
16
|
+
|
|
17
|
+
local function resolve(promise, timeout: number?)
|
|
18
|
+
expect(PromiseTestUtils.awaitSettled(promise, timeout or 10)).toEqual(true)
|
|
19
|
+
local ok, value = promise:Yield()
|
|
20
|
+
expect(ok).toEqual(true)
|
|
21
|
+
return value
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
local function newInMemoryController()
|
|
25
|
+
local maid = Maid.new()
|
|
26
|
+
return {
|
|
27
|
+
makeStore = function()
|
|
28
|
+
return maid:Add(InMemoryDataStore.new())
|
|
29
|
+
end,
|
|
30
|
+
destroy = function()
|
|
31
|
+
maid:DoCleaning()
|
|
32
|
+
end,
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
local function newDataStoreController()
|
|
37
|
+
local controller = DataStoreTestUtils.setup()
|
|
38
|
+
return {
|
|
39
|
+
makeStore = function()
|
|
40
|
+
return controller.newDataStore()
|
|
41
|
+
end,
|
|
42
|
+
destroy = controller.destroy,
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
local function describeSharedBehavior(caseName: string, newController)
|
|
47
|
+
describe(caseName, function()
|
|
48
|
+
it("loads the default value when the key is empty", function()
|
|
49
|
+
local c = newController()
|
|
50
|
+
expect(resolve(c.makeStore():Load("coins", 99))).toEqual(99)
|
|
51
|
+
c.destroy()
|
|
52
|
+
end)
|
|
53
|
+
|
|
54
|
+
it("round-trips a stored value", function()
|
|
55
|
+
local c = newController()
|
|
56
|
+
local store = c.makeStore()
|
|
57
|
+
store:Store("coins", 5)
|
|
58
|
+
expect(resolve(store:Load("coins"))).toEqual(5)
|
|
59
|
+
c.destroy()
|
|
60
|
+
end)
|
|
61
|
+
|
|
62
|
+
it("round-trips multiple keys and loads defaults for missing ones", function()
|
|
63
|
+
local c = newController()
|
|
64
|
+
local store = c.makeStore()
|
|
65
|
+
store:Store("coins", 5)
|
|
66
|
+
store:Store("gems", 10)
|
|
67
|
+
|
|
68
|
+
local all = resolve(store:LoadAll())
|
|
69
|
+
expect(all.coins).toEqual(5)
|
|
70
|
+
expect(all.gems).toEqual(10)
|
|
71
|
+
expect(resolve(store:Load("missing", "default"))).toEqual("default")
|
|
72
|
+
c.destroy()
|
|
73
|
+
end)
|
|
74
|
+
|
|
75
|
+
it("deletes a key so it no longer loads", function()
|
|
76
|
+
local c = newController()
|
|
77
|
+
local store = c.makeStore()
|
|
78
|
+
store:Store("a", 1)
|
|
79
|
+
store:Store("b", 2)
|
|
80
|
+
store:Delete("a")
|
|
81
|
+
|
|
82
|
+
local all = resolve(store:LoadAll())
|
|
83
|
+
expect(all.a).toEqual(nil)
|
|
84
|
+
expect(all.b).toEqual(2)
|
|
85
|
+
c.destroy()
|
|
86
|
+
end)
|
|
87
|
+
|
|
88
|
+
it("overwrites the whole view", function()
|
|
89
|
+
local c = newController()
|
|
90
|
+
local store = c.makeStore()
|
|
91
|
+
store:Store("a", 1)
|
|
92
|
+
store:Store("b", 2)
|
|
93
|
+
store:Overwrite({ c = 3 })
|
|
94
|
+
|
|
95
|
+
local all = resolve(store:LoadAll())
|
|
96
|
+
expect(all.a).toEqual(nil)
|
|
97
|
+
expect(all.b).toEqual(nil)
|
|
98
|
+
expect(all.c).toEqual(3)
|
|
99
|
+
c.destroy()
|
|
100
|
+
end)
|
|
101
|
+
|
|
102
|
+
it("wipes to empty", function()
|
|
103
|
+
local c = newController()
|
|
104
|
+
local store = c.makeStore()
|
|
105
|
+
store:Store("a", 1)
|
|
106
|
+
store:Wipe()
|
|
107
|
+
expect(resolve(store:LoadAll({}))).toEqual({})
|
|
108
|
+
c.destroy()
|
|
109
|
+
end)
|
|
110
|
+
|
|
111
|
+
it("round-trips substore values and nests them under the parent", function()
|
|
112
|
+
local c = newController()
|
|
113
|
+
local store = c.makeStore()
|
|
114
|
+
store:GetSubStore("inventory"):Store("sword", true)
|
|
115
|
+
|
|
116
|
+
expect(resolve(store:GetSubStore("inventory"):Load("sword"))).toEqual(true)
|
|
117
|
+
|
|
118
|
+
local all = resolve(store:LoadAll())
|
|
119
|
+
expect(all.inventory.sword).toEqual(true)
|
|
120
|
+
c.destroy()
|
|
121
|
+
end)
|
|
122
|
+
|
|
123
|
+
it("lists the top-level keys", function()
|
|
124
|
+
local c = newController()
|
|
125
|
+
local store = c.makeStore()
|
|
126
|
+
store:Store("a", 1)
|
|
127
|
+
store:Store("b", 2)
|
|
128
|
+
|
|
129
|
+
local keys = resolve(store:PromiseKeyList())
|
|
130
|
+
table.sort(keys)
|
|
131
|
+
expect(keys).toEqual({ "a", "b" })
|
|
132
|
+
c.destroy()
|
|
133
|
+
end)
|
|
134
|
+
|
|
135
|
+
it("stores table values by deep copy, immune to later mutation of the source", function()
|
|
136
|
+
local c = newController()
|
|
137
|
+
local store = c.makeStore()
|
|
138
|
+
local source = { count = 1 }
|
|
139
|
+
store:Store("data", source)
|
|
140
|
+
source.count = 999
|
|
141
|
+
|
|
142
|
+
expect(resolve(store:Load("data")).count).toEqual(1)
|
|
143
|
+
c.destroy()
|
|
144
|
+
end)
|
|
145
|
+
|
|
146
|
+
it("observes a key: emits the initial value then updates on store", function()
|
|
147
|
+
local c = newController()
|
|
148
|
+
local store = c.makeStore()
|
|
149
|
+
|
|
150
|
+
local maid = Maid.new()
|
|
151
|
+
local seen = {}
|
|
152
|
+
maid:GiveTask(store:Observe("coins", 0):Subscribe(function(value)
|
|
153
|
+
table.insert(seen, value)
|
|
154
|
+
end))
|
|
155
|
+
|
|
156
|
+
expect(PromiseTestUtils.awaitValue(function()
|
|
157
|
+
return #seen >= 1
|
|
158
|
+
end, 5)).toEqual(true)
|
|
159
|
+
expect(seen[1]).toEqual(0)
|
|
160
|
+
|
|
161
|
+
store:Store("coins", 7)
|
|
162
|
+
expect(PromiseTestUtils.awaitValue(function()
|
|
163
|
+
return seen[#seen] == 7
|
|
164
|
+
end, 5)).toEqual(true)
|
|
165
|
+
|
|
166
|
+
maid:DoCleaning()
|
|
167
|
+
c.destroy()
|
|
168
|
+
end)
|
|
169
|
+
end)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
describeSharedBehavior("matrix: DataStore over DataStoreMock", newDataStoreController)
|
|
173
|
+
describeSharedBehavior("matrix: InMemoryDataStore", newInMemoryController)
|
|
174
|
+
|
|
175
|
+
describe("matrix: cross-implementation consistency", function()
|
|
176
|
+
it("yields the same view for the same op sequence on both roots", function()
|
|
177
|
+
local dataStoreController = newDataStoreController()
|
|
178
|
+
local inMemoryController = newInMemoryController()
|
|
179
|
+
|
|
180
|
+
local function runOps(store)
|
|
181
|
+
store:Store("coins", 5)
|
|
182
|
+
store:Store("gems", 10)
|
|
183
|
+
store:GetSubStore("inventory"):Store("sword", true)
|
|
184
|
+
store:Delete("gems")
|
|
185
|
+
return resolve(store:LoadAll())
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
local persistedView = runOps(dataStoreController.makeStore())
|
|
189
|
+
local inMemoryView = runOps(inMemoryController.makeStore())
|
|
190
|
+
|
|
191
|
+
expect(inMemoryView).toEqual(persistedView)
|
|
192
|
+
|
|
193
|
+
dataStoreController.destroy()
|
|
194
|
+
inMemoryController.destroy()
|
|
195
|
+
end)
|
|
196
|
+
end)
|
|
197
|
+
|
|
198
|
+
describe("InMemoryDataStore isolation and non-persistence", function()
|
|
199
|
+
it("does not share data between separate instances", function()
|
|
200
|
+
local maid = Maid.new()
|
|
201
|
+
local first = maid:Add(InMemoryDataStore.new())
|
|
202
|
+
first:Store("coins", 5)
|
|
203
|
+
|
|
204
|
+
local second = maid:Add(InMemoryDataStore.new())
|
|
205
|
+
expect(resolve(second:Load("coins", 0))).toEqual(0)
|
|
206
|
+
|
|
207
|
+
maid:DoCleaning()
|
|
208
|
+
end)
|
|
209
|
+
|
|
210
|
+
it("resolves Save as a no-op", function()
|
|
211
|
+
local maid = Maid.new()
|
|
212
|
+
local store = maid:Add(InMemoryDataStore.new())
|
|
213
|
+
store:Store("coins", 5)
|
|
214
|
+
|
|
215
|
+
expect(resolve(store:Save())).toEqual(nil)
|
|
216
|
+
expect(resolve(store:Load("coins"))).toEqual(5)
|
|
217
|
+
|
|
218
|
+
maid:DoCleaning()
|
|
219
|
+
end)
|
|
220
|
+
|
|
221
|
+
it("resolves reads immediately with no parent to sync from", function()
|
|
222
|
+
local maid = Maid.new()
|
|
223
|
+
local store = maid:Add(InMemoryDataStore.new())
|
|
224
|
+
|
|
225
|
+
expect(resolve(store:LoadAll({}))).toEqual({})
|
|
226
|
+
|
|
227
|
+
maid:DoCleaning()
|
|
228
|
+
end)
|
|
229
|
+
end)
|
|
230
|
+
|
|
231
|
+
return nil
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
--!nonstrict
|
|
2
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
3
|
@class DataStoreStage.spec.lua
|
|
8
4
|
]]
|
|
9
5
|
local require = require(script.Parent.loader).load(script)
|
|
@@ -154,12 +150,10 @@ describe("DataStoreStage staging (through a DataStore root)", function()
|
|
|
154
150
|
describe("View priority", function()
|
|
155
151
|
it("should prioritize staged save data over loaded base data", function()
|
|
156
152
|
local mock = DataStoreMock.new()
|
|
157
|
-
-- Seed base data BEFORE constructing the reader so it loads through getAsync.
|
|
158
153
|
mock:SetRaw("player_1", { coins = 1, gems = 2 })
|
|
159
154
|
|
|
160
155
|
local dataStore = DataStore.new(mock, "player_1")
|
|
161
156
|
|
|
162
|
-
-- Base data loads first
|
|
163
157
|
local basePromise = dataStore:Load("coins")
|
|
164
158
|
if not PromiseTestUtils.awaitSettled(basePromise) then
|
|
165
159
|
expect("hung").toEqual("settled")
|
|
@@ -167,7 +161,6 @@ describe("DataStoreStage staging (through a DataStore root)", function()
|
|
|
167
161
|
end
|
|
168
162
|
expect((basePromise:Wait())).toEqual(1)
|
|
169
163
|
|
|
170
|
-
-- Staged value overrides base; untouched base key stays visible
|
|
171
164
|
dataStore:Store("coins", 999)
|
|
172
165
|
|
|
173
166
|
local promise = dataStore:LoadAll()
|
|
@@ -228,7 +221,6 @@ describe("DataStoreStage staging (through a DataStore root)", function()
|
|
|
228
221
|
|
|
229
222
|
local dataStore = DataStore.new(mock, "player_1")
|
|
230
223
|
|
|
231
|
-
-- Force base to load first
|
|
232
224
|
local basePromise = dataStore:Load("coins")
|
|
233
225
|
if not PromiseTestUtils.awaitSettled(basePromise) then
|
|
234
226
|
expect("hung").toEqual("settled")
|
|
@@ -347,8 +339,6 @@ describe("DataStoreStage staging (through a DataStore root)", function()
|
|
|
347
339
|
it("should fire the whole-view observer with the new snapshot after a Store", function()
|
|
348
340
|
local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
|
|
349
341
|
|
|
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
342
|
local emissions = 0
|
|
353
343
|
local captured
|
|
354
344
|
local sub = dataStore:Observe():Subscribe(function(snapshot)
|
|
@@ -404,7 +394,6 @@ describe("DataStoreStage staging (through a DataStore root)", function()
|
|
|
404
394
|
local ok, list = promise:Yield()
|
|
405
395
|
expect(ok).toEqual(true)
|
|
406
396
|
|
|
407
|
-
-- Order is not guaranteed; compare as a set
|
|
408
397
|
local asSet = {}
|
|
409
398
|
for _, key in list do
|
|
410
399
|
asSet[key] = true
|
|
@@ -419,7 +408,6 @@ describe("DataStoreStage staging (through a DataStore root)", function()
|
|
|
419
408
|
it("should stage the value whenever the ValueObject changes", function()
|
|
420
409
|
local dataStore = DataStore.new(DataStoreMock.new(), "player_1")
|
|
421
410
|
|
|
422
|
-
-- Ensure the stage is loaded before wiring up the value object
|
|
423
411
|
if not PromiseTestUtils.awaitSettled(dataStore:Load("level")) then
|
|
424
412
|
expect("hung").toEqual("settled")
|
|
425
413
|
return
|
|
@@ -428,7 +416,6 @@ describe("DataStoreStage staging (through a DataStore root)", function()
|
|
|
428
416
|
local valueObject = ValueObject.new(0)
|
|
429
417
|
dataStore:StoreOnValueChange("level", valueObject)
|
|
430
418
|
|
|
431
|
-
-- Construction does not stage; a change does
|
|
432
419
|
valueObject.Value = 7
|
|
433
420
|
|
|
434
421
|
local promise = dataStore:LoadAll()
|
|
@@ -482,7 +469,6 @@ describe("DataStoreStage staging (through a DataStore root)", function()
|
|
|
482
469
|
local input = { count = 1 }
|
|
483
470
|
dataStore:Store("data", input)
|
|
484
471
|
|
|
485
|
-
-- Mutating the caller's table must not change the frozen staged copy
|
|
486
472
|
input.count = 999
|
|
487
473
|
|
|
488
474
|
local promise = dataStore:Load("data")
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
--!nonstrict
|
|
2
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
3
|
@class PlayerDataStoreManager.RemovalCallbacks.spec.lua
|
|
9
4
|
]]
|
|
10
5
|
local require = require(script.Parent.loader).load(script)
|
|
@@ -32,7 +27,6 @@ describe("PlayerDataStoreManager removal matrix (misbehaving removing callbacks)
|
|
|
32
27
|
local raw = controller.mock:GetRaw("user_1")
|
|
33
28
|
return raw ~= nil and raw.coins == 5
|
|
34
29
|
end, 10)).toEqual(true)
|
|
35
|
-
-- SaveAndCloseSession stripped the lock as it wrote.
|
|
36
30
|
expect(controller.mock:GetRaw("user_1").lock).toEqual(nil)
|
|
37
31
|
|
|
38
32
|
controller:destroy()
|
|
@@ -48,8 +42,6 @@ describe("PlayerDataStoreManager removal matrix (misbehaving removing callbacks)
|
|
|
48
42
|
expect(controller.storeAndAwaitLock()).toEqual(true)
|
|
49
43
|
controller.manager:RemovePlayerDataStore(1)
|
|
50
44
|
|
|
51
|
-
-- The rejected callback short-circuits PromiseUtils.all before SaveAndCloseSession: coins are
|
|
52
|
-
-- never persisted, and the lock is never released.
|
|
53
45
|
expect(PromiseTestUtils.awaitValue(function()
|
|
54
46
|
local raw = controller.mock:GetRaw("user_1")
|
|
55
47
|
return raw ~= nil and raw.coins == 5
|
|
@@ -67,7 +59,6 @@ describe("PlayerDataStoreManager removal matrix (misbehaving removing callbacks)
|
|
|
67
59
|
|
|
68
60
|
expect(controller.storeAndAwaitLock()).toEqual(true)
|
|
69
61
|
|
|
70
|
-
-- There is no pcall around removing callbacks, so a synchronous throw escapes removal entirely.
|
|
71
62
|
expect(function()
|
|
72
63
|
controller.manager:RemovePlayerDataStore(1)
|
|
73
64
|
end).toThrow("removing callback boom")
|
|
@@ -85,8 +76,6 @@ describe("PlayerDataStoreManager removal matrix (misbehaving removing callbacks)
|
|
|
85
76
|
expect(controller.storeAndAwaitLock()).toEqual(true)
|
|
86
77
|
controller.manager:RemovePlayerDataStore(1)
|
|
87
78
|
|
|
88
|
-
-- SaveAndCloseSession is gated behind the yielding callback, so neither the save nor the lock
|
|
89
|
-
-- release ever happen.
|
|
90
79
|
expect(PromiseTestUtils.awaitValue(function()
|
|
91
80
|
local raw = controller.mock:GetRaw("user_1")
|
|
92
81
|
return raw ~= nil and raw.coins == 5
|
|
@@ -59,6 +59,7 @@ local BindToCloseService = require("BindToCloseService")
|
|
|
59
59
|
local DataStore = require("DataStore")
|
|
60
60
|
local Maid = require("Maid")
|
|
61
61
|
local PendingPromiseTracker = require("PendingPromiseTracker")
|
|
62
|
+
local PlayerMock = require("PlayerMock")
|
|
62
63
|
local Promise = require("Promise")
|
|
63
64
|
local PromiseUtils = require("PromiseUtils")
|
|
64
65
|
local ServiceBag = require("ServiceBag")
|
|
@@ -164,7 +165,11 @@ function PlayerDataStoreManager._flushAndDestroyAll(self: PlayerDataStoreManager
|
|
|
164
165
|
-- Cast past the DataStore intersection type: the solver otherwise blows up ("code too complex")
|
|
165
166
|
-- resolving :Save()/:Destroy() through it.
|
|
166
167
|
local store = datastore :: any
|
|
167
|
-
|
|
168
|
+
-- A failed load makes Save() reject unconditionally; skip it so teardown does not
|
|
169
|
+
-- manufacture a guaranteed rejection.
|
|
170
|
+
if not store:DidLoadFail() then
|
|
171
|
+
store:Save()
|
|
172
|
+
end
|
|
168
173
|
store:Destroy()
|
|
169
174
|
self._datastores[userId] = nil
|
|
170
175
|
end
|
|
@@ -290,13 +295,17 @@ function PlayerDataStoreManager._promiseWaitForRemoving(
|
|
|
290
295
|
end
|
|
291
296
|
|
|
292
297
|
function PlayerDataStoreManager:_toPlayerUserIdOrError(playerOrUserId: Player | PlayerUserId): PlayerUserId
|
|
293
|
-
if
|
|
294
|
-
return playerOrUserId.UserId
|
|
295
|
-
elseif type(playerOrUserId) == "number" then
|
|
298
|
+
if type(playerOrUserId) == "number" then
|
|
296
299
|
return playerOrUserId :: PlayerUserId
|
|
297
|
-
else
|
|
298
|
-
error("Bad playerOrUserId")
|
|
299
300
|
end
|
|
301
|
+
|
|
302
|
+
assert(
|
|
303
|
+
typeof(playerOrUserId) == "Instance" and (playerOrUserId:IsA("Player") or PlayerMock.isMock(playerOrUserId)),
|
|
304
|
+
"Bad playerOrUserId"
|
|
305
|
+
)
|
|
306
|
+
return (
|
|
307
|
+
if PlayerMock.isMock(playerOrUserId) then PlayerMock.read(playerOrUserId, "UserId") else playerOrUserId.UserId
|
|
308
|
+
) :: PlayerUserId
|
|
300
309
|
end
|
|
301
310
|
|
|
302
311
|
--[=[
|
|
@@ -1,23 +1,18 @@
|
|
|
1
1
|
--!nonstrict
|
|
2
2
|
--[[
|
|
3
|
-
Integration coverage for PlayerDataStoreManager against a mocked Roblox datastore. The manager
|
|
4
|
-
auto-enables session locking and session messaging on every DataStore it creates, so even a load
|
|
5
|
-
does an UpdateAsync round-trip through the mock. Tests use numeric userIds, never real Players.
|
|
6
|
-
|
|
7
3
|
@class PlayerDataStoreManager.spec.lua
|
|
8
4
|
]]
|
|
9
5
|
local require = require(script.Parent.loader).load(script)
|
|
10
6
|
|
|
11
7
|
local DataStoreTestUtils = require("DataStoreTestUtils")
|
|
12
8
|
local Jest = require("Jest")
|
|
9
|
+
local PlayerMock = require("PlayerMock")
|
|
13
10
|
local PromiseTestUtils = require("PromiseTestUtils")
|
|
14
11
|
|
|
15
12
|
local describe = Jest.Globals.describe
|
|
16
13
|
local expect = Jest.Globals.expect
|
|
17
14
|
local it = Jest.Globals.it
|
|
18
15
|
|
|
19
|
-
-- Asserts the promise settled within the timeout and returns whether it is now safe to :Yield(), so
|
|
20
|
-
-- a hung promise fails the test instead of freezing the runner.
|
|
21
16
|
local function expectSettled(promise, timeout: number?): boolean
|
|
22
17
|
local settled = PromiseTestUtils.awaitSettled(promise, timeout)
|
|
23
18
|
expect(settled).toEqual(true)
|
|
@@ -57,6 +52,44 @@ describe("PlayerDataStoreManager.GetDataStore", function()
|
|
|
57
52
|
end)
|
|
58
53
|
end)
|
|
59
54
|
|
|
55
|
+
describe("PlayerDataStoreManager PlayerMock support", function()
|
|
56
|
+
it("resolves a datastore for a PlayerMock keyed by its seeded UserId", function()
|
|
57
|
+
local controller = DataStoreTestUtils.setupDataStoreManager()
|
|
58
|
+
|
|
59
|
+
local player = PlayerMock.new({ UserId = 42 })
|
|
60
|
+
local dataStore = controller.manager:GetDataStore(player)
|
|
61
|
+
expect(dataStore).never.toBeNil()
|
|
62
|
+
expect((dataStore:GetKey())).toEqual("user_42")
|
|
63
|
+
|
|
64
|
+
player:Destroy()
|
|
65
|
+
controller:destroy()
|
|
66
|
+
end)
|
|
67
|
+
|
|
68
|
+
it("shares the same datastore between the mock and its numeric userId (unified state)", function()
|
|
69
|
+
local controller = DataStoreTestUtils.setupDataStoreManager()
|
|
70
|
+
|
|
71
|
+
local player = PlayerMock.new({ UserId = 42 })
|
|
72
|
+
local viaMock = controller.manager:GetDataStore(player)
|
|
73
|
+
local viaUserId = controller.manager:GetDataStore(42)
|
|
74
|
+
expect(viaMock).toEqual(viaUserId)
|
|
75
|
+
|
|
76
|
+
player:Destroy()
|
|
77
|
+
controller:destroy()
|
|
78
|
+
end)
|
|
79
|
+
|
|
80
|
+
it("rejects a plain Folder that is not a PlayerMock", function()
|
|
81
|
+
local controller = DataStoreTestUtils.setupDataStoreManager()
|
|
82
|
+
|
|
83
|
+
local folder = Instance.new("Folder")
|
|
84
|
+
expect(function()
|
|
85
|
+
controller.manager:GetDataStore(folder)
|
|
86
|
+
end).toThrow()
|
|
87
|
+
|
|
88
|
+
folder:Destroy()
|
|
89
|
+
controller:destroy()
|
|
90
|
+
end)
|
|
91
|
+
end)
|
|
92
|
+
|
|
60
93
|
describe("PlayerDataStoreManager.PromiseDataStore", function()
|
|
61
94
|
it("should resolve the datastore and load successfully against a healthy mock", function()
|
|
62
95
|
local controller = DataStoreTestUtils.setupDataStoreManager()
|
|
@@ -89,11 +122,8 @@ describe("PlayerDataStoreManager persistence", function()
|
|
|
89
122
|
local dataStore = controller.manager:GetDataStore(1)
|
|
90
123
|
dataStore:Store("coins", 5)
|
|
91
124
|
|
|
92
|
-
-- Removal saves (SaveAndCloseSession) then closes the session, asynchronously.
|
|
93
125
|
controller.manager:RemovePlayerDataStore(1)
|
|
94
126
|
|
|
95
|
-
-- PromiseDataStore waits for the in-progress removal to finish before handing back a
|
|
96
|
-
-- fresh datastore for the same user.
|
|
97
127
|
local promise = controller.manager:PromiseDataStore(1)
|
|
98
128
|
if not expectSettled(promise, 10) then
|
|
99
129
|
controller:destroy()
|
|
@@ -128,7 +158,6 @@ describe("PlayerDataStoreManager.AddRemovingCallback", function()
|
|
|
128
158
|
|
|
129
159
|
controller.manager:GetDataStore(1)
|
|
130
160
|
|
|
131
|
-
-- Drain the removal to be sure the callback fired.
|
|
132
161
|
local promise = controller.manager:PromiseAllSaves()
|
|
133
162
|
if not expectSettled(promise, 10) then
|
|
134
163
|
controller:destroy()
|
|
@@ -172,7 +201,6 @@ describe("PlayerDataStoreManager teardown", function()
|
|
|
172
201
|
|
|
173
202
|
controller.manager:Destroy()
|
|
174
203
|
|
|
175
|
-
-- BaseObject.Destroy clears the metatable, so a torn-down store reads a nil metatable.
|
|
176
204
|
expect(getmetatable(dataStore)).toBeNil()
|
|
177
205
|
|
|
178
206
|
controller:destroy()
|
|
@@ -189,8 +217,6 @@ describe("PlayerDataStoreManager teardown", function()
|
|
|
189
217
|
|
|
190
218
|
dataStore:Store("coins", 5)
|
|
191
219
|
|
|
192
|
-
-- Destroy must fire a synchronous Save before tearing each store down, so the staged value is
|
|
193
|
-
-- already persisted the instant Destroy() returns -- asserted with no awaiting.
|
|
194
220
|
controller.manager:Destroy()
|
|
195
221
|
|
|
196
222
|
local raw = controller.mock:GetRaw("user_1")
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
--!nonstrict
|
|
2
2
|
--[[
|
|
3
|
-
Integration coverage for PlayerDataStoreService, the ServiceBag-driven wrapper around one
|
|
4
|
-
PlayerDataStoreManager, with the underlying datastore injected via the SetRobloxDataStore test
|
|
5
|
-
seam. The manager auto-enables session locking, so even a load does an UpdateAsync round-trip
|
|
6
|
-
through the mock. Tests use numeric userIds, never real Players.
|
|
7
|
-
|
|
8
3
|
@class PlayerDataStoreService.spec.lua
|
|
9
4
|
]]
|
|
10
5
|
local require = require(script.Parent.loader).load(script)
|
|
@@ -91,7 +86,6 @@ describe("PlayerDataStoreService configuration guards", function()
|
|
|
91
86
|
it("should throw when SetRobloxDataStore is called after the manager is built", function()
|
|
92
87
|
local controller = setup(DataStoreMock.new())
|
|
93
88
|
|
|
94
|
-
-- Building the manager (via PromiseDataStore) locks out further overrides.
|
|
95
89
|
local promise = controller.service:PromiseDataStore(1)
|
|
96
90
|
if not PromiseTestUtils.awaitSettled(promise, 10) then
|
|
97
91
|
expect("hung").toEqual("settled")
|
|
@@ -144,8 +138,6 @@ describe("PlayerDataStoreService failure handling", function()
|
|
|
144
138
|
|
|
145
139
|
local controller = setup(mock)
|
|
146
140
|
|
|
147
|
-
-- PromiseDataStore resolves synchronously; the session-locked load underneath is what must
|
|
148
|
-
-- settle rather than hang against a failing datastore.
|
|
149
141
|
local promise = controller.service:PromiseDataStore(1)
|
|
150
142
|
if not PromiseTestUtils.awaitSettled(promise, 10) then
|
|
151
143
|
expect("hung").toEqual("settled")
|
|
@@ -207,10 +199,8 @@ describe("PlayerDataStoreService teardown", function()
|
|
|
207
199
|
|
|
208
200
|
dataStore:Store("coins", 7)
|
|
209
201
|
|
|
210
|
-
-- Destroy must fire a synchronous Save before tearing the store down.
|
|
211
202
|
controller:destroy()
|
|
212
203
|
|
|
213
|
-
-- keyGenerator maps userId 1 -> "1"
|
|
214
204
|
local raw = controller.mock:GetRaw("1")
|
|
215
205
|
expect(raw).never.toBeNil()
|
|
216
206
|
expect(raw.coins).toEqual(7)
|
|
@@ -64,8 +64,11 @@ function PrivateServerDataStoreService.PromiseDataStore(
|
|
|
64
64
|
-- On service teardown (hot reload / tests) flush and destroy the store. Save() is a best-effort
|
|
65
65
|
-- synchronous write before Destroy() cancels it.
|
|
66
66
|
self._maid:GiveTask(function()
|
|
67
|
-
--
|
|
68
|
-
|
|
67
|
+
-- A failed load makes Save() reject unconditionally; skip it so teardown does not
|
|
68
|
+
-- manufacture a guaranteed rejection.
|
|
69
|
+
if not dataStore:DidLoadFail() then
|
|
70
|
+
dataStore:Save()
|
|
71
|
+
end
|
|
69
72
|
dataStore:Destroy()
|
|
70
73
|
end)
|
|
71
74
|
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
--!nonstrict
|
|
2
2
|
--[[
|
|
3
|
-
Integration coverage for PrivateServerDataStoreService wired through a real ServiceBag, with the
|
|
4
|
-
underlying datastore injected via the SetRobloxDataStore test seam. It is not session-locking, so a
|
|
5
|
-
failing load rejects promptly rather than hanging. The datastore key defaults to "main" (or the
|
|
6
|
-
private-server id) unless a custom key is set via SetCustomKey before the datastore is resolved.
|
|
7
|
-
|
|
8
3
|
@class PrivateServerDataStoreService.spec.lua
|
|
9
4
|
]]
|
|
10
5
|
local require = require(script.Parent.loader).load(script)
|
|
@@ -123,7 +118,6 @@ describe("PrivateServerDataStoreService persistence", function()
|
|
|
123
118
|
end
|
|
124
119
|
expect((savePromise:Yield())).toEqual(true)
|
|
125
120
|
|
|
126
|
-
-- Non-session-locking, so the raw persisted value is the plain data table.
|
|
127
121
|
local raw = controller.mock:GetRaw(key)
|
|
128
122
|
expect(raw).never.toBeNil()
|
|
129
123
|
expect(raw.motd).toEqual("hello")
|
|
@@ -146,7 +140,6 @@ describe("PrivateServerDataStoreService.SetRobloxDataStore", function()
|
|
|
146
140
|
it("should throw on a non-datastore argument", function()
|
|
147
141
|
local controller = setup()
|
|
148
142
|
|
|
149
|
-
-- isDataStore is validated before the already-resolved check, so a bad arg throws regardless.
|
|
150
143
|
expect(function()
|
|
151
144
|
controller.service:SetRobloxDataStore({})
|
|
152
145
|
end).toThrow("Bad robloxDataStore")
|
|
@@ -176,7 +169,6 @@ describe("PrivateServerDataStoreService failure handling", function()
|
|
|
176
169
|
local ok, dataStore = promise:Yield()
|
|
177
170
|
expect(ok).toEqual(true)
|
|
178
171
|
|
|
179
|
-
-- Non-session-locking: a failing load rejects promptly, so this settles false rather than hanging.
|
|
180
172
|
local loadPromise = dataStore:PromiseLoadSuccessful()
|
|
181
173
|
if not PromiseTestUtils.awaitSettled(loadPromise, 5) then
|
|
182
174
|
expect("hung").toEqual("settled")
|
|
@@ -232,7 +224,6 @@ describe("PrivateServerDataStoreService teardown", function()
|
|
|
232
224
|
local key = dataStore:GetKey()
|
|
233
225
|
dataStore:Store("region", "us")
|
|
234
226
|
|
|
235
|
-
-- Destroy must fire a synchronous Save before tearing the store down.
|
|
236
227
|
controller:destroy()
|
|
237
228
|
|
|
238
229
|
local raw = controller.mock:GetRaw(key)
|