@quenty/datastore 13.46.0 → 13.47.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,20 @@
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.47.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.46.1...@quenty/datastore@13.47.0) (2026-07-23)
7
+
8
+ ### Bug Fixes
9
+
10
+ - Data store tear down fixes that are graceful ([79152d0](https://github.com/Quenty/NevermoreEngine/commit/79152d03156f2328ab2a4a07bc68e27b5b666775))
11
+
12
+ ### Features
13
+
14
+ - Even more stuff ([fea7e95](https://github.com/Quenty/NevermoreEngine/commit/fea7e9587e7195bbfa7f4753c130a3d597f9a34b))
15
+
16
+ ## [13.46.1](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.46.0...@quenty/datastore@13.46.1) (2026-07-23)
17
+
18
+ **Note:** Version bump only for package @quenty/datastore
19
+
6
20
  # [13.46.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.45.0...@quenty/datastore@13.46.0) (2026-07-23)
7
21
 
8
22
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/datastore",
3
- "version": "13.46.0",
3
+ "version": "13.47.0",
4
4
  "description": "Quenty's Datastore implementation for Roblox",
5
5
  "keywords": [
6
6
  "Roblox",
@@ -30,26 +30,26 @@
30
30
  ],
31
31
  "dependencies": {
32
32
  "@quenty/baseobject": "10.15.0",
33
- "@quenty/bindtocloseservice": "8.36.0",
33
+ "@quenty/bindtocloseservice": "8.37.0",
34
34
  "@quenty/loader": "10.11.0",
35
35
  "@quenty/maid": "3.11.0",
36
36
  "@quenty/math": "2.7.5",
37
- "@quenty/messagingserviceutils": "7.25.0",
37
+ "@quenty/messagingserviceutils": "7.26.0",
38
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",
39
+ "@quenty/pagesutils": "5.23.0",
40
+ "@quenty/playermock": "1.2.0",
41
+ "@quenty/promise": "10.22.0",
42
+ "@quenty/promisemaid": "5.22.0",
43
+ "@quenty/rx": "13.32.0",
44
44
  "@quenty/servicebag": "11.20.0",
45
45
  "@quenty/signal": "7.13.1",
46
46
  "@quenty/symbol": "3.5.2",
47
47
  "@quenty/table": "3.9.2",
48
- "@quenty/valueobject": "13.34.0",
48
+ "@quenty/valueobject": "13.35.0",
49
49
  "@quentystudios/jest-lua": "3.10.0-quenty.2"
50
50
  },
51
51
  "publishConfig": {
52
52
  "access": "public"
53
53
  },
54
- "gitHead": "1de37218a2bedb8e3f8614a2e09bba9eddc812da"
54
+ "gitHead": "88b03743718172ff79cd62ade64a86565fcac17d"
55
55
  }
@@ -0,0 +1,133 @@
1
+ --!strict
2
+ --[[
3
+ Covers the graceful session close handoff between two sequential "server sessions" over one
4
+ DataStoreMock: a session that ends cleanly must release its session lock, so the next server's
5
+ load is an immediate clean takeover (no graceful-close wait, no retry ladder). Also pins the
6
+ crash-safety semantics that must NOT change: a live holder still blocks, and a dead holder's
7
+ fresh lock is only stolen through the retry ladder -- without leaking uncaught rejections.
8
+
9
+ @class DataStoreGracefulClose.spec.lua
10
+ ]]
11
+ local require = require(script.Parent.loader).load(script)
12
+
13
+ local DataStoreMock = require("DataStoreMock")
14
+ local DataStoreTestUtils = require("DataStoreTestUtils")
15
+ local Jest = require("Jest")
16
+ local Maid = require("Maid")
17
+ local MessagingServiceMock = require("MessagingServiceMock")
18
+ local PlayerDataStoreManager = require("PlayerDataStoreManager")
19
+ local PromiseTestUtils = require("PromiseTestUtils")
20
+
21
+ local describe = Jest.Globals.describe
22
+ local expect = Jest.Globals.expect
23
+ local it = Jest.Globals.it
24
+
25
+ describe("clean session end releases the lock (server-shutdown teardown path)", function()
26
+ it("manager teardown closes the session so the next server loads immediately", function()
27
+ local mock = DataStoreMock.new()
28
+
29
+ -- Server A: the manager still owns the store at teardown (nothing removed the player
30
+ -- first), which is exactly what a ServiceBag destroy after a clean session looks like.
31
+ local maidA = Maid.new()
32
+ local serviceBagA = DataStoreTestUtils.newServiceBag(maidA, MessagingServiceMock.new())
33
+ local managerA = maidA:Add(PlayerDataStoreManager.new(serviceBagA, mock :: any, function(userId)
34
+ return "user_" .. tostring(userId)
35
+ end, true))
36
+
37
+ local storeA = assert(managerA:GetDataStore(1), "No storeA")
38
+ storeA:Store("coins", 42)
39
+ if
40
+ not PromiseTestUtils.awaitValue(function()
41
+ local raw = mock:GetRaw("user_1")
42
+ return raw ~= nil and raw.lock ~= nil
43
+ end, 10)
44
+ then
45
+ expect("A never acquired the lock").toEqual("A acquired the lock")
46
+ maidA:DoCleaning()
47
+ return
48
+ end
49
+
50
+ -- Clean shutdown: the whole session tears down without the player ever "removing".
51
+ maidA:DoCleaning()
52
+
53
+ -- The teardown flush must write the graceful close: data saved, lock released.
54
+ expect(PromiseTestUtils.awaitValue(function()
55
+ local raw = mock:GetRaw("user_1")
56
+ return raw ~= nil and raw.lock == nil
57
+ end, 5)).toEqual(true)
58
+ expect(mock:GetRaw("user_1").coins).toEqual(42)
59
+
60
+ -- Server B: an immediate clean takeover -- bounded well under the 5s graceful-close
61
+ -- wait (and nowhere near the ~49s retry ladder).
62
+ local maidB = Maid.new()
63
+ local storeB = DataStoreTestUtils.newSessionLockedStore(maidB, mock, "user_1")
64
+ local loadB = storeB:Load("coins")
65
+ if not PromiseTestUtils.awaitSettled(loadB, 1) then
66
+ expect("B load was not immediate").toEqual("B load was immediate")
67
+ maidB:DoCleaning()
68
+ return
69
+ end
70
+ expect((loadB:Wait())).toEqual(42)
71
+ expect(mock:GetRaw("user_1").lock.ActiveSession.SessionId).toEqual(storeB:GetSessionId())
72
+
73
+ maidB:DoCleaning()
74
+ end)
75
+ end)
76
+
77
+ describe("crash-safety semantics preserved", function()
78
+ it("still blocks on a live holder that does not close (ask-and-wait, no immediate steal)", function()
79
+ local controller = DataStoreTestUtils.setup()
80
+
81
+ local serverA = controller.newServer({ messaging = true })
82
+ if not controller.awaitOwn(serverA) then
83
+ expect("A load hung").toEqual("A load settled")
84
+ controller:destroy()
85
+ return
86
+ end
87
+
88
+ local serverB = controller.newServer({ messaging = true })
89
+ serverB:SetSessionMessagingCloseDelaySeconds(0.1)
90
+ local loadB = serverB:PromiseLoadSuccessful()
91
+
92
+ -- The holder is alive but never closes; B must still be waiting on the graceful
93
+ -- protocol, not stealing a fresh lock.
94
+ expect(PromiseTestUtils.awaitSettled(loadB, 2)).toEqual(false)
95
+ expect(controller.mock:GetRaw("player_1").lock.ActiveSession.SessionId).toEqual(serverA:GetSessionId())
96
+
97
+ controller:destroy()
98
+ end)
99
+
100
+ it("steals a dead holder's fresh lock only through the retry ladder, leaking no rejections", function()
101
+ local controller = DataStoreTestUtils.setup()
102
+
103
+ -- A holder that died without closing: fresh lock, no live session behind it. Every
104
+ -- graceful-close request times out, so the load must grind through the (shortened)
105
+ -- retry ladder and then steal -- with every rejection along the way consumed (the
106
+ -- test runner fails the suite on stray uncaught rejections).
107
+ controller.mock:SetRaw("player_1", {
108
+ coins = 9,
109
+ lock = {
110
+ LastUpdateTime = os.time(),
111
+ ActiveSession = { SessionId = "dead-session", PlaceId = 123, JobId = "dead-job" },
112
+ },
113
+ })
114
+
115
+ local serverB = controller.newServer({ messaging = true })
116
+ serverB:SetLoadRetryOptions({ exponential = 1, initialWaitTime = 0.2, maxAttempts = 2, printWarning = false })
117
+ serverB:SetSessionMessagingCloseDelaySeconds(0.05)
118
+
119
+ local loadB = serverB:PromiseLoadSuccessful()
120
+ if not PromiseTestUtils.awaitSettled(loadB, 20) then
121
+ expect("B never stole the dead session's lock").toEqual("B stole the dead session's lock")
122
+ controller:destroy()
123
+ return
124
+ end
125
+ expect((loadB:Wait())).toEqual(true)
126
+
127
+ local raw = controller.mock:GetRaw("player_1")
128
+ expect(raw.coins).toEqual(9)
129
+ expect(raw.lock.ActiveSession.SessionId).toEqual(serverB:GetSessionId())
130
+
131
+ controller:destroy()
132
+ end, 30000) -- Two full 5s graceful-close timeouts plus backoff, beyond jest's 5s default
133
+ end)
@@ -673,73 +673,78 @@ function DataStore._promiseGetAsyncNoCache(self: DataStore): Promise.Promise<()>
673
673
  self._maid[loadPromise] = loadPromise
674
674
 
675
675
  PromiseMaidUtils.whilePromise(loadPromise, function(maid)
676
- maid:GivePromise(
677
- DataStorePromises.updateAsync(self._robloxDataStore, self._key, function(data, datastoreKeyInfo)
678
- local userIdList = self._userIdList
679
- if datastoreKeyInfo then
680
- userIdList = datastoreKeyInfo:GetUserIds()
681
- end
676
+ maid
677
+ :GivePromise(
678
+ DataStorePromises.updateAsync(self._robloxDataStore, self._key, function(data, datastoreKeyInfo)
679
+ local userIdList = self._userIdList
680
+ if datastoreKeyInfo then
681
+ userIdList = datastoreKeyInfo:GetUserIds()
682
+ end
682
683
 
683
- local metadata = nil
684
- if datastoreKeyInfo then
685
- metadata = datastoreKeyInfo:GetMetadata()
686
- end
684
+ local metadata = nil
685
+ if datastoreKeyInfo then
686
+ metadata = datastoreKeyInfo:GetMetadata()
687
+ end
687
688
 
688
- if self._debugWriting then
689
- print(string.format("DataStorePromises.updateAsync(%q) -> Got ", tostring(self._key)), data)
690
- end
689
+ if self._debugWriting then
690
+ print(
691
+ string.format("DataStorePromises.updateAsync(%q) -> Got ", tostring(self._key)),
692
+ data
693
+ )
694
+ end
691
695
 
692
- local lockResult = self._sessionLockingEnabledHelper:AcquireLock(data, canStealLock)
693
- if not lockResult.isValid then
694
- if self._sessionMessagingEnabledHelper and tryMessagingServiceSessionClose then
695
- -- Gracefully kick to avoid losing memory
696
- self._sessionMessagingEnabledHelper
697
- :PromiseCloseSessionGraceful(
698
- lockResult.blockingSession.PlaceId,
699
- lockResult.blockingSession.JobId,
700
- lockResult.blockingSession.SessionId
701
- )
702
- :Then(function()
703
- -- Give enough time for Roblox to replicate changes
704
- -- We probably could bump back to the loop but this has slightly better error messages
705
- return maid:GivePromise(
706
- PromiseUtils.delayed(self._sessionMessagingCloseDelaySeconds)
696
+ local lockResult = self._sessionLockingEnabledHelper:AcquireLock(data, canStealLock)
697
+ if not lockResult.isValid then
698
+ if self._sessionMessagingEnabledHelper and tryMessagingServiceSessionClose then
699
+ -- Gracefully kick to avoid losing memory
700
+ self._sessionMessagingEnabledHelper
701
+ :PromiseCloseSessionGraceful(
702
+ lockResult.blockingSession.PlaceId,
703
+ lockResult.blockingSession.JobId,
704
+ lockResult.blockingSession.SessionId
707
705
  )
708
- end)
709
- :Then(function()
710
- return maid:GivePromise(promiseLoadUnlockedProfile(canStealLock, false))
711
- end)
712
- :Then(function(unlockedProfile)
713
- loadPromise:Resolve(unlockedProfile)
714
- end, function(err)
715
- loadPromise:Reject(
716
- `Profile is locked, but gracefully closed. Failed to load with {err}`
706
+ :Then(function()
707
+ -- Give enough time for Roblox to replicate changes
708
+ -- We probably could bump back to the loop but this has slightly better error messages
709
+ return maid:GivePromise(
710
+ PromiseUtils.delayed(self._sessionMessagingCloseDelaySeconds)
711
+ )
712
+ end)
713
+ :Then(function()
714
+ return maid:GivePromise(promiseLoadUnlockedProfile(canStealLock, false))
715
+ end)
716
+ :Then(function(unlockedProfile)
717
+ loadPromise:Resolve(unlockedProfile)
718
+ end, function(err)
719
+ loadPromise:Reject(
720
+ `Profile is locked and the graceful session-close request did not release it. Failed to load with {err}`
721
+ )
722
+ end)
723
+ else
724
+ loadPromise:Reject(
725
+ string.format(
726
+ "Profile is locked (%s)",
727
+ MessagingServiceUtils.toHumanReadable(data.lock)
717
728
  )
718
- end)
719
- else
720
- loadPromise:Reject(
721
- string.format(
722
- "Profile is locked (%s)",
723
- MessagingServiceUtils.toHumanReadable(data.lock)
724
729
  )
725
- )
726
- end
730
+ end
727
731
 
728
- -- Cancel write to avoid maintaining lock
729
- return nil
730
- end
732
+ -- Cancel write to avoid maintaining lock
733
+ return nil
734
+ end
731
735
 
732
- loadPromise:Resolve(lockResult.unlockedProfile)
736
+ loadPromise:Resolve(lockResult.unlockedProfile)
733
737
 
734
- return lockResult.lockedProfile, userIdList, metadata
738
+ return lockResult.lockedProfile, userIdList, metadata
739
+ end)
740
+ )
741
+ :Catch(function(opError)
742
+ -- The datastore operation itself failed (e.g. 509), which is NOT lock contention and
743
+ -- will not resolve by retrying. Fail the load fast, preserving the original error.
744
+ if loadPromise:IsPending() then
745
+ loadPromise:Reject(DataStoreNonRetryableLoadError.new(opError))
746
+ end
735
747
  end)
736
- ):Catch(function(opError)
737
- -- The datastore operation itself failed (e.g. 509), which is NOT lock contention and
738
- -- will not resolve by retrying. Fail the load fast, preserving the original error.
739
- if loadPromise:IsPending() then
740
- loadPromise:Reject(DataStoreNonRetryableLoadError.new(opError))
741
- end
742
- end)
743
748
  end)
744
749
 
745
750
  loadPromise:Finally(function()
@@ -94,6 +94,13 @@ function DataStoreMessageHelper.PromiseCloseSessionGraceful(
94
94
  self._maid[promise] = promise
95
95
  promise:Finally(function()
96
96
  self._maid[promise] = nil
97
+
98
+ -- Never cache a settled notification: a timed-out (rejected) promise left in the map would
99
+ -- make every later request for this session fail instantly instead of sending a fresh
100
+ -- close request with its own timeout window.
101
+ if self._sessionClosedNotifications[sessionId] == promise then
102
+ self._sessionClosedNotifications[sessionId] = nil
103
+ end
97
104
  end)
98
105
 
99
106
  PromiseMaidUtils.whilePromise(promise, function(maid)
@@ -276,6 +276,61 @@ function DataStoreMock.GetRaw(self: DataStoreMock, key: string): any
276
276
  return deepCopy(self._store[key])
277
277
  end
278
278
 
279
+ --[=[
280
+ Serializes the full raw key -> value store to a JSON string, so a mock's contents can
281
+ survive between two in-process "server sessions" (e.g. an integration test simulating a
282
+ cross-place teleport) or be written out as an inspectable, diffable checkpoint artifact.
283
+ Datastore values are JSON-safe by contract, so the export is lossless; hydrate a fresh
284
+ mock from it with [DataStoreMock.ImportRaw].
285
+
286
+ Errors when the store holds a value that cannot be JSON-encoded (which a real datastore
287
+ would have refused to store in the first place).
288
+
289
+ @return string
290
+ ]=]
291
+ function DataStoreMock.ExportRaw(self: DataStoreMock): string
292
+ local ok, encoded = pcall(function()
293
+ return HttpService:JSONEncode(self._store)
294
+ end)
295
+ if not ok then
296
+ error(string.format("[DataStoreMock.ExportRaw] - Store contents are not JSON-encodable: %s", tostring(encoded)))
297
+ end
298
+
299
+ return encoded
300
+ end
301
+
302
+ --[=[
303
+ Decodes a JSON string produced by [DataStoreMock.ExportRaw] and replaces the store
304
+ contents with it. This replaces rather than merges: every existing key is discarded,
305
+ along with its version/userId/metadata bookkeeping. Each imported key is then seeded
306
+ exactly like [DataStoreMock.SetRaw] (no version bump, no failure injection), so a
307
+ hydrated mock is indistinguishable from a fresh one seeded key-by-key.
308
+
309
+ @param json string
310
+ ]=]
311
+ function DataStoreMock.ImportRaw(self: DataStoreMock, json: string): ()
312
+ assert(type(json) == "string", "Bad json")
313
+
314
+ local ok, decoded = pcall(function()
315
+ return HttpService:JSONDecode(json)
316
+ end)
317
+ if not ok then
318
+ error(string.format("[DataStoreMock.ImportRaw] - Could not decode json: %s", tostring(decoded)))
319
+ end
320
+ assert(type(decoded) == "table", "Bad json - expected an encoded key -> value object")
321
+
322
+ local store: { [string]: any } = {}
323
+ for key, value in decoded do
324
+ assert(type(key) == "string", "Bad json - datastore keys must be strings")
325
+ store[key] = value
326
+ end
327
+
328
+ self._store = store
329
+ self._userIds = {}
330
+ self._metadata = {}
331
+ self._versions = {}
332
+ end
333
+
279
334
  function DataStoreMock._beginRequest(self: DataStoreMock, method: string, key: string): ()
280
335
  self._callCounts[method] = (self._callCounts[method] or 0) + 1
281
336
  self._totalCalls += 1
@@ -1,4 +1,4 @@
1
- --!nonstrict
1
+ --!strict
2
2
  --[[
3
3
  Sanity coverage for the DataStoreMock itself, so tests that rely on it can trust its
4
4
  datastore-faithful behavior (deep-copy round-tripping, UpdateAsync transform semantics,
@@ -436,6 +436,125 @@ describe("DataStoreMock serialized-size overflow", function()
436
436
  end)
437
437
  end)
438
438
 
439
+ describe("DataStoreMock:ExportRaw / ImportRaw", function()
440
+ it("should round-trip values written through the real datastore APIs", function()
441
+ local sessionA = DataStoreMock.new()
442
+ sessionA:SetAsync("player_1", { coins = 5, inventory = { "sword", "shield" } })
443
+ sessionA:UpdateAsync("player_2", function()
444
+ return { coins = 12, quests = { active = { "eggHunt" }, completed = {} } }
445
+ end)
446
+ sessionA:SetAsync("motd", "Welcome!")
447
+
448
+ local json = sessionA:ExportRaw()
449
+ expect(type(json)).toEqual("string")
450
+
451
+ local sessionB = DataStoreMock.new()
452
+ sessionB:ImportRaw(json)
453
+
454
+ expect((sessionB:GetAsync("player_1"))).toEqual({ coins = 5, inventory = { "sword", "shield" } })
455
+ expect((sessionB:GetAsync("player_2"))).toEqual({
456
+ coins = 12,
457
+ quests = { active = { "eggHunt" }, completed = {} },
458
+ })
459
+ expect((sessionB:GetAsync("motd"))).toEqual("Welcome!")
460
+ end)
461
+
462
+ it("should hand out a sane keyInfo for imported keys, like a mock seeded via SetRaw", function()
463
+ local sessionA = DataStoreMock.new()
464
+ sessionA:SetAsync("key", { coins = 5 })
465
+ sessionA:SetAsync("key", { coins = 6 }) -- version bumps stay behind, like SetRaw
466
+
467
+ local sessionB = DataStoreMock.new()
468
+ sessionB:ImportRaw(sessionA:ExportRaw())
469
+
470
+ local _, keyInfo = sessionB:GetAsync("key")
471
+ expect(keyInfo).never.toBeNil()
472
+ expect(keyInfo.Version).toEqual("0")
473
+ expect(keyInfo:GetUserIds()).toEqual({})
474
+ expect(keyInfo:GetMetadata()).toEqual({})
475
+ end)
476
+
477
+ it("should replace, not merge, the existing contents on import", function()
478
+ local source = DataStoreMock.new()
479
+ source:SetAsync("imported", 1)
480
+
481
+ local target = DataStoreMock.new()
482
+ target:SetAsync("preexisting", "should be discarded")
483
+ target:SetAsync("imported", "stale value")
484
+
485
+ target:ImportRaw(source:ExportRaw())
486
+
487
+ expect((target:GetAsync("preexisting"))).toEqual(nil)
488
+ expect((target:GetAsync("imported"))).toEqual(1)
489
+ end)
490
+
491
+ it("should discard bookkeeping for keys that survive a replace", function()
492
+ local source = DataStoreMock.new()
493
+ source:SetAsync("key", 1)
494
+
495
+ local target = DataStoreMock.new()
496
+ target:SetAsync("key", "old", { 111 })
497
+ target:ImportRaw(source:ExportRaw())
498
+
499
+ local _, keyInfo = target:GetAsync("key")
500
+ expect(keyInfo.Version).toEqual("0")
501
+ expect(keyInfo:GetUserIds()).toEqual({})
502
+ end)
503
+
504
+ it("should round-trip an empty store", function()
505
+ local empty = DataStoreMock.new()
506
+
507
+ local target = DataStoreMock.new()
508
+ target:SetAsync("key", "should be discarded")
509
+ target:ImportRaw(empty:ExportRaw())
510
+
511
+ expect((target:GetAsync("key"))).toEqual(nil)
512
+ end)
513
+
514
+ it("should export stably across an import cycle", function()
515
+ local sessionA = DataStoreMock.new()
516
+ sessionA:SetAsync("player_1", { coins = 5, nested = { list = { 1, 2, 3 } } })
517
+ sessionA:SetAsync("player_2", true)
518
+
519
+ local firstExport = sessionA:ExportRaw()
520
+
521
+ local sessionB = DataStoreMock.new()
522
+ sessionB:ImportRaw(firstExport)
523
+ local secondExport = sessionB:ExportRaw()
524
+
525
+ -- Key order inside the JSON is not guaranteed, so compare decoded contents.
526
+ local HttpService = game:GetService("HttpService")
527
+ expect(HttpService:JSONDecode(secondExport)).toEqual(HttpService:JSONDecode(firstExport))
528
+ end)
529
+
530
+ it("should not alias imported values to later reads", function()
531
+ local source = DataStoreMock.new()
532
+ source:SetAsync("key", { coins = 5 })
533
+
534
+ local target = DataStoreMock.new()
535
+ target:ImportRaw(source:ExportRaw())
536
+
537
+ local first = target:GetAsync("key")
538
+ first.coins = 999
539
+
540
+ expect((target:GetAsync("key")).coins).toEqual(5)
541
+ end)
542
+
543
+ it("should throw a clear error for malformed json", function()
544
+ local store = DataStoreMock.new()
545
+ expect(function()
546
+ store:ImportRaw("not json {")
547
+ end).toThrow("Could not decode json")
548
+ end)
549
+
550
+ it("should throw when the json is not an object", function()
551
+ local store = DataStoreMock.new()
552
+ expect(function()
553
+ store:ImportRaw('"just a string"')
554
+ end).toThrow()
555
+ end)
556
+ end)
557
+
439
558
  describe("DataStoreMock:SetRaw / GetRaw", function()
440
559
  it("should seed and read without triggering failures", function()
441
560
  local store = DataStoreMock.new()
@@ -154,21 +154,25 @@ end
154
154
 
155
155
  --[=[
156
156
  Flushes and tears down every datastore we still own. Runs on manager teardown (a hot-reloaded
157
- ServiceBag, or a unit test). Save() is a best-effort synchronous write: the underlying UpdateAsync
158
- request is dispatched before Destroy() cancels the promise, so a live server usually honors it, but
159
- it is not guaranteed. A store whose load failed rejects, so the rejection is swallowed. Stores handed
160
- off gracefully via _removePlayerDataStore have already been pulled out of _datastores, so this only
161
- covers the ones nothing else cleaned up.
157
+ ServiceBag, or a unit test). SaveAndCloseSession() is a best-effort synchronous write: the
158
+ underlying UpdateAsync request is dispatched before Destroy() cancels the promise, so a live
159
+ server usually honors it, but it is not guaranteed. A store whose load failed rejects, so the
160
+ rejection is swallowed. Stores handed off gracefully via _removePlayerDataStore have already been
161
+ pulled out of _datastores, so this only covers the ones nothing else cleaned up.
162
162
  ]=]
163
163
  function PlayerDataStoreManager._flushAndDestroyAll(self: PlayerDataStoreManager): ()
164
164
  for userId, datastore in self._datastores do
165
165
  -- Cast past the DataStore intersection type: the solver otherwise blows up ("code too complex")
166
- -- resolving :Save()/:Destroy() through it.
166
+ -- resolving :SaveAndCloseSession()/:Destroy() through it.
167
167
  local store = datastore :: any
168
- -- A failed load makes Save() reject unconditionally; skip it so teardown does not
168
+ -- A failed load makes the save reject unconditionally; skip it so teardown does not
169
169
  -- manufacture a guaranteed rejection.
170
170
  if not store:DidLoadFail() then
171
- store:Save()
171
+ -- Close the session, don't just save: this teardown ends the session, so it must also
172
+ -- release the session lock. A lock left held here reads as a live foreign session to the
173
+ -- next server that loads the key, which then grinds through the whole graceful-close/steal
174
+ -- retry ladder against a holder that no longer exists before it can load.
175
+ store:SaveAndCloseSession()
172
176
  end
173
177
  store:Destroy()
174
178
  self._datastores[userId] = nil
@@ -1,4 +1,4 @@
1
- --!nonstrict
1
+ --!strict
2
2
  --[[
3
3
  @class PlayerDataStoreManager.spec.lua
4
4
  ]]
@@ -225,4 +225,75 @@ describe("PlayerDataStoreManager teardown", function()
225
225
 
226
226
  controller:destroy()
227
227
  end)
228
+
229
+ it("releases the session lock when destroyed, not just the staged data", function()
230
+ local controller = DataStoreTestUtils.setupDataStoreManager()
231
+
232
+ if not controller.storeAndAwaitLock() then
233
+ expect("lock was never acquired").toEqual("lock was acquired")
234
+ controller:destroy()
235
+ return
236
+ end
237
+
238
+ controller.manager:Destroy()
239
+
240
+ expect(PromiseTestUtils.awaitValue(function()
241
+ local raw = controller.mock:GetRaw("user_1")
242
+ return raw ~= nil and raw.lock == nil
243
+ end, 5)).toEqual(true)
244
+ expect(controller.mock:GetRaw("user_1").coins).toEqual(5)
245
+
246
+ controller:destroy()
247
+ end)
248
+
249
+ it("releases the lock for every store it still owns", function()
250
+ local controller = DataStoreTestUtils.setupDataStoreManager()
251
+
252
+ controller.manager:GetDataStore(1):Store("coins", 1)
253
+ controller.manager:GetDataStore(2):Store("coins", 2)
254
+
255
+ local locked = PromiseTestUtils.awaitValue(function()
256
+ local rawOne = controller.mock:GetRaw("user_1")
257
+ local rawTwo = controller.mock:GetRaw("user_2")
258
+ return rawOne ~= nil and rawOne.lock ~= nil and rawTwo ~= nil and rawTwo.lock ~= nil
259
+ end, 10)
260
+ if not locked then
261
+ expect("both locks were never acquired").toEqual("both locks were acquired")
262
+ controller:destroy()
263
+ return
264
+ end
265
+
266
+ controller.manager:Destroy()
267
+
268
+ expect(PromiseTestUtils.awaitValue(function()
269
+ local rawOne = controller.mock:GetRaw("user_1")
270
+ local rawTwo = controller.mock:GetRaw("user_2")
271
+ return rawOne ~= nil and rawOne.lock == nil and rawTwo ~= nil and rawTwo.lock == nil
272
+ end, 5)).toEqual(true)
273
+ expect(controller.mock:GetRaw("user_1").coins).toEqual(1)
274
+ expect(controller.mock:GetRaw("user_2").coins).toEqual(2)
275
+
276
+ controller:destroy()
277
+ end)
278
+
279
+ it("tears down cleanly when a store's load failed, leaking no rejection and writing no lock", function()
280
+ local controller = DataStoreTestUtils.setupDataStoreManager()
281
+
282
+ controller.mock:FailAllRequests()
283
+
284
+ local dataStore = controller.manager:GetDataStore(1)
285
+
286
+ local loaded = dataStore:PromiseLoadSuccessful()
287
+ if not expectSettled(loaded, 10) then
288
+ controller:destroy()
289
+ return
290
+ end
291
+ expect((loaded:Wait())).toEqual(false)
292
+
293
+ controller.manager:Destroy()
294
+
295
+ expect(controller.mock:GetRaw("user_1")).toBeNil()
296
+
297
+ controller:destroy()
298
+ end)
228
299
  end)