@quenty/datastore 13.51.0 → 13.52.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.
@@ -102,14 +102,83 @@ function DataStoreTestUtils.setup()
102
102
  }
103
103
  end
104
104
 
105
+ --[=[
106
+ Simulates what a real Roblox server does when it shuts down, which is the only accurate model for
107
+ the save path: Roblox fires PlayerRemoving for every player still in the server, giving those
108
+ handlers the same "hold the shutdown open for me" treatment BindToClose gets. So the removals are
109
+ what save and close each session; the close callback's job is only to wait for them to flush.
110
+
111
+ Destroying the manager is NOT a shutdown and never has been -- nothing in a live server destroys it.
112
+
113
+ @param manager PlayerDataStoreManager
114
+ @param userIds { PlayerUserId }? -- players still in the server when it began closing
115
+ @return Promise -- what BindToCloseService yields on, so the server cannot die until it settles
116
+ ]=]
117
+ function DataStoreTestUtils.promiseSimulatedShutdown(manager, userIds)
118
+ for _, userId in userIds or {} do
119
+ manager:RemovePlayerDataStore(userId)
120
+ end
121
+
122
+ return manager:PromiseAllSaves()
123
+ end
124
+
125
+ --[=[
126
+ Shuts down the manager a [PlayerDataStoreService] owns, the way Roblox would, and waits for it.
127
+
128
+ Call this from the `destroy()` of any spec that injects a datastore into the service and tears down
129
+ by destroying its ServiceBag. `manager:Destroy()` destroys no stores -- they are only ever destroyed
130
+ by a removal -- and a [PlayerMock] never fires the real `Players.PlayerRemoving`, so without this
131
+ every store the spec loaded outlives it with its `task.spawn` auto-save loop running. In the shared
132
+ test place that loop later fires inside another package's window and fails it.
133
+
134
+ `userIds` is only needed to model "PlayerRemoving landed first" for an ordering assertion. For
135
+ cleanup, omit it: the close removes every store the manager still owns.
136
+
137
+ @param playerDataStoreService PlayerDataStoreService
138
+ @param userIds { PlayerUserId }?
139
+ @param timeout number? -- defaults to 5
140
+ @return boolean -- false only if a shutdown was started and did not settle in time
141
+ ]=]
142
+ function DataStoreTestUtils.awaitServiceShutdown(playerDataStoreService, userIds, timeout)
143
+ local managerPromise = playerDataStoreService:PromiseManager()
144
+
145
+ -- Nothing to shut down yet, so return rather than sit out the timeout. The manager is only
146
+ -- constructed inside the last link of PromiseManager's chain, so a promise still pending here means
147
+ -- that link never ran: no manager exists, so _createDataStore was never called and no store exists to
148
+ -- leak. That holds whatever the promise is waiting on -- a ServiceBag that was never started, or a
149
+ -- real datastore still resolving. Specs that start the bag per-test would otherwise pay the full
150
+ -- timeout on every teardown.
151
+ if managerPromise:IsPending() then
152
+ return true
153
+ end
154
+
155
+ return PromiseTestUtils.awaitSettled(
156
+ managerPromise:Then(function(manager)
157
+ -- Promise runs handlers without a pcall, so calling a destroyed manager would throw straight
158
+ -- out of the spec's teardown rather than fail it.
159
+ if not manager.Destroy then
160
+ return nil
161
+ end
162
+
163
+ return DataStoreTestUtils.promiseSimulatedShutdown(manager, userIds)
164
+ end),
165
+ timeout or 5
166
+ )
167
+ end
168
+
105
169
  --[=[
106
170
  Builds the controller the [PlayerDataStoreManager] specs share: a session-locked manager wired to
107
- a fresh [DataStoreMock] (keyed `user_<userId>`), all owned by a Maid. `destroy()` tears down the
108
- manager (and the loaded stores whose auto-save loops it owns) and the service bag.
171
+ a fresh [DataStoreMock] (keyed `user_<userId>`), all owned by a Maid.
172
+
173
+ `destroy()` shuts the server down the way Roblox would (see
174
+ [DataStoreTestUtils.promiseSimulatedShutdown]) and then tears the objects down. The shutdown is not
175
+ optional bookkeeping: a store the spec loaded keeps its auto-save loop running until something
176
+ removes it, and in the shared test place that loop outlives the spec and fires inside a later
177
+ package's window.
109
178
 
110
179
  Fields: `manager`, `mock`, `serviceBag`.
111
180
  Helpers: `storeAndAwaitLock()` -> boolean -- stores a value on user 1's store and waits for the
112
- session-locked load to write the lock envelope.
181
+ session-locked load to write the lock envelope. `promiseShutdown(userIds?)` -> Promise.
113
182
 
114
183
  @return { manager: PlayerDataStoreManager, mock: DataStoreMock, ... }
115
184
  ]=]
@@ -123,6 +192,7 @@ function DataStoreTestUtils.setupDataStoreManager()
123
192
  return "user_" .. tostring(userId)
124
193
  end, true))
125
194
 
195
+ -- Returns exactly one value: specs call this straight through expect(), which rejects a second arg.
126
196
  local function storeAndAwaitLock()
127
197
  local dataStore = manager:GetDataStore(1)
128
198
  dataStore:Store("coins", 5)
@@ -133,12 +203,18 @@ function DataStoreTestUtils.setupDataStoreManager()
133
203
  end, 10)
134
204
  end
135
205
 
206
+ local function promiseShutdown(userIds)
207
+ return DataStoreTestUtils.promiseSimulatedShutdown(manager, userIds)
208
+ end
209
+
136
210
  return {
137
211
  manager = manager,
138
212
  mock = mock,
139
213
  serviceBag = serviceBag,
140
214
  storeAndAwaitLock = storeAndAwaitLock,
215
+ promiseShutdown = promiseShutdown,
141
216
  destroy = function()
217
+ PromiseTestUtils.awaitSettled(promiseShutdown(), 5)
142
218
  maid:DoCleaning()
143
219
  end,
144
220
  }
@@ -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)
@@ -57,6 +57,7 @@ describe("PlayerDataStoreManager removal matrix (misbehaving removing callbacks)
57
57
  error("removing callback boom")
58
58
  end)
59
59
 
60
+ local dataStore = controller.manager:GetDataStore(1)
60
61
  expect(controller.storeAndAwaitLock()).toEqual(true)
61
62
 
62
63
  expect(function()
@@ -64,6 +65,10 @@ describe("PlayerDataStoreManager removal matrix (misbehaving removing callbacks)
64
65
  end).toThrow("removing callback boom")
65
66
  expect(controller.mock:GetRaw("user_1").lock ~= nil).toEqual(true)
66
67
 
68
+ -- The throw latched _removing before the store left _datastores, so no later removal can
69
+ -- reach it and nothing else destroys it. Kill its auto-save loop by hand, or it outlives this
70
+ -- spec and fires inside a later package's window.
71
+ dataStore:Destroy()
67
72
  controller:destroy()
68
73
  end)
69
74