@quenty/datastore 13.51.1 → 13.52.1

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.
@@ -0,0 +1,139 @@
1
+ --!strict
2
+ --[=[
3
+ Pure reading and writing of the session-lock envelope a [DataStore] keeps under the `lock` field.
4
+
5
+ [DataStoreLockHelper] owns the live path, where the lock is only ever touched by the session that
6
+ holds it. Tooling needs the other path: inspect or clear the lock on a key belonging to a player
7
+ who is not in this server, through a raw datastore write with no session at all. Both go through
8
+ here so there is one definition of the envelope.
9
+
10
+ @server
11
+ @class DataStoreLockUtils
12
+ ]=]
13
+
14
+ local DataStoreLockUtils = {}
15
+
16
+ export type LockedSessionData = {
17
+ SessionId: string,
18
+ PlaceId: number,
19
+ JobId: string,
20
+ }
21
+
22
+ export type LockData = {
23
+ LastUpdateTime: number?,
24
+ ActiveSession: LockedSessionData?,
25
+ }
26
+
27
+ --[=[
28
+ Reads session data back out of whatever the datastore returned, rejecting anything malformed.
29
+
30
+ @param sessionData any
31
+ @return LockedSessionData?
32
+ ]=]
33
+ function DataStoreLockUtils.deserializeSessionData(sessionData: any): LockedSessionData?
34
+ if type(sessionData) ~= "table" then
35
+ return nil
36
+ end
37
+
38
+ if type(sessionData.SessionId) ~= "string" then
39
+ return nil
40
+ end
41
+
42
+ if type(sessionData.PlaceId) ~= "number" then
43
+ return nil
44
+ end
45
+
46
+ if type(sessionData.JobId) ~= "string" then
47
+ return nil
48
+ end
49
+
50
+ return {
51
+ SessionId = sessionData.SessionId,
52
+ PlaceId = sessionData.PlaceId,
53
+ JobId = sessionData.JobId,
54
+ }
55
+ end
56
+
57
+ --[=[
58
+ Reads a lock envelope back out of whatever the datastore returned.
59
+
60
+ @param lockData any
61
+ @return LockData?
62
+ ]=]
63
+ function DataStoreLockUtils.deserializeLockData(lockData: any): LockData?
64
+ if type(lockData) ~= "table" then
65
+ return nil
66
+ end
67
+
68
+ return {
69
+ LastUpdateTime = if type(lockData.LastUpdateTime) == "number" then lockData.LastUpdateTime else nil,
70
+ ActiveSession = DataStoreLockUtils.deserializeSessionData(lockData.ActiveSession),
71
+ }
72
+ end
73
+
74
+ --[=[
75
+ Reads the lock off a whole stored profile.
76
+
77
+ @param data any -- the raw value stored at the key
78
+ @return LockData?
79
+ ]=]
80
+ function DataStoreLockUtils.readLock(data: any): LockData?
81
+ if type(data) ~= "table" then
82
+ return nil
83
+ end
84
+
85
+ return DataStoreLockUtils.deserializeLockData(data.lock)
86
+ end
87
+
88
+ --[=[
89
+ Builds the envelope a session writes to claim the key.
90
+
91
+ @param sessionData LockedSessionData
92
+ @param lastUpdateTime number? -- defaults to now
93
+ @return LockData
94
+ ]=]
95
+ function DataStoreLockUtils.createLockData(sessionData: LockedSessionData, lastUpdateTime: number?): LockData
96
+ return {
97
+ LastUpdateTime = lastUpdateTime or os.time(),
98
+ ActiveSession = sessionData,
99
+ }
100
+ end
101
+
102
+ --[=[
103
+ Returns a copy of the profile with the lock set to `lockData`, or cleared when it is nil.
104
+
105
+ @param data any -- the raw value stored at the key
106
+ @param lockData LockData?
107
+ @return any
108
+ ]=]
109
+ function DataStoreLockUtils.withLock(data: any, lockData: LockData?): any
110
+ if data == nil then
111
+ return if lockData == nil then {} else { lock = lockData }
112
+ elseif type(data) ~= "table" then
113
+ warn("[DataStoreLockUtils] - Data session locking is not available for non-table entries")
114
+ return data
115
+ end
116
+
117
+ local copy = table.clone(data)
118
+ copy.lock = lockData
119
+ return copy
120
+ end
121
+
122
+ --[=[
123
+ Renders a lock for a human reading command output.
124
+
125
+ @param lockData LockData?
126
+ @return string
127
+ ]=]
128
+ function DataStoreLockUtils.toHumanReadable(lockData: LockData?): string
129
+ if lockData == nil or lockData.ActiveSession == nil then
130
+ return "unlocked"
131
+ end
132
+
133
+ local session = lockData.ActiveSession
134
+ local age = if lockData.LastUpdateTime then `{os.time() - lockData.LastUpdateTime}s ago` else "unknown age"
135
+
136
+ return `locked by PlaceId {session.PlaceId}, JobId {session.JobId}, SessionId {session.SessionId} (updated {age})`
137
+ end
138
+
139
+ return DataStoreLockUtils
@@ -0,0 +1,103 @@
1
+ --!strict
2
+ --[=[
3
+ A borrowed [DataStore] for a player, and the obligation to put it back.
4
+
5
+ Opening a store for a player who is not in this server takes their session lock, which kicks them
6
+ from wherever they were playing. Until that lock is dropped again they cannot rejoin, so releasing
7
+ it is the part that matters -- and a release the caller has to remember to perform is one that
8
+ eventually gets missed on an error path.
9
+
10
+ Handing back a handle makes it a [Maid]-shaped obligation instead: give it to a maid, or destroy
11
+ it, and the session is released once nothing is using it. Destroying twice is safe.
12
+
13
+ Handles are counted, so three systems loading the same player each get their own and the session
14
+ survives until the last one is destroyed.
15
+
16
+ A player in this server is not represented by a handle -- their session is owned by the join and
17
+ leave path, which reaches removal from several directions a handle could not model safely. So
18
+ destroying a handle never closes a live player's session; it only releases one this tooling
19
+ opened on behalf of someone absent.
20
+
21
+ ```lua
22
+ local handle = manager:PromiseDataStoreHandle(userId):Yield()
23
+ local data = handle:GetDataStore():LoadAll({}):Yield()
24
+ handle:Destroy()
25
+ ```
26
+
27
+ @server
28
+ @class PlayerDataStoreHandle
29
+ ]=]
30
+
31
+ local require = require(script.Parent.loader).load(script)
32
+
33
+ local DataStore = require("DataStore")
34
+
35
+ local PlayerDataStoreHandle = {}
36
+ PlayerDataStoreHandle.ClassName = "PlayerDataStoreHandle"
37
+ PlayerDataStoreHandle.__index = PlayerDataStoreHandle
38
+
39
+ export type PlayerDataStoreHandle = typeof(setmetatable(
40
+ {} :: {
41
+ _dataStore: DataStore.DataStore?,
42
+ _release: (() -> ())?,
43
+ },
44
+ {} :: typeof({ __index = PlayerDataStoreHandle })
45
+ ))
46
+
47
+ --[=[
48
+ Constructs a new handle over a datastore.
49
+
50
+ @param dataStore DataStore
51
+ @param release (() -> ())? -- invoked on destroy, when this handle is the one that borrowed the store
52
+ @return PlayerDataStoreHandle
53
+ ]=]
54
+ function PlayerDataStoreHandle.new(dataStore: DataStore.DataStore, release: (() -> ())?): PlayerDataStoreHandle
55
+ local self: PlayerDataStoreHandle = setmetatable({} :: any, PlayerDataStoreHandle)
56
+
57
+ self._dataStore = assert(dataStore, "No dataStore")
58
+ self._release = release
59
+
60
+ return self
61
+ end
62
+
63
+ --[=[
64
+ Returns whether the value is a handle.
65
+
66
+ @param value any
67
+ @return boolean
68
+ ]=]
69
+ function PlayerDataStoreHandle.isPlayerDataStoreHandle(value: any): boolean
70
+ return type(value) == "table" and getmetatable(value) == PlayerDataStoreHandle
71
+ end
72
+
73
+ --[=[
74
+ Returns the datastore this handle holds. Errors once destroyed, since the session behind it may
75
+ already be closed.
76
+
77
+ @return DataStore
78
+ ]=]
79
+ function PlayerDataStoreHandle.GetDataStore(self: PlayerDataStoreHandle): DataStore.DataStore
80
+ -- Bound to one value rather than returned straight out of the assert, which would hand back its
81
+ -- message as a second return and quietly widen every call site into a multiple-value expression.
82
+ local dataStore = self._dataStore
83
+ assert(dataStore, "Handle is destroyed")
84
+
85
+ return dataStore
86
+ end
87
+
88
+ --[=[
89
+ Drops this handle's reference to the session. The session itself is released once no handle and
90
+ no player in this server is still holding it.
91
+ ]=]
92
+ function PlayerDataStoreHandle.Destroy(self: PlayerDataStoreHandle): ()
93
+ local release = self._release
94
+
95
+ self._dataStore = nil
96
+ self._release = nil
97
+
98
+ if release then
99
+ release()
100
+ end
101
+ end
102
+
103
+ return PlayerDataStoreHandle
@@ -0,0 +1,136 @@
1
+ --!strict
2
+ --[[
3
+ Counted datastore handles. Opening a store for an absent player takes their session lock, so the
4
+ thing worth pinning down is that the session is released once nothing is holding it -- and not
5
+ before, while another holder is still using it.
6
+
7
+ @class PlayerDataStoreManager.Handles.spec.lua
8
+ ]]
9
+ local require = require(script.Parent.loader).load(script)
10
+
11
+ local DataStoreTestUtils = require("DataStoreTestUtils")
12
+ local Jest = require("Jest")
13
+ local PromiseTestUtils = require("PromiseTestUtils")
14
+
15
+ local describe = Jest.Globals.describe
16
+ local expect = Jest.Globals.expect
17
+ local it = Jest.Globals.it
18
+
19
+ -- Returns the resolved value, or fails the spec and returns nil.
20
+ local function awaitValue(promise, label: string): any
21
+ if not PromiseTestUtils.awaitSettled(promise, 10) then
22
+ expect(`{label} hung`).toEqual(`{label} settled`)
23
+ return nil
24
+ end
25
+
26
+ local ok, value = promise:Yield()
27
+ expect(ok).toEqual(true)
28
+ return value
29
+ end
30
+
31
+ -- Drains the removals a test left in flight, so none of them settle during teardown. Note that this
32
+ -- also *removes* every remaining store, so it belongs at the end of a test and nowhere else.
33
+ local function settleSaves(controller): ()
34
+ if not PromiseTestUtils.awaitSettled(controller.manager:PromiseAllSaves(), 10) then
35
+ expect("saves hung").toEqual("saves settled")
36
+ end
37
+ end
38
+
39
+ describe("PlayerDataStoreManager.PromiseDataStoreHandle", function()
40
+ it("hands back the store for the player", function()
41
+ local controller = DataStoreTestUtils.setupDataStoreManager()
42
+
43
+ controller.mock:SetRaw("user_1", { coins = 5 })
44
+
45
+ local handle = awaitValue(controller.manager:PromiseDataStoreHandle(1), "handle")
46
+ expect(handle).never.toBeNil()
47
+ expect(handle:GetDataStore()).never.toBeNil()
48
+
49
+ handle:Destroy()
50
+ settleSaves(controller)
51
+
52
+ controller:destroy()
53
+ end)
54
+
55
+ -- Whether the session is still open is read through store identity rather than the stored lock:
56
+ -- the lock is written asynchronously as the load settles, so asserting on it here would be timing
57
+ -- dependent. A released session is gone, so the next handle has to build a new store object.
58
+ it("releases the session once the handle is destroyed", function()
59
+ local controller = DataStoreTestUtils.setupDataStoreManager()
60
+
61
+ controller.mock:SetRaw("user_1", { coins = 5 })
62
+
63
+ local first = awaitValue(controller.manager:PromiseDataStoreHandle(1), "first")
64
+ local released = first:GetDataStore()
65
+
66
+ first:Destroy()
67
+
68
+ local second = awaitValue(controller.manager:PromiseDataStoreHandle(1), "second")
69
+ expect(second:GetDataStore() == released).toEqual(false)
70
+
71
+ second:Destroy()
72
+ settleSaves(controller)
73
+
74
+ controller:destroy()
75
+ end)
76
+
77
+ it("holds the session while another handle is still open", function()
78
+ local controller = DataStoreTestUtils.setupDataStoreManager()
79
+
80
+ controller.mock:SetRaw("user_1", { coins = 5 })
81
+
82
+ local first = awaitValue(controller.manager:PromiseDataStoreHandle(1), "first")
83
+ local second = awaitValue(controller.manager:PromiseDataStoreHandle(1), "second")
84
+
85
+ local shared = first:GetDataStore()
86
+
87
+ -- Both name the same session rather than opening a second one.
88
+ expect(second:GetDataStore() == shared).toEqual(true)
89
+
90
+ first:Destroy()
91
+
92
+ -- Still the same store, because the second handle never let go of it.
93
+ local third = awaitValue(controller.manager:PromiseDataStoreHandle(1), "third")
94
+ expect(third:GetDataStore() == shared).toEqual(true)
95
+
96
+ second:Destroy()
97
+ third:Destroy()
98
+
99
+ local fourth = awaitValue(controller.manager:PromiseDataStoreHandle(1), "fourth")
100
+ expect(fourth:GetDataStore() == shared).toEqual(false)
101
+
102
+ fourth:Destroy()
103
+ settleSaves(controller)
104
+
105
+ controller:destroy()
106
+ end)
107
+
108
+ it("survives being destroyed twice", function()
109
+ local controller = DataStoreTestUtils.setupDataStoreManager()
110
+
111
+ controller.mock:SetRaw("user_1", { coins = 5 })
112
+
113
+ local handle = awaitValue(controller.manager:PromiseDataStoreHandle(1), "handle")
114
+ handle:Destroy()
115
+ handle:Destroy()
116
+ settleSaves(controller)
117
+
118
+ controller:destroy()
119
+ end)
120
+
121
+ it("refuses to hand back a store once destroyed", function()
122
+ local controller = DataStoreTestUtils.setupDataStoreManager()
123
+
124
+ controller.mock:SetRaw("user_1", { coins = 5 })
125
+
126
+ local handle = awaitValue(controller.manager:PromiseDataStoreHandle(1), "handle")
127
+ handle:Destroy()
128
+ settleSaves(controller)
129
+
130
+ expect(function()
131
+ handle:GetDataStore()
132
+ end).toThrow()
133
+
134
+ controller:destroy()
135
+ end)
136
+ end)
@@ -0,0 +1,228 @@
1
+ --!strict
2
+ --[[
3
+ The raw session-lock path: reading and writing the lock on a key without opening a session on it.
4
+ Usually the target is a player who is not in this server, but the write is deliberately permitted
5
+ against a live local session too -- see the last describe block.
6
+
7
+ @class PlayerDataStoreManager.SessionLockTools.spec.lua
8
+ ]]
9
+ local require = require(script.Parent.loader).load(script)
10
+
11
+ local DataStoreTestUtils = require("DataStoreTestUtils")
12
+ local Jest = require("Jest")
13
+ local PromiseTestUtils = require("PromiseTestUtils")
14
+
15
+ local describe = Jest.Globals.describe
16
+ local expect = Jest.Globals.expect
17
+ local it = Jest.Globals.it
18
+
19
+ local FOREIGN_SESSION = {
20
+ SessionId = "foreign-session",
21
+ PlaceId = 123,
22
+ JobId = "foreign-job",
23
+ }
24
+
25
+ local function seedForeignLock(mock, key: string, profile: { [string]: any }?)
26
+ local data: { [string]: any } = profile or {}
27
+ data.lock = {
28
+ LastUpdateTime = os.time(),
29
+ ActiveSession = FOREIGN_SESSION,
30
+ }
31
+ mock:SetRaw(key, data)
32
+ end
33
+
34
+ -- Returns the resolved value, or fails the spec and returns nil.
35
+ local function awaitValue(promise, label: string): any
36
+ if not PromiseTestUtils.awaitSettled(promise, 10) then
37
+ expect(`{label} hung`).toEqual(`{label} settled`)
38
+ return nil
39
+ end
40
+
41
+ local ok, value = promise:Yield()
42
+ expect(ok).toEqual(true)
43
+ return value
44
+ end
45
+
46
+ describe("PlayerDataStoreManager.PromiseReadSessionLock", function()
47
+ it("resolves nil for a key that was never written", function()
48
+ local controller = DataStoreTestUtils.setupDataStoreManager()
49
+
50
+ local lock = awaitValue(controller.manager:PromiseReadSessionLock(1), "read")
51
+ expect(lock).toBeNil()
52
+
53
+ controller:destroy()
54
+ end)
55
+
56
+ it("reports the session holding a foreign lock", function()
57
+ local controller = DataStoreTestUtils.setupDataStoreManager()
58
+
59
+ seedForeignLock(controller.mock, "user_1", { coins = 5 })
60
+
61
+ local lock = awaitValue(controller.manager:PromiseReadSessionLock(1), "read")
62
+ expect(lock).never.toBeNil()
63
+ expect(lock.ActiveSession).toEqual(FOREIGN_SESSION)
64
+
65
+ controller:destroy()
66
+ end)
67
+
68
+ it("resolves nil for a stored profile with no lock", function()
69
+ local controller = DataStoreTestUtils.setupDataStoreManager()
70
+
71
+ controller.mock:SetRaw("user_1", { coins = 5 })
72
+
73
+ local lock = awaitValue(controller.manager:PromiseReadSessionLock(1), "read")
74
+ expect(lock).toBeNil()
75
+
76
+ controller:destroy()
77
+ end)
78
+ end)
79
+
80
+ describe("PlayerDataStoreManager.PromiseUnlockSession", function()
81
+ it("clears a foreign lock and reports what it cleared", function()
82
+ local controller = DataStoreTestUtils.setupDataStoreManager()
83
+
84
+ seedForeignLock(controller.mock, "user_1", { coins = 5 })
85
+
86
+ local previous = awaitValue(controller.manager:PromiseUnlockSession(1), "unlock")
87
+ expect(previous).never.toBeNil()
88
+ expect(previous.ActiveSession).toEqual(FOREIGN_SESSION)
89
+
90
+ expect(controller.mock:GetRaw("user_1").lock).toBeNil()
91
+
92
+ controller:destroy()
93
+ end)
94
+
95
+ it("leaves the rest of the profile untouched", function()
96
+ local controller = DataStoreTestUtils.setupDataStoreManager()
97
+
98
+ seedForeignLock(controller.mock, "user_1", { coins = 5, level = 3 })
99
+
100
+ awaitValue(controller.manager:PromiseUnlockSession(1), "unlock")
101
+
102
+ local raw = controller.mock:GetRaw("user_1")
103
+ expect(raw.coins).toEqual(5)
104
+ expect(raw.level).toEqual(3)
105
+
106
+ controller:destroy()
107
+ end)
108
+
109
+ it("resolves nil on an already-unlocked key", function()
110
+ local controller = DataStoreTestUtils.setupDataStoreManager()
111
+
112
+ controller.mock:SetRaw("user_1", { coins = 5 })
113
+
114
+ local previous = awaitValue(controller.manager:PromiseUnlockSession(1), "unlock")
115
+ expect(previous).toBeNil()
116
+
117
+ controller:destroy()
118
+ end)
119
+
120
+ it("does not create an entry for a key that was never written", function()
121
+ local controller = DataStoreTestUtils.setupDataStoreManager()
122
+
123
+ awaitValue(controller.manager:PromiseUnlockSession(1), "unlock")
124
+
125
+ expect(controller.mock:GetRaw("user_1")).toBeNil()
126
+
127
+ controller:destroy()
128
+ end)
129
+ end)
130
+
131
+ describe("PlayerDataStoreManager.PromiseLockSession", function()
132
+ it("claims an unlocked key", function()
133
+ local controller = DataStoreTestUtils.setupDataStoreManager()
134
+
135
+ controller.mock:SetRaw("user_1", { coins = 5 })
136
+
137
+ local previous = awaitValue(controller.manager:PromiseLockSession(1), "lock")
138
+ expect(previous).toBeNil()
139
+
140
+ local lock = awaitValue(controller.manager:PromiseReadSessionLock(1), "read")
141
+ expect(lock).never.toBeNil()
142
+ expect(lock.ActiveSession.PlaceId).toEqual(game.PlaceId)
143
+ expect(controller.mock:GetRaw("user_1").coins).toEqual(5)
144
+
145
+ controller:destroy()
146
+ end)
147
+
148
+ it("reports the lock it replaced", function()
149
+ local controller = DataStoreTestUtils.setupDataStoreManager()
150
+
151
+ seedForeignLock(controller.mock, "user_1", { coins = 5 })
152
+
153
+ local previous = awaitValue(controller.manager:PromiseLockSession(1), "lock")
154
+ expect(previous).never.toBeNil()
155
+ expect(previous.ActiveSession).toEqual(FOREIGN_SESSION)
156
+
157
+ controller:destroy()
158
+ end)
159
+
160
+ it("claims a key that was never written", function()
161
+ local controller = DataStoreTestUtils.setupDataStoreManager()
162
+
163
+ awaitValue(controller.manager:PromiseLockSession(1), "lock")
164
+
165
+ local lock = awaitValue(controller.manager:PromiseReadSessionLock(1), "read")
166
+ expect(lock).never.toBeNil()
167
+
168
+ controller:destroy()
169
+ end)
170
+ end)
171
+
172
+ describe("PlayerDataStoreManager raw lock writes against a live session", function()
173
+ -- Allowed on purpose: pulling the key out from under a live local session is the failure the
174
+ -- lock/unlock debug tools exist to provoke. See PlayerDataStoreManager._promiseWriteRawSessionLock.
175
+ it("writes the lock while this server holds a session for that user", function()
176
+ local controller = DataStoreTestUtils.setupDataStoreManager()
177
+
178
+ if not controller.storeAndAwaitLock() then
179
+ expect("lock was never acquired").toEqual("lock was acquired")
180
+ controller:destroy()
181
+ return
182
+ end
183
+
184
+ local previous = awaitValue(controller.manager:PromiseUnlockSession(1), "unlock")
185
+ expect(previous).never.toBeNil()
186
+ expect(previous.ActiveSession.JobId).toEqual(game.JobId)
187
+ expect(controller.mock:GetRaw("user_1").lock).toBeNil()
188
+
189
+ controller:destroy()
190
+ end)
191
+
192
+ it("still serves a read while this server holds a session", function()
193
+ local controller = DataStoreTestUtils.setupDataStoreManager()
194
+
195
+ if not controller.storeAndAwaitLock() then
196
+ expect("lock was never acquired").toEqual("lock was acquired")
197
+ controller:destroy()
198
+ return
199
+ end
200
+
201
+ local lock = awaitValue(controller.manager:PromiseReadSessionLock(1), "read")
202
+ expect(lock).never.toBeNil()
203
+ expect(lock.ActiveSession.JobId).toEqual(game.JobId)
204
+
205
+ controller:destroy()
206
+ end)
207
+
208
+ it("writes once the session has been removed", function()
209
+ local controller = DataStoreTestUtils.setupDataStoreManager()
210
+
211
+ if not controller.storeAndAwaitLock() then
212
+ expect("lock was never acquired").toEqual("lock was acquired")
213
+ controller:destroy()
214
+ return
215
+ end
216
+
217
+ if not PromiseTestUtils.awaitSettled(controller.promiseShutdown({ 1 }), 10) then
218
+ expect("shutdown hung").toEqual("shutdown settled")
219
+ controller:destroy()
220
+ return
221
+ end
222
+
223
+ awaitValue(controller.manager:PromiseLockSession(1), "lock")
224
+ expect(controller.mock:GetRaw("user_1").lock).never.toBeNil()
225
+
226
+ controller:destroy()
227
+ end)
228
+ end)