@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 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.44.0",
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.14.0",
33
- "@quenty/bindtocloseservice": "8.35.0",
32
+ "@quenty/baseobject": "10.15.0",
33
+ "@quenty/bindtocloseservice": "8.36.0",
34
34
  "@quenty/loader": "10.11.0",
35
- "@quenty/maid": "3.10.0",
35
+ "@quenty/maid": "3.11.0",
36
36
  "@quenty/math": "2.7.5",
37
- "@quenty/messagingserviceutils": "7.24.0",
38
- "@quenty/nevermore-test-runner": "1.4.0",
39
- "@quenty/pagesutils": "5.21.0",
40
- "@quenty/promise": "10.20.0",
41
- "@quenty/promisemaid": "5.20.0",
42
- "@quenty/rx": "13.30.0",
43
- "@quenty/servicebag": "11.19.0",
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.33.0",
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": "b7e59984e586064ea3cec6176e79b3f7451ecdc5"
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() -- the lock-acquire request hangs, so the load is stuck in flight
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() -- simulate a lock command that hasn't propagated yet (up to ~30s)
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() -- cancel the in-flight lock command
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) -- exactly one
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
- -- Best-effort: swallow a rejection (e.g. the load failed) so it is not uncaught.
87
- dataStore:Save():Catch(function() end)
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
- store:Save():Catch(function() end)
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 typeof(playerOrUserId) == "Instance" and playerOrUserId:IsA("Player") then
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
- -- Best-effort: swallow a rejection (e.g. the load failed) so it is not uncaught.
68
- dataStore:Save():Catch(function() end)
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)