@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.
- package/CHANGELOG.md +19 -0
- package/package.json +17 -16
- 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.lua +21 -0
- package/src/Server/DataStoreLockHelper.lua +8 -86
- package/src/Server/DataStoreService.lua +40 -0
- 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.SessionLockTools.spec.lua +228 -0
- package/src/Server/PlayerDataStoreManager.lua +282 -0
- package/src/Server/PlayerDataStoreManager.spec.lua +86 -0
- package/src/Server/PlayerDataStoreService.lua +112 -0
- package/src/Server/PlayerDataStoreService.spec.lua +65 -0
- package/src/Shared/Cmdr/DataStoreCmdrUtils.lua +57 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
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.52.1](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.52.0...@quenty/datastore@13.52.1) (2026-08-14)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @quenty/datastore
|
|
9
|
+
|
|
10
|
+
# [13.52.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.51.1...@quenty/datastore@13.52.0) (2026-08-13)
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
- **datastore:** break the cmdr/service require cycle ([b7ece57](https://github.com/Quenty/NevermoreEngine/commit/b7ece577b509e2a394b864e4a538070fbe94ffc9))
|
|
15
|
+
- **datastore:** release a borrowed session even when the caller is torn down ([2537a0b](https://github.com/Quenty/NevermoreEngine/commit/2537a0b8e90a658cca96f83a515b5ffac8662518))
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
- **datastore:** add Cmdr commands for session locks and stored data ([adc36ac](https://github.com/Quenty/NevermoreEngine/commit/adc36ac2a25dc5aa0c481b6746b78fec331951e4))
|
|
20
|
+
- **datastore:** read and write a key's session lock without opening a session ([21da4a6](https://github.com/Quenty/NevermoreEngine/commit/21da4a6b9fa4ce7752a41a386385033a785becef))
|
|
21
|
+
- **datastore:** release borrowed sessions through a counted handle ([8c3d2f8](https://github.com/Quenty/NevermoreEngine/commit/8c3d2f8f32595be89f6297e4a223d858c3634822))
|
|
22
|
+
- Fix a lot of cmdr issues ([05b6025](https://github.com/Quenty/NevermoreEngine/commit/05b60255c6f436872895e8d9398fed12dd3ab1d5))
|
|
23
|
+
- **saveslot:** make every save slot command work on players who are not in this server ([795549c](https://github.com/Quenty/NevermoreEngine/commit/795549cdcb4c8584a4acd22eb7377c7aea149182))
|
|
24
|
+
|
|
6
25
|
## [13.51.1](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.51.0...@quenty/datastore@13.51.1) (2026-07-30)
|
|
7
26
|
|
|
8
27
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quenty/datastore",
|
|
3
|
-
"version": "13.
|
|
3
|
+
"version": "13.52.1",
|
|
4
4
|
"description": "Quenty's Datastore implementation for Roblox",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Roblox",
|
|
@@ -29,27 +29,28 @@
|
|
|
29
29
|
"Quenty"
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@quenty/baseobject": "10.15.
|
|
33
|
-
"@quenty/bindtocloseservice": "8.41.
|
|
34
|
-
"@quenty/
|
|
35
|
-
"@quenty/
|
|
32
|
+
"@quenty/baseobject": "10.15.1",
|
|
33
|
+
"@quenty/bindtocloseservice": "8.41.1",
|
|
34
|
+
"@quenty/cmdrservice": "13.57.1",
|
|
35
|
+
"@quenty/loader": "10.11.1",
|
|
36
|
+
"@quenty/maid": "3.11.1",
|
|
36
37
|
"@quenty/math": "2.7.5",
|
|
37
|
-
"@quenty/messagingserviceutils": "7.27.
|
|
38
|
-
"@quenty/nevermore-test-runner": "1.5.
|
|
39
|
-
"@quenty/pagesutils": "5.24.
|
|
40
|
-
"@quenty/playermock": "1.6.
|
|
41
|
-
"@quenty/promise": "10.23.
|
|
42
|
-
"@quenty/promisemaid": "5.23.
|
|
43
|
-
"@quenty/rx": "13.33.
|
|
44
|
-
"@quenty/servicebag": "11.20.
|
|
45
|
-
"@quenty/signal": "7.13.
|
|
38
|
+
"@quenty/messagingserviceutils": "7.27.2",
|
|
39
|
+
"@quenty/nevermore-test-runner": "1.5.1",
|
|
40
|
+
"@quenty/pagesutils": "5.24.1",
|
|
41
|
+
"@quenty/playermock": "1.6.1",
|
|
42
|
+
"@quenty/promise": "10.23.1",
|
|
43
|
+
"@quenty/promisemaid": "5.23.1",
|
|
44
|
+
"@quenty/rx": "13.33.1",
|
|
45
|
+
"@quenty/servicebag": "11.20.1",
|
|
46
|
+
"@quenty/signal": "7.13.2",
|
|
46
47
|
"@quenty/symbol": "3.5.2",
|
|
47
48
|
"@quenty/table": "3.9.2",
|
|
48
|
-
"@quenty/valueobject": "13.36.
|
|
49
|
+
"@quenty/valueobject": "13.36.2",
|
|
49
50
|
"@quentystudios/jest-lua": "3.10.0-quenty.2"
|
|
50
51
|
},
|
|
51
52
|
"publishConfig": {
|
|
52
53
|
"access": "public"
|
|
53
54
|
},
|
|
54
|
-
"gitHead": "
|
|
55
|
+
"gitHead": "f084360c3729ea424d49d9bc42f8f0d1c128fa82"
|
|
55
56
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[=[
|
|
3
|
+
Client half of the datastore Cmdr commands. Cmdr parses arguments on the executor, so the types
|
|
4
|
+
[DataStoreCmdrService]'s commands take have to be registered here too.
|
|
5
|
+
|
|
6
|
+
@client
|
|
7
|
+
@class DataStoreCmdrServiceClient
|
|
8
|
+
]=]
|
|
9
|
+
|
|
10
|
+
local require = require(script.Parent.loader).load(script)
|
|
11
|
+
|
|
12
|
+
local CmdrServiceClient = require("CmdrServiceClient")
|
|
13
|
+
local DataStoreCmdrUtils = require("DataStoreCmdrUtils")
|
|
14
|
+
local Maid = require("Maid")
|
|
15
|
+
local ServiceBag = require("ServiceBag")
|
|
16
|
+
|
|
17
|
+
local DataStoreCmdrServiceClient = {}
|
|
18
|
+
DataStoreCmdrServiceClient.ServiceName = "DataStoreCmdrServiceClient"
|
|
19
|
+
|
|
20
|
+
export type DataStoreCmdrServiceClient = typeof(setmetatable(
|
|
21
|
+
{} :: {
|
|
22
|
+
_serviceBag: ServiceBag.ServiceBag,
|
|
23
|
+
_maid: Maid.Maid,
|
|
24
|
+
_cmdrServiceClient: any,
|
|
25
|
+
},
|
|
26
|
+
{} :: typeof({ __index = DataStoreCmdrServiceClient })
|
|
27
|
+
))
|
|
28
|
+
|
|
29
|
+
function DataStoreCmdrServiceClient.Init(self: DataStoreCmdrServiceClient, serviceBag: ServiceBag.ServiceBag): ()
|
|
30
|
+
assert(not (self :: any)._serviceBag, "Already initialized")
|
|
31
|
+
self._serviceBag = assert(serviceBag, "No serviceBag")
|
|
32
|
+
self._maid = Maid.new()
|
|
33
|
+
|
|
34
|
+
-- External
|
|
35
|
+
self._cmdrServiceClient = self._serviceBag:GetService(CmdrServiceClient)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
function DataStoreCmdrServiceClient.Start(self: DataStoreCmdrServiceClient): ()
|
|
39
|
+
self._maid:GivePromise(self._cmdrServiceClient:PromiseCmdr()):Then(function(cmdr)
|
|
40
|
+
DataStoreCmdrUtils.registerSubStoreType(cmdr)
|
|
41
|
+
end)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
function DataStoreCmdrServiceClient.Destroy(self: DataStoreCmdrServiceClient): ()
|
|
45
|
+
self._maid:DoCleaning()
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
return DataStoreCmdrServiceClient
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[=[
|
|
3
|
+
Entry point for the client half of the datastore package. Registers every client-side datastore
|
|
4
|
+
service, so a consumer gets the package in one [ServiceBag.GetService].
|
|
5
|
+
|
|
6
|
+
@client
|
|
7
|
+
@class DataStoreServiceClient
|
|
8
|
+
]=]
|
|
9
|
+
|
|
10
|
+
local require = require(script.Parent.loader).load(script)
|
|
11
|
+
|
|
12
|
+
local ServiceBag = require("ServiceBag")
|
|
13
|
+
|
|
14
|
+
local DataStoreServiceClient = {}
|
|
15
|
+
DataStoreServiceClient.ServiceName = "DataStoreServiceClient"
|
|
16
|
+
|
|
17
|
+
export type DataStoreServiceClient = typeof(setmetatable(
|
|
18
|
+
{} :: {
|
|
19
|
+
_serviceBag: ServiceBag.ServiceBag,
|
|
20
|
+
},
|
|
21
|
+
{} :: typeof({ __index = DataStoreServiceClient })
|
|
22
|
+
))
|
|
23
|
+
|
|
24
|
+
function DataStoreServiceClient.Init(self: DataStoreServiceClient, serviceBag: ServiceBag.ServiceBag)
|
|
25
|
+
assert(not (self :: any)._serviceBag, "Already initialized")
|
|
26
|
+
self._serviceBag = assert(serviceBag, "No serviceBag")
|
|
27
|
+
|
|
28
|
+
-- External
|
|
29
|
+
self._serviceBag:GetService(require("CmdrServiceClient"))
|
|
30
|
+
|
|
31
|
+
-- Internal
|
|
32
|
+
self._serviceBag:GetService(require("DataStoreCmdrServiceClient"))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
return DataStoreServiceClient
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[=[
|
|
3
|
+
Cmdr commands for inspecting and stress-testing player datastores, for customer service and
|
|
4
|
+
debugging.
|
|
5
|
+
|
|
6
|
+
Targets come in as `playerIds`, so the same command reaches a player in this server (`.`, `*`, a
|
|
7
|
+
name) and one who is not (a name Cmdr resolves through `GetUserIdFromNameAsync`, or `#userId` for
|
|
8
|
+
an account whose name is unknown or since changed).
|
|
9
|
+
|
|
10
|
+
There are two families here, and they reach the key differently.
|
|
11
|
+
|
|
12
|
+
The lock commands (`datastore-lock-info`, `datastore-lock`, `datastore-unlock`) write the key
|
|
13
|
+
directly without opening a session, so they act on a lock left behind by a server that died --
|
|
14
|
+
the usual reason to reach for them.
|
|
15
|
+
|
|
16
|
+
The data commands (`datastore-read-json`, `datastore-write-json`, `datastore-delete`,
|
|
17
|
+
`datastore-copy`) go through a real [DataStore], which means they **steal the session** from
|
|
18
|
+
whichever server holds it, including this one. That is deliberate: these exist to stress-test the
|
|
19
|
+
session-locking system, and a read that cannot be starved of a current write is exactly what is
|
|
20
|
+
being tested. A player whose session is stolen mid-play is disrupted -- they are kicked when their
|
|
21
|
+
server notices -- so treat these as debug tooling rather than customer-service tooling.
|
|
22
|
+
|
|
23
|
+
:::warning
|
|
24
|
+
The session lock is soft. A loading session steals it once its retry ladder is exhausted, so
|
|
25
|
+
`datastore-lock` parks a key for that long and no longer. Unlocking is the durable half.
|
|
26
|
+
:::
|
|
27
|
+
|
|
28
|
+
:::info
|
|
29
|
+
TODO: give the read paths a read-only [DataStore] so they stop stealing. The load path already
|
|
30
|
+
uses a plain `GetAsync` and takes no lock, so the store itself is a thin flag -- but a read that
|
|
31
|
+
is guaranteed to see the *current* write needs MessagingService to ask the holding session to
|
|
32
|
+
flush first, plus the edge cases around a session that never answers. Deferred rather than
|
|
33
|
+
half-built.
|
|
34
|
+
:::
|
|
35
|
+
|
|
36
|
+
@server
|
|
37
|
+
@class DataStoreCmdrService
|
|
38
|
+
]=]
|
|
39
|
+
|
|
40
|
+
local require = require(script.Parent.loader).load(script)
|
|
41
|
+
|
|
42
|
+
local HttpService = game:GetService("HttpService")
|
|
43
|
+
local Players = game:GetService("Players")
|
|
44
|
+
|
|
45
|
+
local CmdrReplyUtils = require("CmdrReplyUtils")
|
|
46
|
+
local CmdrService = require("CmdrService")
|
|
47
|
+
local CmdrTypes = require("CmdrTypes")
|
|
48
|
+
local DataStoreCmdrUtils = require("DataStoreCmdrUtils")
|
|
49
|
+
local DataStoreLockUtils = require("DataStoreLockUtils")
|
|
50
|
+
local DataStoreStage = require("DataStoreStage")
|
|
51
|
+
local Maid = require("Maid")
|
|
52
|
+
local PlayerDataStoreManager = require("PlayerDataStoreManager")
|
|
53
|
+
local Promise = require("Promise")
|
|
54
|
+
local ServiceBag = require("ServiceBag")
|
|
55
|
+
|
|
56
|
+
local DataStoreCmdrService = {}
|
|
57
|
+
DataStoreCmdrService.ServiceName = "DataStoreCmdrService"
|
|
58
|
+
|
|
59
|
+
export type DataStoreCmdrService = typeof(setmetatable(
|
|
60
|
+
{} :: {
|
|
61
|
+
_serviceBag: ServiceBag.ServiceBag,
|
|
62
|
+
_maid: Maid.Maid,
|
|
63
|
+
_cmdrService: any,
|
|
64
|
+
_playerDataStoreService: any,
|
|
65
|
+
_replyConfig: CmdrReplyUtils.CmdrReplyConfig,
|
|
66
|
+
},
|
|
67
|
+
{} :: typeof({ __index = DataStoreCmdrService })
|
|
68
|
+
))
|
|
69
|
+
|
|
70
|
+
type Manager = PlayerDataStoreManager.PlayerDataStoreManager
|
|
71
|
+
type UserIdHandler = (Manager, number) -> Promise.Promise<string>
|
|
72
|
+
type CommandContext = CmdrTypes.CommandContext
|
|
73
|
+
|
|
74
|
+
--[[
|
|
75
|
+
Walks a sub-store path from a store, returning the store itself for an empty path.
|
|
76
|
+
]]
|
|
77
|
+
local function resolveSubStore(
|
|
78
|
+
dataStoreStage: DataStoreStage.DataStoreStage,
|
|
79
|
+
path: { string }
|
|
80
|
+
): DataStoreStage.DataStoreStage
|
|
81
|
+
local current = dataStoreStage
|
|
82
|
+
for _, segment in path do
|
|
83
|
+
current = current:GetSubStore(segment)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
return current
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
--[=[
|
|
90
|
+
Initializes the service. Should be done via [ServiceBag.Init].
|
|
91
|
+
@param serviceBag ServiceBag
|
|
92
|
+
]=]
|
|
93
|
+
function DataStoreCmdrService.Init(self: DataStoreCmdrService, serviceBag: ServiceBag.ServiceBag): ()
|
|
94
|
+
assert(not (self :: any)._serviceBag, "Already initialized")
|
|
95
|
+
self._serviceBag = assert(serviceBag, "No serviceBag")
|
|
96
|
+
self._maid = Maid.new()
|
|
97
|
+
self._replyConfig = CmdrReplyUtils.createConfig()
|
|
98
|
+
|
|
99
|
+
-- External
|
|
100
|
+
self._cmdrService = self._serviceBag:GetService(CmdrService)
|
|
101
|
+
|
|
102
|
+
-- Reached through the module instance rather than by name, because
|
|
103
|
+
-- `require("PlayerDataStoreService")` from here is a cyclic module dependency:
|
|
104
|
+
-- PlayerDataStoreService registers this service. Mirrors SaveSlotCmdrService._getSaveSlotService.
|
|
105
|
+
local serviceModule =
|
|
106
|
+
assert(script.Parent.Parent:FindFirstChild("PlayerDataStoreService"), "No PlayerDataStoreService")
|
|
107
|
+
self._playerDataStoreService = self._serviceBag:GetService(serviceModule)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
--[=[
|
|
111
|
+
Sets how long a command may run before it tells the executor it is still working, and how that
|
|
112
|
+
line is colored.
|
|
113
|
+
|
|
114
|
+
@param replyConfig CmdrReplyConfig -- see [CmdrReplyUtils.createConfig]
|
|
115
|
+
]=]
|
|
116
|
+
function DataStoreCmdrService.SetReplyConfig(
|
|
117
|
+
self: DataStoreCmdrService,
|
|
118
|
+
replyConfig: CmdrReplyUtils.CmdrReplyConfig
|
|
119
|
+
): ()
|
|
120
|
+
assert(CmdrReplyUtils.isCmdrReplyConfig(replyConfig), "Bad replyConfig")
|
|
121
|
+
|
|
122
|
+
self._replyConfig = replyConfig
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
--[=[
|
|
126
|
+
Registers the commands. Should be done via [ServiceBag.Start].
|
|
127
|
+
]=]
|
|
128
|
+
function DataStoreCmdrService.Start(self: DataStoreCmdrService): ()
|
|
129
|
+
self._maid:GivePromise(self._cmdrService:PromiseCmdr()):Then(function(cmdr)
|
|
130
|
+
DataStoreCmdrUtils.registerSubStoreType(cmdr)
|
|
131
|
+
|
|
132
|
+
self:_registerCommands()
|
|
133
|
+
end)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
function DataStoreCmdrService._registerCommands(self: DataStoreCmdrService): ()
|
|
137
|
+
local playersArg = {
|
|
138
|
+
Name = "Players",
|
|
139
|
+
Type = "playerIds",
|
|
140
|
+
Description = "Players to act on (e.g. . for yourself, * for everyone here, a username, or #userId).",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
local subStoreArg = {
|
|
144
|
+
Name = "SubStore",
|
|
145
|
+
Type = "dataStoreSubStore",
|
|
146
|
+
Description = "Sub-store path to scope to, slash-delimited (e.g. SaveSlotSystem/Metadata). Omit for the whole key.",
|
|
147
|
+
Optional = true,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
self._cmdrService:RegisterCommand({
|
|
151
|
+
Name = "datastore-lock-info",
|
|
152
|
+
Description = "Reads the session lock on each player's datastore key. Works remotely and on players in-game.",
|
|
153
|
+
Group = "DataStore",
|
|
154
|
+
Args = { playersArg },
|
|
155
|
+
}, function(context: CommandContext, userIds: { number })
|
|
156
|
+
return self:_executeForUserIds(context, userIds, function(manager, userId)
|
|
157
|
+
return manager:PromiseReadSessionLock(userId):Then(function(lockData)
|
|
158
|
+
return `{userId}: {DataStoreLockUtils.toHumanReadable(lockData)}`
|
|
159
|
+
end)
|
|
160
|
+
end)
|
|
161
|
+
end)
|
|
162
|
+
|
|
163
|
+
self._cmdrService:RegisterCommand({
|
|
164
|
+
Name = "datastore-unlock",
|
|
165
|
+
Description = "Clears the session lock on each player's datastore key, releasing a claim left behind by a dead server. Works remotely and on players in-game.",
|
|
166
|
+
Group = "DataStore",
|
|
167
|
+
Args = { playersArg },
|
|
168
|
+
}, function(context: CommandContext, userIds: { number })
|
|
169
|
+
return self:_executeForUserIds(context, userIds, function(manager, userId)
|
|
170
|
+
return manager:PromiseUnlockSession(userId):Then(function(previousLock)
|
|
171
|
+
if previousLock == nil or previousLock.ActiveSession == nil then
|
|
172
|
+
return `{userId} was already unlocked. Nothing to do.`
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
return `Unlocked {userId}. Cleared: {DataStoreLockUtils.toHumanReadable(previousLock)}`
|
|
176
|
+
end)
|
|
177
|
+
end)
|
|
178
|
+
end)
|
|
179
|
+
|
|
180
|
+
self._cmdrService:RegisterCommand({
|
|
181
|
+
Name = "datastore-lock",
|
|
182
|
+
Description = "Claims each player's datastore key so an inspection is not racing a live server. Soft: a loading session steals it once its retry ladder runs out. Works remotely and on players in-game.",
|
|
183
|
+
Group = "DataStore",
|
|
184
|
+
Args = { playersArg },
|
|
185
|
+
}, function(context: CommandContext, userIds: { number })
|
|
186
|
+
return self:_executeForUserIds(context, userIds, function(manager, userId)
|
|
187
|
+
return manager:PromiseLockSession(userId):Then(function(previousLock)
|
|
188
|
+
if previousLock == nil or previousLock.ActiveSession == nil then
|
|
189
|
+
return `Locked {userId}.`
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
return `Locked {userId}, replacing: {DataStoreLockUtils.toHumanReadable(previousLock)}`
|
|
193
|
+
end)
|
|
194
|
+
end)
|
|
195
|
+
end)
|
|
196
|
+
|
|
197
|
+
self._cmdrService:RegisterCommand({
|
|
198
|
+
Name = "datastore-read-json",
|
|
199
|
+
Description = "Reads each player's datastore as JSON. Steals the session. Works remotely and on players in-game.",
|
|
200
|
+
Group = "DataStore",
|
|
201
|
+
Args = { playersArg, subStoreArg },
|
|
202
|
+
}, function(context: CommandContext, userIds: { number }, path: { string }?)
|
|
203
|
+
return self:_executeForUserIds(context, userIds, function(manager, userId)
|
|
204
|
+
return self:_promiseWithDataStore(manager, userId, false, function(dataStore)
|
|
205
|
+
return resolveSubStore(dataStore, path or {}):LoadAll({}):Then(function(data)
|
|
206
|
+
return `-- {userId} {self:_describePath(path)}\n{HttpService:JSONEncode(data)}`
|
|
207
|
+
end)
|
|
208
|
+
end)
|
|
209
|
+
end)
|
|
210
|
+
end)
|
|
211
|
+
|
|
212
|
+
self._cmdrService:RegisterCommand({
|
|
213
|
+
Name = "datastore-write-json",
|
|
214
|
+
Description = "Overwrites each player's datastore with the given JSON. Steals the session. Works remotely and on players in-game.",
|
|
215
|
+
Group = "DataStore",
|
|
216
|
+
Args = {
|
|
217
|
+
playersArg,
|
|
218
|
+
-- Ahead of the optional sub-store, since Cmdr can only leave trailing arguments off.
|
|
219
|
+
{
|
|
220
|
+
Name = "Json",
|
|
221
|
+
Type = "string",
|
|
222
|
+
Description = "JSON object to write.",
|
|
223
|
+
},
|
|
224
|
+
subStoreArg,
|
|
225
|
+
},
|
|
226
|
+
}, function(context: CommandContext, userIds: { number }, json: string, path: { string }?)
|
|
227
|
+
local ok, decoded = pcall(HttpService.JSONDecode, HttpService, json)
|
|
228
|
+
if not ok then
|
|
229
|
+
return `Failed: could not decode JSON: {tostring(decoded)}`
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
return self:_executeForUserIds(context, userIds, function(manager, userId)
|
|
233
|
+
return self:_promiseWithDataStore(manager, userId, true, function(dataStore)
|
|
234
|
+
resolveSubStore(dataStore, path or {}):Overwrite(decoded)
|
|
235
|
+
return Promise.resolved(`Wrote {userId} {self:_describePath(path)}.`)
|
|
236
|
+
end)
|
|
237
|
+
end)
|
|
238
|
+
end)
|
|
239
|
+
|
|
240
|
+
self._cmdrService:RegisterCommand({
|
|
241
|
+
Name = "datastore-delete",
|
|
242
|
+
Description = "Wipes each player's datastore, or one sub-store of it. Steals the session. Works remotely and on players in-game.",
|
|
243
|
+
Group = "DataStore",
|
|
244
|
+
Args = { playersArg, subStoreArg },
|
|
245
|
+
}, function(context: CommandContext, userIds: { number }, path: { string }?)
|
|
246
|
+
return self:_executeForUserIds(context, userIds, function(manager, userId)
|
|
247
|
+
return self:_promiseWithDataStore(manager, userId, true, function(dataStore)
|
|
248
|
+
resolveSubStore(dataStore, path or {}):Wipe()
|
|
249
|
+
return Promise.resolved(`Deleted {userId} {self:_describePath(path)}.`)
|
|
250
|
+
end)
|
|
251
|
+
end)
|
|
252
|
+
end)
|
|
253
|
+
|
|
254
|
+
self._cmdrService:RegisterCommand({
|
|
255
|
+
Name = "datastore-copy",
|
|
256
|
+
Description = "Copies one player's datastore over others'. Steals the session from every player involved. Works remotely and on players in-game.",
|
|
257
|
+
Group = "DataStore",
|
|
258
|
+
Args = {
|
|
259
|
+
{
|
|
260
|
+
Name = "FromPlayer",
|
|
261
|
+
Type = "playerId",
|
|
262
|
+
Description = "Player to copy from (e.g. . for yourself, a username, or #userId).",
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
Name = "ToPlayers",
|
|
266
|
+
Type = "playerIds",
|
|
267
|
+
Description = "Players to copy onto (e.g. . for yourself, a username, or #userId).",
|
|
268
|
+
},
|
|
269
|
+
subStoreArg,
|
|
270
|
+
},
|
|
271
|
+
}, function(context: CommandContext, fromUserId: number, toUserIds: { number }, path: { string }?)
|
|
272
|
+
local readPromise = self._playerDataStoreService:PromiseManager():Then(function(manager)
|
|
273
|
+
return self:_promiseWithDataStore(manager, fromUserId, false, function(dataStore)
|
|
274
|
+
return resolveSubStore(dataStore, path or {}):LoadAll({})
|
|
275
|
+
end)
|
|
276
|
+
end)
|
|
277
|
+
|
|
278
|
+
CmdrReplyUtils.replyWhenSlow(self._replyConfig, context, readPromise, `{fromUserId}: still reading...`)
|
|
279
|
+
|
|
280
|
+
local ok, source = self._maid:GivePromise(readPromise):Yield()
|
|
281
|
+
if not ok then
|
|
282
|
+
return `Failed to read {fromUserId}: {tostring(source)}`
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
return self:_executeForUserIds(context, toUserIds, function(manager, userId)
|
|
286
|
+
if userId == fromUserId then
|
|
287
|
+
return Promise.resolved(`{userId} is the source. Skipped.`)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
return self:_promiseWithDataStore(manager, userId, true, function(dataStore)
|
|
291
|
+
resolveSubStore(dataStore, path or {}):Overwrite(source)
|
|
292
|
+
return Promise.resolved(`Copied {fromUserId} {self:_describePath(path)} onto {userId}.`)
|
|
293
|
+
end)
|
|
294
|
+
end)
|
|
295
|
+
end)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
--[=[
|
|
299
|
+
Opens the store for `userId`, runs `handler` against it, and puts it back.
|
|
300
|
+
|
|
301
|
+
[PlayerDataStoreManager.PromiseDataStore] opens a real session, which takes the lock from whoever
|
|
302
|
+
holds it -- kicking that player when their server notices the theft. Releasing what was taken is
|
|
303
|
+
therefore the part that matters: an orphaned lock is exactly what `datastore-unlock` exists to
|
|
304
|
+
clear, and until it is dropped the player cannot rejoin.
|
|
305
|
+
|
|
306
|
+
So a store opened for an absent player is closed again, and the promise waits for that flush to
|
|
307
|
+
land rather than reporting success while the lock is still held. A store belonging to a player in
|
|
308
|
+
this server is left alone instead: no session was stolen, and removing it would strand a live one.
|
|
309
|
+
|
|
310
|
+
@param manager PlayerDataStoreManager
|
|
311
|
+
@param userId number
|
|
312
|
+
@param doesWrite boolean -- whether to flush before handing a live store back
|
|
313
|
+
@param handler (DataStore) -> Promise<T>
|
|
314
|
+
@return Promise<T>
|
|
315
|
+
]=]
|
|
316
|
+
function DataStoreCmdrService._promiseWithDataStore(
|
|
317
|
+
self: DataStoreCmdrService,
|
|
318
|
+
manager: Manager,
|
|
319
|
+
userId: number,
|
|
320
|
+
doesWrite: boolean,
|
|
321
|
+
handler: (any) -> Promise.Promise<any>
|
|
322
|
+
): Promise.Promise<any>
|
|
323
|
+
local isInThisServer = Players:GetPlayerByUserId(userId) ~= nil
|
|
324
|
+
|
|
325
|
+
-- Deliberately not `_maid:GivePromise`, which cancels nothing upstream: on teardown the handle
|
|
326
|
+
-- still resolves, into a continuation the maid has already skipped, and the session it took stays
|
|
327
|
+
-- locked for the rest of the server's life. The maid is probed for liveness instead, and then
|
|
328
|
+
-- handed the handle itself.
|
|
329
|
+
local probe = {}
|
|
330
|
+
self._maid[probe] = probe
|
|
331
|
+
|
|
332
|
+
return manager:PromiseDataStoreHandle(userId):Then(function(handle)
|
|
333
|
+
local isAlive = self._maid[probe] == probe
|
|
334
|
+
self._maid[probe] = nil
|
|
335
|
+
|
|
336
|
+
if not isAlive then
|
|
337
|
+
handle:Destroy()
|
|
338
|
+
return Promise.rejected(`Destroyed while opening the datastore for {userId}`)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
local handleId = self._maid:GiveTask(handle :: any)
|
|
342
|
+
|
|
343
|
+
local function release(): Promise.Promise<()>
|
|
344
|
+
-- A write only stages, so a command can reach here with the opening load still in flight.
|
|
345
|
+
-- Releasing then destroys the store out from under it, and the UpdateAsync Roblox has
|
|
346
|
+
-- already dispatched calls back into the wreckage. Let the load settle first. Resolves
|
|
347
|
+
-- either way -- a load that failed is still a load that is no longer running.
|
|
348
|
+
return handle:GetDataStore():PromiseLoadSuccessful():Then(function()
|
|
349
|
+
-- Destroying the handle closes a borrowed session and flushes it. A live player's store
|
|
350
|
+
-- is not the handle's to close, so that one is saved explicitly instead.
|
|
351
|
+
local savePromise: Promise.Promise<()> = Promise.resolved()
|
|
352
|
+
if isInThisServer and doesWrite then
|
|
353
|
+
savePromise = handle:GetDataStore():Save()
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
return savePromise:Finally(function()
|
|
357
|
+
self._maid[handleId] = nil
|
|
358
|
+
|
|
359
|
+
-- Destroying the handle only *starts* the save-and-close, so wait for it here: the
|
|
360
|
+
-- command reports success once the lock it took is gone, not while it is still held.
|
|
361
|
+
return manager:PromiseSessionClosed(userId)
|
|
362
|
+
end)
|
|
363
|
+
end)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
return handler(handle:GetDataStore()):Then(function(result)
|
|
367
|
+
return release():Then(function()
|
|
368
|
+
return result
|
|
369
|
+
end)
|
|
370
|
+
end, function(err)
|
|
371
|
+
return release():Then(function()
|
|
372
|
+
return Promise.rejected(err)
|
|
373
|
+
end)
|
|
374
|
+
end)
|
|
375
|
+
end, function(err)
|
|
376
|
+
-- The open failed, so there is no handle to hand to the maid. Take the probe back out.
|
|
377
|
+
self._maid[probe] = nil
|
|
378
|
+
return Promise.rejected(err)
|
|
379
|
+
end)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
--[=[
|
|
383
|
+
Runs `handler` once per target userId and renders the results back into the console.
|
|
384
|
+
|
|
385
|
+
Runs sequentially, to keep a batch from firing concurrent writes at the datastore. A target that
|
|
386
|
+
fails reports on its own line rather than throwing into Cmdr or costing the rest of the batch its
|
|
387
|
+
result.
|
|
388
|
+
|
|
389
|
+
Nothing reaches the console until every target is done, and a single target can sit in a load
|
|
390
|
+
retry ladder for a long time, so a target still going after the reply config's
|
|
391
|
+
`slowReplySeconds` says so.
|
|
392
|
+
|
|
393
|
+
@param context CommandContext
|
|
394
|
+
@param userIds { number }
|
|
395
|
+
@param handler (PlayerDataStoreManager, number) -> Promise<string>
|
|
396
|
+
@return string
|
|
397
|
+
]=]
|
|
398
|
+
function DataStoreCmdrService._executeForUserIds(
|
|
399
|
+
self: DataStoreCmdrService,
|
|
400
|
+
context: CommandContext,
|
|
401
|
+
userIds: { number },
|
|
402
|
+
handler: UserIdHandler
|
|
403
|
+
): string
|
|
404
|
+
if #userIds == 0 then
|
|
405
|
+
return "No players to act on."
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
local promise = self._playerDataStoreService:PromiseManager():Then(function(manager)
|
|
409
|
+
local chain = Promise.resolved()
|
|
410
|
+
local lines: { string } = {}
|
|
411
|
+
|
|
412
|
+
for _, userId in userIds do
|
|
413
|
+
chain = chain:Then(function()
|
|
414
|
+
return CmdrReplyUtils.replyWhenSlow(
|
|
415
|
+
self._replyConfig,
|
|
416
|
+
context,
|
|
417
|
+
handler(manager, userId),
|
|
418
|
+
`{userId}: still working...`
|
|
419
|
+
)
|
|
420
|
+
:Then(function(line)
|
|
421
|
+
table.insert(lines, line)
|
|
422
|
+
end)
|
|
423
|
+
:Catch(function(err)
|
|
424
|
+
table.insert(lines, `{userId}: Failed: {tostring(err)}`)
|
|
425
|
+
end)
|
|
426
|
+
end)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
return chain:Then(function()
|
|
430
|
+
return lines
|
|
431
|
+
end)
|
|
432
|
+
end)
|
|
433
|
+
|
|
434
|
+
local ok, result = self._maid:GivePromise(promise):Yield()
|
|
435
|
+
if not ok then
|
|
436
|
+
return `Failed: {tostring(result)}`
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
return table.concat(result, "\n")
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
function DataStoreCmdrService._describePath(_self: DataStoreCmdrService, path: { string }?): string
|
|
443
|
+
if path == nil or #path == 0 then
|
|
444
|
+
return "(whole key)"
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
return table.concat(path, "/")
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
function DataStoreCmdrService.Destroy(self: DataStoreCmdrService): ()
|
|
451
|
+
self._maid:DoCleaning()
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
return DataStoreCmdrService
|