@quenty/datastore 13.40.0 → 13.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/package.json +9 -9
  3. package/src/Server/DataStore.HookErrors.spec.lua +92 -0
  4. package/src/Server/DataStore.LoadErrors.spec.lua +133 -0
  5. package/src/Server/DataStore.Reentrance.spec.lua +119 -0
  6. package/src/Server/DataStore.SessionLock.spec.lua +478 -0
  7. package/src/Server/DataStore.SessionMessaging.spec.lua +71 -0
  8. package/src/Server/DataStore.TwoServerLock.spec.lua +190 -0
  9. package/src/Server/DataStore.lua +89 -18
  10. package/src/Server/DataStore.spec.lua +237 -0
  11. package/src/Server/DataStoreLockHelper.spec.lua +239 -0
  12. package/src/Server/DataStoreMessageHelper.spec.lua +134 -0
  13. package/src/Server/DataStoreNonRetryableLoadError.lua +61 -0
  14. package/src/Server/DataStoreTestUtils.lua +206 -0
  15. package/src/Server/GameDataStoreService.lua +26 -0
  16. package/src/Server/GameDataStoreService.spec.lua +215 -0
  17. package/src/Server/Mocks/DataStoreMock.lua +405 -0
  18. package/src/Server/Mocks/DataStoreMock.spec.lua +384 -0
  19. package/src/Server/Modules/DataStoreSnapshotUtils.spec.lua +91 -0
  20. package/src/Server/Modules/DataStoreStage.lua +8 -2
  21. package/src/Server/Modules/DataStoreStage.spec.lua +539 -0
  22. package/src/Server/Modules/DataStoreWriter.spec.lua +399 -0
  23. package/src/Server/PlayerDataStoreManager.RemovalCallbacks.spec.lua +99 -0
  24. package/src/Server/PlayerDataStoreManager.lua +36 -4
  25. package/src/Server/PlayerDataStoreManager.spec.lua +202 -0
  26. package/src/Server/PlayerDataStoreService.lua +18 -0
  27. package/src/Server/PlayerDataStoreService.spec.lua +218 -0
  28. package/src/Server/PrivateServerDataStoreService.lua +23 -1
  29. package/src/Server/PrivateServerDataStoreService.spec.lua +242 -0
  30. package/src/Server/Utility/DataStorePromises.lua +18 -5
  31. package/src/Server/Utility/DataStorePromises.spec.lua +363 -0
  32. package/src/jest.config.lua +3 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,10 @@
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.41.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.40.0...@quenty/datastore@13.41.0) (2026-07-18)
7
+
8
+ **Note:** Version bump only for package @quenty/datastore
9
+
6
10
  # [13.40.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.39.1...@quenty/datastore@13.40.0) (2026-07-14)
7
11
 
8
12
  **Note:** Version bump only for package @quenty/datastore
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/datastore",
3
- "version": "13.40.0",
3
+ "version": "13.41.0",
4
4
  "description": "Quenty's Datastore implementation for Roblox",
5
5
  "keywords": [
6
6
  "Roblox",
@@ -30,25 +30,25 @@
30
30
  ],
31
31
  "dependencies": {
32
32
  "@quenty/baseobject": "10.13.0",
33
- "@quenty/bindtocloseservice": "8.33.0",
33
+ "@quenty/bindtocloseservice": "8.34.0",
34
34
  "@quenty/loader": "10.11.0",
35
35
  "@quenty/maid": "3.9.0",
36
36
  "@quenty/math": "2.7.5",
37
- "@quenty/messagingserviceutils": "7.22.1",
37
+ "@quenty/messagingserviceutils": "7.23.0",
38
38
  "@quenty/nevermore-test-runner": "1.4.0",
39
- "@quenty/pagesutils": "5.19.1",
40
- "@quenty/promise": "10.18.1",
41
- "@quenty/promisemaid": "5.18.1",
42
- "@quenty/rx": "13.28.3",
39
+ "@quenty/pagesutils": "5.20.0",
40
+ "@quenty/promise": "10.19.0",
41
+ "@quenty/promisemaid": "5.19.0",
42
+ "@quenty/rx": "13.29.0",
43
43
  "@quenty/servicebag": "11.18.1",
44
44
  "@quenty/signal": "7.13.1",
45
45
  "@quenty/symbol": "3.5.2",
46
46
  "@quenty/table": "3.9.2",
47
- "@quenty/valueobject": "13.31.1",
47
+ "@quenty/valueobject": "13.32.0",
48
48
  "@quentystudios/jest-lua": "3.10.0-quenty.2"
49
49
  },
50
50
  "publishConfig": {
51
51
  "access": "public"
52
52
  },
53
- "gitHead": "13f0162f4971a77378e55e9b7236aea94f0dd3a8"
53
+ "gitHead": "cbbb89635bfdcbf32f26a40422ac736292917cca"
54
54
  }
