@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 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.51.1",
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.0",
33
- "@quenty/bindtocloseservice": "8.41.0",
34
- "@quenty/loader": "10.11.0",
35
- "@quenty/maid": "3.11.0",
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.1",
38
- "@quenty/nevermore-test-runner": "1.5.0",
39
- "@quenty/pagesutils": "5.24.0",
40
- "@quenty/playermock": "1.6.0",
41
- "@quenty/promise": "10.23.0",
42
- "@quenty/promisemaid": "5.23.0",
43
- "@quenty/rx": "13.33.0",
44
- "@quenty/servicebag": "11.20.0",
45
- "@quenty/signal": "7.13.1",
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.1",
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": "03307dd337fa003fce541cac45106b0df6fbf7d9"
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