@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.
- package/CHANGELOG.md +22 -0
- package/README.md +10 -0
- package/docs/shutdown-and-session-locks.md +183 -0
- package/package.json +3 -2
- package/src/Client/Cmdr/DataStoreCmdrServiceClient.lua +48 -0
- package/src/Client/DataStoreServiceClient.lua +35 -0
- package/src/Server/Cmdr/DataStoreCmdrService.lua +454 -0
- package/src/Server/Cmdr/DataStoreCmdrService.spec.lua +370 -0
- package/src/Server/DataStore.GracefulClose.spec.lua +12 -10
- package/src/Server/DataStore.lua +21 -0
- package/src/Server/DataStoreLockHelper.lua +8 -86
- package/src/Server/DataStoreService.lua +40 -0
- package/src/Server/DataStoreTestUtils.lua +79 -3
- package/src/Server/Modules/DataStoreLockUtils.lua +139 -0
- package/src/Server/PlayerDataStoreHandle.lua +103 -0
- package/src/Server/PlayerDataStoreManager.Handles.spec.lua +136 -0
- package/src/Server/PlayerDataStoreManager.RemovalCallbacks.spec.lua +5 -0
- package/src/Server/PlayerDataStoreManager.SessionLockTools.spec.lua +228 -0
- package/src/Server/PlayerDataStoreManager.lua +306 -34
- package/src/Server/PlayerDataStoreManager.spec.lua +155 -44
- package/src/Server/PlayerDataStoreService.lua +112 -0
- package/src/Server/PlayerDataStoreService.spec.lua +85 -15
- package/src/Shared/Cmdr/DataStoreCmdrUtils.lua +57 -0
|
@@ -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.
|
|
108
|
-
|
|
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
|
|