@@ -0,0 +1,92 @@
1
+ --!nonstrict
2
+ --[[
3
+ Characterizes how the datastore layer handles user-supplied saving callbacks that misbehave:
4
+ throw, return a rejected promise, or yield forever. Several are genuine data-integrity failure
5
+ modes, so pinning them documents the contract callers must honor. The removing-callback matrix
6
+ lives with the manager it exercises in PlayerDataStoreManager.RemovalCallbacks.spec.lua.
7
+
8
+ @class DataStoreHookErrors.spec.lua
9
+ ]]
10
+ local require = require(script.Parent.loader).load(script)
11
+
12
+ local DataStoreTestUtils = require("DataStoreTestUtils")
13
+ local Jest = require("Jest")
14
+ local Promise = require("Promise")
15
+ local PromiseTestUtils = require("PromiseTestUtils")
16
+
17
+ local describe = Jest.Globals.describe
18
+ local expect = Jest.Globals.expect
19
+ local it = Jest.Globals.it
20
+
21
+ describe("DataStore saving callbacks that misbehave", function()
22
+ it("runs a well-behaved saving callback and completes the save", function()
23
+ local controller = DataStoreTestUtils.setup()
24
+ local dataStore = controller.newDataStore("key")
25
+
26
+ local ran = false
27
+ dataStore:AddSavingCallback(function()
28
+ ran = true
29
+ end)
30
+ dataStore:Store("x", 1)
31
+
32
+ local promise = dataStore:Save()
33
+ if not PromiseTestUtils.awaitSettled(promise, 5) then
34
+ expect("hung").toEqual("settled")
35
+ controller:destroy()
36
+ return
37
+ end
38
+ expect((promise:Yield())).toEqual(true)
39
+ expect(ran).toEqual(true)
40
+
41
+ controller:destroy()
42
+ end)
43
+
44
+ it("isolates a throwing saving callback into a clean save rejection, preserving the stack trace", function()
45
+ local controller = DataStoreTestUtils.setup()
46
+ local dataStore = controller.newDataStore("key")
47
+
48
+ dataStore:AddSavingCallback(function()
49
+ error("saving callback boom")
50
+ end)
51
+ dataStore:Store("x", 1)
52
+
53
+ local outcome, err = PromiseTestUtils.awaitOutcome(dataStore:Save(), 5)
54
+ expect(outcome).toEqual("rejected")
55
+
56
+ -- The rejection preserves the original message and the invoking frame, so the failure stays debuggable.
57
+ expect(string.find(tostring(err), "saving callback boom", 1, true) ~= nil).toEqual(true)
58
+ expect(string.find(tostring(err), "PromiseInvokeSavingCallbacks", 1, true) ~= nil).toEqual(true)
59
+
60
+ controller:destroy()
61
+ end)
62
+
63
+ it("rejects the save when a saving callback returns a rejected promise (and does not persist)", function()
64
+ local controller = DataStoreTestUtils.setup()
65
+ local dataStore = controller.newDataStore("key")
66
+
67
+ dataStore:AddSavingCallback(function()
68
+ return Promise.rejected("nope")
69
+ end)
70
+ dataStore:Store("x", 1)
71
+
72
+ expect((PromiseTestUtils.awaitOutcome(dataStore:Save(), 5))).toEqual("rejected")
73
+ expect(controller.mock:GetRaw("key")).toEqual(nil)
74
+
75
+ controller:destroy()
76
+ end)
77
+
78
+ it("blocks the save while a saving callback yields (never resolves)", function()
79
+ local controller = DataStoreTestUtils.setup()
80
+ local dataStore = controller.newDataStore("key")
81
+
82
+ dataStore:AddSavingCallback(function()
83
+ return Promise.new()
84
+ end)
85
+ dataStore:Store("x", 1)
86
+
87
+ local promise = dataStore:Save()
88
+ expect(PromiseTestUtils.awaitSettled(promise, 2)).toEqual(false)
89
+
90
+ controller:destroy()
91
+ end)
92
+ end)
@@ -0,0 +1,133 @@
1
+ --!nonstrict
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
+ @class DataStoreLoadErrors.spec.lua
9
+ ]]
10
+ local require = require(script.Parent.loader).load(script)
11
+
12
+ local DataStoreTestUtils = require("DataStoreTestUtils")
13
+ local Jest = require("Jest")
14
+ local PromiseTestUtils = require("PromiseTestUtils")
15
+
16
+ local describe = Jest.Globals.describe
17
+ local expect = Jest.Globals.expect
18
+ local it = Jest.Globals.it
19
+
20
+ describe("shared load promise fan-out", function()
21
+ it("delivers the resolution to every consumer of the shared load", function()
22
+ local controller = DataStoreTestUtils.setup()
23
+ controller.mock:SetRaw("key", { a = 1, b = 2 })
24
+ local dataStore = controller.newDataStore("key")
25
+
26
+ local valueA, valueB, all
27
+ dataStore:Load("a"):Then(function(value)
28
+ valueA = value
29
+ end)
30
+ dataStore:Load("b"):Then(function(value)
31
+ valueB = value
32
+ end)
33
+ dataStore:LoadAll():Then(function(value)
34
+ all = value
35
+ end)
36
+
37
+ expect(PromiseTestUtils.awaitValue(function()
38
+ return valueA ~= nil and valueB ~= nil and all ~= nil
39
+ end, 5)).toEqual(true)
40
+ expect(valueA).toEqual(1)
41
+ expect(valueB).toEqual(2)
42
+ expect(all).toEqual({ a = 1, b = 2 })
43
+
44
+ controller:destroy()
45
+ end)
46
+
47
+ it("resolves all consumers of a slow (yielding) load attached during the yield", function()
48
+ local controller = DataStoreTestUtils.setup()
49
+ controller.mock:SetRaw("key", { a = 1 })
50
+ controller.mock:SetYieldTime(0.3)
51
+ local dataStore = controller.newDataStore("key")
52
+
53
+ local results = {}
54
+ for i = 1, 3 do
55
+ dataStore:Load("a"):Then(function(value)
56
+ results[i] = value
57
+ end)
58
+ end
59
+
60
+ expect(PromiseTestUtils.awaitValue(function()
61
+ return results[1] ~= nil and results[2] ~= nil and results[3] ~= nil
62
+ end, 5)).toEqual(true)
63
+ expect(results[1]).toEqual(1)
64
+ expect(results[2]).toEqual(1)
65
+ expect(results[3]).toEqual(1)
66
+
67
+ controller:destroy()
68
+ end)
69
+
70
+ it("delivers the rejection to every consumer when the shared load fails", function()
71
+ local controller = DataStoreTestUtils.setup()
72
+ controller.mock:FailAllRequests()
73
+ local dataStore = controller.newDataStore("key")
74
+
75
+ local aRejected, bRejected = false, false
76
+ dataStore:Load("a"):Then(nil, function()
77
+ aRejected = true
78
+ end)
79
+ dataStore:Load("b"):Then(nil, function()
80
+ bRejected = true
81
+ end)
82
+
83
+ expect(PromiseTestUtils.awaitValue(function()
84
+ return aRejected and bRejected
85
+ end, 5)).toEqual(true)
86
+
87
+ controller:destroy()
88
+ end)
89
+ end)
90
+
91
+ describe("auto-save loop", function()
92
+ it("persists staged data on the auto-save schedule without an explicit Save", function()
93
+ local controller = DataStoreTestUtils.setup()
94
+ local dataStore = controller.newDataStore("key")
95
+ dataStore:SetAutoSaveTimeSeconds(0.2)
96
+
97
+ -- Store triggers the initial load; once loaded, the auto-save loop starts and flushes.
98
+ dataStore:Store("coins", 5)
99
+
100
+ local saved = PromiseTestUtils.awaitValue(function()
101
+ local raw = controller.mock:GetRaw("key")
102
+ return raw ~= nil and raw.coins == 5
103
+ end, 6)
104
+ expect(saved).toEqual(true)
105
+
106
+ controller:destroy()
107
+ end)
108
+
109
+ it("stops auto-saving after the datastore is destroyed", function()
110
+ local controller = DataStoreTestUtils.setup()
111
+ local dataStore = controller.newDataStore("key")
112
+ dataStore:SetAutoSaveTimeSeconds(0.2)
113
+ dataStore:Store("coins", 5)
114
+
115
+ expect(PromiseTestUtils.awaitValue(function()
116
+ local raw = controller.mock:GetRaw("key")
117
+ return raw ~= nil and raw.coins == 5
118
+ end, 6)).toEqual(true)
119
+
120
+ dataStore:Destroy()
121
+
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
+ local before = controller.mock:GetCallCount("UpdateAsync")
125
+ local settled = PromiseTestUtils.awaitValue(function()
126
+ return false
127
+ end, 1)
128
+ expect(settled).toEqual(false)
129
+ expect(controller.mock:GetCallCount("UpdateAsync")).toEqual(before)
130
+
131
+ controller:destroy()
132
+ end)
133
+ end)
@@ -0,0 +1,119 @@
1
+ --!nonstrict
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
+ @class DataStoreReentrance.spec.lua
9
+ ]]
10
+ local require = require(script.Parent.loader).load(script)
11
+
12
+ local DataStoreTestUtils = require("DataStoreTestUtils")
13
+ local Jest = require("Jest")
14
+ local PromiseTestUtils = require("PromiseTestUtils")
15
+
16
+ local describe = Jest.Globals.describe
17
+ local expect = Jest.Globals.expect
18
+ local it = Jest.Globals.it
19
+
20
+ describe("in-flight request cancellation (maid teardown)", function()
21
+ it("cancels a yielding load thread when the DataStore is destroyed (no leaked thread)", function()
22
+ local controller = DataStoreTestUtils.setup()
23
+ controller.mock:BlockRequests() -- the lock-acquire request hangs, so the load is stuck in flight
24
+
25
+ local dataStore = controller.newSessionLockedStore()
26
+ local promise = dataStore:PromiseLoadSuccessful()
27
+
28
+ expect(PromiseTestUtils.awaitSettled(promise, 1)).toEqual(false)
29
+
30
+ -- Destroy while the request thread is yielding -- its maid must cancel it.
31
+ dataStore:Destroy()
32
+
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
+ controller.mock:UnblockRequests()
36
+ local everWrote = PromiseTestUtils.awaitValue(function()
37
+ return controller.mock:GetRaw("player_1") ~= nil
38
+ end, 2)
39
+ expect(everWrote).toEqual(false)
40
+
41
+ controller:destroy()
42
+ end)
43
+
44
+ it("cancels a yielding save thread when the DataStore is destroyed", function()
45
+ local controller = DataStoreTestUtils.setup()
46
+
47
+ -- Load cleanly first (acquires the lock), then block so the SAVE request hangs in flight.
48
+ local dataStore = controller.newSessionLockedStore()
49
+ local loadPromise = dataStore:PromiseLoadSuccessful()
50
+ if not PromiseTestUtils.awaitSettled(loadPromise, 10) then
51
+ expect("load hung").toEqual("load settled")
52
+ controller:destroy()
53
+ return
54
+ end
55
+
56
+ controller.mock:BlockRequests()
57
+ dataStore:Store("coins", 5)
58
+ local savePromise = dataStore:Save()
59
+ expect(PromiseTestUtils.awaitSettled(savePromise, 1)).toEqual(false)
60
+
61
+ local versionsBefore = controller.mock:GetCallCount("UpdateAsync")
62
+ dataStore:Destroy()
63
+ controller.mock:UnblockRequests()
64
+
65
+ -- The cancelled save thread must not resume and perform another UpdateAsync write.
66
+ local extraWrites = PromiseTestUtils.awaitValue(function()
67
+ return controller.mock:GetCallCount("UpdateAsync") > versionsBefore + 1
68
+ end, 2)
69
+ expect(extraWrites).toEqual(false)
70
+
71
+ controller:destroy()
72
+ end)
73
+ end)
74
+
75
+ describe("lock command that does not settle", function()
76
+ it("keeps the load pending while the lock command is outstanding, then completes when it settles", function()
77
+ local controller = DataStoreTestUtils.setup()
78
+ controller.mock:BlockRequests() -- simulate a lock command that hasn't propagated yet (up to ~30s)
79
+
80
+ local dataStore = controller.newSessionLockedStore()
81
+ local promise = dataStore:PromiseLoadSuccessful()
82
+
83
+ -- Must NOT spuriously resolve or error while the lock command is outstanding.
84
+ expect(PromiseTestUtils.awaitSettled(promise, 2)).toEqual(false)
85
+
86
+ controller.mock:UnblockRequests()
87
+ if not PromiseTestUtils.awaitSettled(promise, 10) then
88
+ expect("hung after unblock").toEqual("settled")
89
+ controller:destroy()
90
+ return
91
+ end
92
+ expect((promise:Wait())).toEqual(true)
93
+
94
+ controller:destroy()
95
+ end)
96
+
97
+ it("re-loads cleanly on a fresh session after a cancelled in-flight lock command", function()
98
+ local controller = DataStoreTestUtils.setup()
99
+ controller.mock:BlockRequests()
100
+
101
+ local first = controller.newSessionLockedStore()
102
+ local firstPromise = first:PromiseLoadSuccessful()
103
+ expect(PromiseTestUtils.awaitSettled(firstPromise, 1)).toEqual(false)
104
+ first:Destroy() -- cancel the in-flight lock command
105
+ controller.mock:UnblockRequests()
106
+
107
+ -- The cancelled attempt left no lock, so a fresh session acquires cleanly.
108
+ local second = controller.newSessionLockedStore()
109
+ local secondPromise = second:PromiseLoadSuccessful()
110
+ if not PromiseTestUtils.awaitSettled(secondPromise, 10) then
111
+ expect("hung").toEqual("settled")
112
+ controller:destroy()
113
+ return
114
+ end
115
+ expect((secondPromise:Wait())).toEqual(true)
116
+
117
+ controller:destroy()
118
+ end)
119
+ end)