@el4cteo/rbx-studio-mcp 0.6.1 → 0.6.7
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/README.md +28 -2
- package/dist/bridge/console.js +182 -0
- package/dist/bridge/console.js.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/cloudassets.js +233 -0
- package/dist/lib/cloudassets.js.map +1 -0
- package/dist/lib/credentials.js +180 -0
- package/dist/lib/credentials.js.map +1 -0
- package/dist/lib/livedata.js +325 -0
- package/dist/lib/livedata.js.map +1 -0
- package/dist/lib/liveluau.js +83 -0
- package/dist/lib/liveluau.js.map +1 -0
- package/dist/lib/liveops.js +358 -0
- package/dist/lib/liveops.js.map +1 -0
- package/dist/lib/opencloud.js +235 -0
- package/dist/lib/opencloud.js.map +1 -0
- package/dist/tools/anim.js +159 -0
- package/dist/tools/anim.js.map +1 -0
- package/dist/tools/audio.js +96 -0
- package/dist/tools/audio.js.map +1 -0
- package/dist/tools/character.js +95 -5
- package/dist/tools/character.js.map +1 -1
- package/dist/tools/data.js +292 -0
- package/dist/tools/data.js.map +1 -0
- package/dist/tools/device.js +77 -7
- package/dist/tools/device.js.map +1 -1
- package/dist/tools/discover.js +80 -4
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +96 -2
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/input.js +35 -9
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/perf.js +74 -7
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/scripts.js +162 -6
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/spatial.js +135 -0
- package/dist/tools/spatial.js.map +1 -0
- package/dist/tools/universe.js +177 -0
- package/dist/tools/universe.js.map +1 -0
- package/dist/tools/upload.js +294 -0
- package/dist/tools/upload.js.map +1 -0
- package/dist/tools/world.js +675 -51
- package/dist/tools/world.js.map +1 -1
- package/package.json +2 -2
- package/plugin/src/Commands.luau +31 -7
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/Console.luau +1909 -1843
- package/plugin/src/Emulation.luau +172 -0
- package/plugin/src/Phrase.luau +816 -618
- package/plugin/src/Png.luau +8 -4
- package/plugin/src/Prompt.luau +965 -961
- package/plugin/src/Secret.luau +86 -0
- package/plugin/src/Serialize.luau +440 -8
- package/plugin/src/Undo.luau +94 -6
- package/plugin/src/handlers/Anim.luau +897 -0
- package/plugin/src/handlers/Assets.luau +286 -2
- package/plugin/src/handlers/Audio.luau +411 -0
- package/plugin/src/handlers/Capture.luau +155 -20
- package/plugin/src/handlers/Character.luau +823 -361
- package/plugin/src/handlers/Data.luau +539 -0
- package/plugin/src/handlers/Device.luau +394 -139
- package/plugin/src/handlers/Discover.luau +685 -363
- package/plugin/src/handlers/Geometry.luau +722 -450
- package/plugin/src/handlers/Instances.luau +84 -4
- package/plugin/src/handlers/Perf.luau +227 -0
- package/plugin/src/handlers/Scripts.luau +673 -539
- package/plugin/src/handlers/Session.luau +3 -0
- package/plugin/src/handlers/Spatial.luau +334 -0
- package/plugin/src/handlers/Viewport.luau +268 -0
- package/plugin/src/handlers/World.luau +89 -15
- package/plugin/src/init.server.luau +9 -1
- package/scripts/build-plugin.mjs +20 -0
- package/scripts/check-plugin.mjs +171 -124
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Saved data: DataStore and MemoryStore.
|
|
4
|
+
|
|
5
|
+
Everything else this server exposes reads the place -- instances, properties,
|
|
6
|
+
source. None of it can answer the most common bug report a Roblox game gets,
|
|
7
|
+
which is some version of "my stuff was gone when I rejoined". That question is
|
|
8
|
+
not about the place at all. It is about a row in a data store, and until now
|
|
9
|
+
the only way to look at one was to write a script, run it, and read the
|
|
10
|
+
output.
|
|
11
|
+
|
|
12
|
+
Two backends live here because they are the same shape to a caller and
|
|
13
|
+
nothing else about them is alike:
|
|
14
|
+
|
|
15
|
+
* `DataStoreService` is permanent, per-player, and the thing people lose
|
|
16
|
+
money and progress in. It keeps version history, which is what makes this
|
|
17
|
+
tool safe to point at a live game: you can read what a key held an hour ago
|
|
18
|
+
without touching what it holds now.
|
|
19
|
+
* `MemoryStoreService` is a shared scratchpad that expires -- matchmaking
|
|
20
|
+
queues, session locks, live leaderboards. Measured: it works from Studio
|
|
21
|
+
with no setup at all, unlike DataStore.
|
|
22
|
+
|
|
23
|
+
The gate is worth stating up front, because it is the first thing anyone
|
|
24
|
+
hits. DataStore refuses every call from Studio until "Enable Studio Access to
|
|
25
|
+
API Services" is ticked in Game Settings -> Security, and the refusal is a
|
|
26
|
+
raw HTTP 502 that says nothing about the checkbox. `gate` below turns it into
|
|
27
|
+
the sentence the user needs. MemoryStore has no such gate.
|
|
28
|
+
]]
|
|
29
|
+
|
|
30
|
+
local DataStoreService = game:GetService("DataStoreService")
|
|
31
|
+
local HttpService = game:GetService("HttpService")
|
|
32
|
+
local MemoryStoreService = game:GetService("MemoryStoreService")
|
|
33
|
+
|
|
34
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
35
|
+
|
|
36
|
+
local Data = {}
|
|
37
|
+
|
|
38
|
+
--[[
|
|
39
|
+
Rows returned in one call.
|
|
40
|
+
|
|
41
|
+
Deliberately modest. Every row is a key name plus, for a get, a whole saved
|
|
42
|
+
value -- and a player's save can be tens of kilobytes of JSON. A page that
|
|
43
|
+
filled the agent's context with someone's inventory would make the next
|
|
44
|
+
question unanswerable, so paging is the default and the cursor is honest.
|
|
45
|
+
]]
|
|
46
|
+
local DEFAULT_LIMIT = 50
|
|
47
|
+
local MAX_LIMIT = 200
|
|
48
|
+
|
|
49
|
+
--[[
|
|
50
|
+
Seconds a MemoryStore write survives without an explicit expiry.
|
|
51
|
+
|
|
52
|
+
An hour. These are session-lifetime values by design, and a tool that wrote
|
|
53
|
+
them with the 45-day maximum would leave test data outliving the test by a
|
|
54
|
+
month and a half.
|
|
55
|
+
]]
|
|
56
|
+
local DEFAULT_TTL = 3600
|
|
57
|
+
|
|
58
|
+
--[[
|
|
59
|
+
Turns the API-services refusal into the one sentence that fixes it.
|
|
60
|
+
|
|
61
|
+
Every DataStore call fails identically while the setting is off, with a 502
|
|
62
|
+
and "Studio access to APIs is not allowed" buried in it. That text names
|
|
63
|
+
neither the setting nor where it lives, and an agent reading a 502 will
|
|
64
|
+
reasonably conclude the service is down and stop -- which is the wrong
|
|
65
|
+
conclusion about a checkbox.
|
|
66
|
+
|
|
67
|
+
Anything else is re-raised as it came: a budget exhaustion, a bad key name
|
|
68
|
+
and a network failure all say useful things already.
|
|
69
|
+
]]
|
|
70
|
+
local function gate(err: any, what: string): never
|
|
71
|
+
local reason = tostring(err)
|
|
72
|
+
if
|
|
73
|
+
string.find(reason, "not allowed", 1, true)
|
|
74
|
+
or string.find(reason, "403", 1, true)
|
|
75
|
+
or string.find(reason, "Error code: 7", 1, true)
|
|
76
|
+
then
|
|
77
|
+
Dispatch.fail(
|
|
78
|
+
"API_ACCESS_DISABLED",
|
|
79
|
+
"This place does not allow Studio to reach data stores yet.",
|
|
80
|
+
"In Studio: File -> Game Settings -> Security -> turn on \"Enable Studio "
|
|
81
|
+
.. "Access to API Services\", then try again. The place must also be "
|
|
82
|
+
.. "published. MemoryStore (kind=\"memory\") needs none of this."
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
Dispatch.fail("DATASTORE_FAILED", string.format("%s failed: %s", what, reason))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
local function limitOf(params: { [string]: any }): number
|
|
89
|
+
return math.clamp(tonumber(params.limit) or DEFAULT_LIMIT, 1, MAX_LIMIT)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
--[[
|
|
93
|
+
Which store the call is about.
|
|
94
|
+
|
|
95
|
+
`scope` is passed through rather than defaulted away: a game that used scopes
|
|
96
|
+
has its data under them, and a tool that could only see the global scope
|
|
97
|
+
would report an empty store and be believed.
|
|
98
|
+
]]
|
|
99
|
+
local function storeOf(params: { [string]: any }): any
|
|
100
|
+
local name = params.store
|
|
101
|
+
if typeof(name) ~= "string" or name == "" then
|
|
102
|
+
Dispatch.fail("BAD_PARAMS", "this needs a `store` name.", 'Call op="list" with no store to see them.')
|
|
103
|
+
end
|
|
104
|
+
local scope = if typeof(params.scope) == "string" and params.scope ~= "" then params.scope else nil
|
|
105
|
+
local ok, store = pcall(function()
|
|
106
|
+
if scope then
|
|
107
|
+
return DataStoreService:GetDataStore(name, scope)
|
|
108
|
+
end
|
|
109
|
+
return DataStoreService:GetDataStore(name)
|
|
110
|
+
end)
|
|
111
|
+
if not ok then
|
|
112
|
+
gate(store, "opening the data store")
|
|
113
|
+
end
|
|
114
|
+
return store
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
local function mapOf(params: { [string]: any }): any
|
|
118
|
+
local name = params.store
|
|
119
|
+
if typeof(name) ~= "string" or name == "" then
|
|
120
|
+
Dispatch.fail("BAD_PARAMS", 'a memory call needs a `store` name -- the sorted map\'s name.')
|
|
121
|
+
end
|
|
122
|
+
local ok, map = pcall(function()
|
|
123
|
+
return MemoryStoreService:GetSortedMap(name)
|
|
124
|
+
end)
|
|
125
|
+
if not ok then
|
|
126
|
+
Dispatch.fail("MEMORYSTORE_FAILED", string.format("Could not open sorted map %q: %s", name, tostring(map)))
|
|
127
|
+
end
|
|
128
|
+
return map
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
--[[
|
|
132
|
+
A saved value, rendered for an agent to read.
|
|
133
|
+
|
|
134
|
+
Saves are arbitrary Luau: a number, a string, or -- nearly always in practice
|
|
135
|
+
-- a nested table. JSON is the only honest way to carry that across the wire,
|
|
136
|
+
and it is also the form the caller will want to reason about.
|
|
137
|
+
|
|
138
|
+
Guarded, because not everything that can be saved can be encoded. A table
|
|
139
|
+
with both array and dictionary keys, or a NaN, throws inside JSONEncode;
|
|
140
|
+
losing the whole read over that would be worse than describing the shape, so
|
|
141
|
+
the fallback says what it was rather than pretending it was nothing.
|
|
142
|
+
]]
|
|
143
|
+
local function render(value: any): { [string]: any }
|
|
144
|
+
if value == nil then
|
|
145
|
+
return { exists = false }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
local kind = typeof(value)
|
|
149
|
+
local ok, encoded = pcall(function()
|
|
150
|
+
return HttpService:JSONEncode(value)
|
|
151
|
+
end)
|
|
152
|
+
if ok then
|
|
153
|
+
return { exists = true, type = kind, value = encoded }
|
|
154
|
+
end
|
|
155
|
+
return {
|
|
156
|
+
exists = true,
|
|
157
|
+
type = kind,
|
|
158
|
+
value = tostring(value),
|
|
159
|
+
note = "This value could not be encoded as JSON, so it is shown as text. "
|
|
160
|
+
.. "Mixed array/dictionary tables and NaN both do this.",
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
--[[
|
|
165
|
+
The inverse, for writes.
|
|
166
|
+
|
|
167
|
+
Values arrive as JSON text, which is the only shape the MCP protocol can
|
|
168
|
+
carry and also the only one a caller can read back from a `get`. A bare
|
|
169
|
+
string that is not valid JSON is taken as a string rather than refused --
|
|
170
|
+
`value="hello"` is what someone means by "write hello", and making them type
|
|
171
|
+
`"\"hello\""` would be pedantry with no upside.
|
|
172
|
+
]]
|
|
173
|
+
local function parse(raw: any): any
|
|
174
|
+
if raw == nil then
|
|
175
|
+
Dispatch.fail("BAD_PARAMS", "a write needs a `value`.", 'Pass JSON, e.g. {"coins":10} or 42 or "hello".')
|
|
176
|
+
end
|
|
177
|
+
local text = tostring(raw)
|
|
178
|
+
local ok, decoded = pcall(function()
|
|
179
|
+
return HttpService:JSONDecode(text)
|
|
180
|
+
end)
|
|
181
|
+
if ok then
|
|
182
|
+
return decoded
|
|
183
|
+
end
|
|
184
|
+
return text
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
--[[
|
|
188
|
+
How many requests are left before Roblox starts refusing.
|
|
189
|
+
|
|
190
|
+
Carried on every reply because it is the difference between "this key is
|
|
191
|
+
empty" and "this call never reached the server". The budget is per request
|
|
192
|
+
type and refills over time; an agent that sees it fall to zero knows to wait
|
|
193
|
+
rather than to conclude anything about the data.
|
|
194
|
+
]]
|
|
195
|
+
local function budget(requestType: Enum.DataStoreRequestType): number?
|
|
196
|
+
local ok, value = pcall(function()
|
|
197
|
+
return DataStoreService:GetRequestBudgetForRequestType(requestType)
|
|
198
|
+
end)
|
|
199
|
+
return if ok then tonumber(value) else nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
--[[
|
|
203
|
+
Walks pages until `limit` rows or the end, whichever comes first.
|
|
204
|
+
|
|
205
|
+
Pages are the engine's unit and they are not the caller's: one page is
|
|
206
|
+
whatever size Roblox felt like returning, and a caller asking for 50 keys
|
|
207
|
+
wants 50 keys, not "a page of maybe 20". `cursor` on the way back is the
|
|
208
|
+
engine's own, so paging resumes exactly where it stopped rather than
|
|
209
|
+
re-listing from the start.
|
|
210
|
+
]]
|
|
211
|
+
local function drain(pages: any, limit: number, read: (any) -> any): ({ any }, string?)
|
|
212
|
+
local rows: { any } = {}
|
|
213
|
+
while true do
|
|
214
|
+
local page = pages:GetCurrentPage()
|
|
215
|
+
for _, entry in page do
|
|
216
|
+
if #rows >= limit then
|
|
217
|
+
break
|
|
218
|
+
end
|
|
219
|
+
table.insert(rows, read(entry))
|
|
220
|
+
end
|
|
221
|
+
if #rows >= limit or pages.IsFinished then
|
|
222
|
+
break
|
|
223
|
+
end
|
|
224
|
+
local ok, err = pcall(function()
|
|
225
|
+
pages:AdvanceToNextPageAsync()
|
|
226
|
+
end)
|
|
227
|
+
if not ok then
|
|
228
|
+
gate(err, "listing")
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
local cursor: string? = nil
|
|
233
|
+
if not pages.IsFinished then
|
|
234
|
+
local ok, value = pcall(function()
|
|
235
|
+
return pages.Cursor
|
|
236
|
+
end)
|
|
237
|
+
if ok and typeof(value) == "string" and value ~= "" then
|
|
238
|
+
cursor = value
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
return rows, cursor
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
--[[
|
|
245
|
+
Lists what exists: stores, keys, or a range of a sorted map.
|
|
246
|
+
|
|
247
|
+
One verb rather than three because the question is one question -- "what is
|
|
248
|
+
in here" -- and the answer only depends on how much of the address is
|
|
249
|
+
already known. No store named: the stores. A store named: its keys. Memory:
|
|
250
|
+
the map's range.
|
|
251
|
+
]]
|
|
252
|
+
function Data.list(params: { [string]: any }): { [string]: any }
|
|
253
|
+
local limit = limitOf(params)
|
|
254
|
+
local prefix = if typeof(params.prefix) == "string" then params.prefix else ""
|
|
255
|
+
local cursor = if typeof(params.cursor) == "string" then params.cursor else ""
|
|
256
|
+
|
|
257
|
+
if params.kind == "memory" then
|
|
258
|
+
local map = mapOf(params)
|
|
259
|
+
local ok, entries = pcall(function()
|
|
260
|
+
return map:GetRangeAsync(Enum.SortDirection.Ascending, limit)
|
|
261
|
+
end)
|
|
262
|
+
if not ok then
|
|
263
|
+
Dispatch.fail("MEMORYSTORE_FAILED", string.format("Could not read the map: %s", tostring(entries)))
|
|
264
|
+
end
|
|
265
|
+
local rows: { { [string]: any } } = {}
|
|
266
|
+
for _, entry in entries :: { any } do
|
|
267
|
+
local row = render(entry.value)
|
|
268
|
+
row.key = tostring(entry.key)
|
|
269
|
+
table.insert(rows, row)
|
|
270
|
+
end
|
|
271
|
+
return { kind = "memory", store = params.store, items = rows, count = #rows }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
if typeof(params.store) == "string" and params.store ~= "" then
|
|
275
|
+
local store = storeOf(params)
|
|
276
|
+
local ok, pages = pcall(function()
|
|
277
|
+
return store:ListKeysAsync(prefix, 0, cursor, false)
|
|
278
|
+
end)
|
|
279
|
+
if not ok then
|
|
280
|
+
gate(pages, "listing keys")
|
|
281
|
+
end
|
|
282
|
+
local rows, next = drain(pages, limit, function(entry)
|
|
283
|
+
return { key = tostring(entry.KeyName) }
|
|
284
|
+
end)
|
|
285
|
+
return {
|
|
286
|
+
kind = "data",
|
|
287
|
+
store = params.store,
|
|
288
|
+
scope = params.scope,
|
|
289
|
+
items = rows,
|
|
290
|
+
count = #rows,
|
|
291
|
+
cursor = next,
|
|
292
|
+
budget = budget(Enum.DataStoreRequestType.GetAsync),
|
|
293
|
+
}
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
local ok, pages = pcall(function()
|
|
297
|
+
return DataStoreService:ListDataStoresAsync(prefix, 0, cursor)
|
|
298
|
+
end)
|
|
299
|
+
if not ok then
|
|
300
|
+
gate(pages, "listing data stores")
|
|
301
|
+
end
|
|
302
|
+
local rows, next = drain(pages, limit, function(entry)
|
|
303
|
+
return { store = tostring(entry.DataStoreName), created = tostring(entry.CreatedTime) }
|
|
304
|
+
end)
|
|
305
|
+
return { kind = "data", items = rows, count = #rows, cursor = next }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
--[[
|
|
309
|
+
Reads one key, optionally as it was at some point in the past.
|
|
310
|
+
|
|
311
|
+
The past is the reason this is worth having. "The save is wrong" is not
|
|
312
|
+
answerable by looking at the save -- you need what it held before, and
|
|
313
|
+
`version` and `at` are the two ways anyone actually has of naming that: a
|
|
314
|
+
version id from `op="versions"`, or a timestamp from when the player says it
|
|
315
|
+
broke.
|
|
316
|
+
]]
|
|
317
|
+
function Data.get(params: { [string]: any }): { [string]: any }
|
|
318
|
+
local key = params.key
|
|
319
|
+
if typeof(key) ~= "string" or key == "" then
|
|
320
|
+
Dispatch.fail("BAD_PARAMS", "get needs a `key`.")
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
if params.kind == "memory" then
|
|
324
|
+
local map = mapOf(params)
|
|
325
|
+
local ok, value = pcall(function()
|
|
326
|
+
return map:GetAsync(key)
|
|
327
|
+
end)
|
|
328
|
+
if not ok then
|
|
329
|
+
Dispatch.fail("MEMORYSTORE_FAILED", string.format("Could not read %q: %s", key, tostring(value)))
|
|
330
|
+
end
|
|
331
|
+
local row = render(value)
|
|
332
|
+
row.kind = "memory"
|
|
333
|
+
row.key = key
|
|
334
|
+
return row
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
local store = storeOf(params)
|
|
338
|
+
local version = if typeof(params.version) == "string" and params.version ~= "" then params.version else nil
|
|
339
|
+
local at = tonumber(params.at)
|
|
340
|
+
|
|
341
|
+
local ok, value, info = pcall(function()
|
|
342
|
+
if version ~= nil then
|
|
343
|
+
return store:GetVersionAsync(key, version)
|
|
344
|
+
end
|
|
345
|
+
if at ~= nil then
|
|
346
|
+
return store:GetVersionAtTimeAsync(key, math.floor(at))
|
|
347
|
+
end
|
|
348
|
+
return store:GetAsync(key)
|
|
349
|
+
end)
|
|
350
|
+
if not ok then
|
|
351
|
+
gate(value, "reading the key")
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
local row = render(value)
|
|
355
|
+
row.kind = "data"
|
|
356
|
+
row.key = key
|
|
357
|
+
row.store = params.store
|
|
358
|
+
row.version = version
|
|
359
|
+
--[[
|
|
360
|
+
`GetAsync` returns the value and a DataStoreKeyInfo beside it. The second
|
|
361
|
+
return is where the useful forensics live -- when it was last written, and
|
|
362
|
+
which user ids it was tagged with -- and it costs nothing to carry.
|
|
363
|
+
]]
|
|
364
|
+
if typeof(info) == "Instance" or typeof(info) == "userdata" then
|
|
365
|
+
local okInfo = pcall(function()
|
|
366
|
+
row.updated = tostring((info :: any).UpdatedTime)
|
|
367
|
+
row.created = tostring((info :: any).CreatedTime)
|
|
368
|
+
row.versionId = tostring((info :: any).Version)
|
|
369
|
+
end)
|
|
370
|
+
if not okInfo then
|
|
371
|
+
row.updated = nil
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
row.budget = budget(Enum.DataStoreRequestType.GetAsync)
|
|
375
|
+
return row
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
--[[
|
|
379
|
+
Every version of a key, newest first.
|
|
380
|
+
|
|
381
|
+
Newest first because the question is nearly always "what did this look like
|
|
382
|
+
before the thing that broke it", and counting forward from the beginning of
|
|
383
|
+
time to find that is the wrong end to start from.
|
|
384
|
+
]]
|
|
385
|
+
function Data.versions(params: { [string]: any }): { [string]: any }
|
|
386
|
+
local key = params.key
|
|
387
|
+
if typeof(key) ~= "string" or key == "" then
|
|
388
|
+
Dispatch.fail("BAD_PARAMS", "versions needs a `key`.")
|
|
389
|
+
end
|
|
390
|
+
local store = storeOf(params)
|
|
391
|
+
local limit = limitOf(params)
|
|
392
|
+
|
|
393
|
+
local ok, pages = pcall(function()
|
|
394
|
+
return store:ListVersionsAsync(key, Enum.SortDirection.Descending, 0, 0, 0)
|
|
395
|
+
end)
|
|
396
|
+
if not ok then
|
|
397
|
+
gate(pages, "listing versions")
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
local rows = drain(pages, limit, function(entry)
|
|
401
|
+
return {
|
|
402
|
+
version = tostring(entry.Version),
|
|
403
|
+
created = tostring(entry.CreatedTime),
|
|
404
|
+
deleted = entry.IsDeleted == true,
|
|
405
|
+
}
|
|
406
|
+
end)
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
kind = "data",
|
|
410
|
+
store = params.store,
|
|
411
|
+
key = key,
|
|
412
|
+
items = rows,
|
|
413
|
+
count = #rows,
|
|
414
|
+
hint = 'Read one with op="get" and the `version` from this list.',
|
|
415
|
+
}
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
--[[
|
|
419
|
+
Writes a key. Refuses to do it by accident.
|
|
420
|
+
|
|
421
|
+
`confirm` is required on DataStore writes and on nothing else in this file,
|
|
422
|
+
because this is the only operation here that cannot be undone by any means
|
|
423
|
+
available to this server. There is no recording to cancel and no Ctrl+Z; the
|
|
424
|
+
previous value survives only in version history, and only if the key had one.
|
|
425
|
+
The confirmation is the last point at which a misread key name is cheap.
|
|
426
|
+
|
|
427
|
+
MemoryStore writes are not confirmed. They expire on their own, they hold
|
|
428
|
+
session state rather than player property, and treating them with the same
|
|
429
|
+
ceremony would teach the caller to type confirm=true without reading it.
|
|
430
|
+
]]
|
|
431
|
+
function Data.set(params: { [string]: any }): { [string]: any }
|
|
432
|
+
local key = params.key
|
|
433
|
+
if typeof(key) ~= "string" or key == "" then
|
|
434
|
+
Dispatch.fail("BAD_PARAMS", "set needs a `key`.")
|
|
435
|
+
end
|
|
436
|
+
local value = parse(params.value)
|
|
437
|
+
|
|
438
|
+
if params.kind == "memory" then
|
|
439
|
+
local map = mapOf(params)
|
|
440
|
+
local ttl = math.clamp(tonumber(params.ttl) or DEFAULT_TTL, 1, 3888000)
|
|
441
|
+
local ok, err = pcall(function()
|
|
442
|
+
map:SetAsync(key, value, ttl)
|
|
443
|
+
end)
|
|
444
|
+
if not ok then
|
|
445
|
+
Dispatch.fail("MEMORYSTORE_FAILED", string.format("Could not write %q: %s", key, tostring(err)))
|
|
446
|
+
end
|
|
447
|
+
return { kind = "memory", store = params.store, key = key, written = true, ttl = ttl }
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
if params.confirm ~= true then
|
|
451
|
+
Dispatch.fail(
|
|
452
|
+
"CONFIRM_REQUIRED",
|
|
453
|
+
string.format("Writing %q would overwrite whatever a player has saved there.", key),
|
|
454
|
+
"Nothing here can undo it -- there is no recording to cancel and no Ctrl+Z. "
|
|
455
|
+
.. 'Read it first with op="get", then pass confirm=true if the new value is right.'
|
|
456
|
+
)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
local store = storeOf(params)
|
|
460
|
+
local ok, err = pcall(function()
|
|
461
|
+
store:SetAsync(key, value)
|
|
462
|
+
end)
|
|
463
|
+
if not ok then
|
|
464
|
+
gate(err, "writing the key")
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
kind = "data",
|
|
469
|
+
store = params.store,
|
|
470
|
+
key = key,
|
|
471
|
+
written = true,
|
|
472
|
+
budget = budget(Enum.DataStoreRequestType.SetIncrementAsync),
|
|
473
|
+
note = 'The previous value is still in version history -- op="versions" lists it.',
|
|
474
|
+
}
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
--[[
|
|
478
|
+
Deletes a key, with the same confirmation and one reassurance.
|
|
479
|
+
|
|
480
|
+
`RemoveAsync` on a DataStore is a soft delete: the key stops reading back but
|
|
481
|
+
its versions remain, so a removal is recoverable in a way a `set` over the
|
|
482
|
+
top of a value is not. Said in the reply rather than assumed, because a
|
|
483
|
+
caller who does not know that will not ask for it back.
|
|
484
|
+
]]
|
|
485
|
+
function Data.remove(params: { [string]: any }): { [string]: any }
|
|
486
|
+
local key = params.key
|
|
487
|
+
if typeof(key) ~= "string" or key == "" then
|
|
488
|
+
Dispatch.fail("BAD_PARAMS", "remove needs a `key`.")
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
if params.kind == "memory" then
|
|
492
|
+
local map = mapOf(params)
|
|
493
|
+
local ok, err = pcall(function()
|
|
494
|
+
map:RemoveAsync(key)
|
|
495
|
+
end)
|
|
496
|
+
if not ok then
|
|
497
|
+
Dispatch.fail("MEMORYSTORE_FAILED", string.format("Could not remove %q: %s", key, tostring(err)))
|
|
498
|
+
end
|
|
499
|
+
return { kind = "memory", store = params.store, key = key, removed = true }
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
if params.confirm ~= true then
|
|
503
|
+
Dispatch.fail(
|
|
504
|
+
"CONFIRM_REQUIRED",
|
|
505
|
+
string.format("Removing %q would take away whatever a player has saved there.", key),
|
|
506
|
+
"Pass confirm=true to do it. It is recoverable -- the key's versions survive "
|
|
507
|
+
.. 'a removal and op="versions" still lists them.'
|
|
508
|
+
)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
local store = storeOf(params)
|
|
512
|
+
local ok, removed = pcall(function()
|
|
513
|
+
return store:RemoveAsync(key)
|
|
514
|
+
end)
|
|
515
|
+
if not ok then
|
|
516
|
+
gate(removed, "removing the key")
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
local row = render(removed)
|
|
520
|
+
row.kind = "data"
|
|
521
|
+
row.store = params.store
|
|
522
|
+
row.key = key
|
|
523
|
+
row.removed = true
|
|
524
|
+
row.note = "This was the value that was removed. Its versions survive; "
|
|
525
|
+
.. 'op="versions" still lists them.'
|
|
526
|
+
return row
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
function Data.register()
|
|
530
|
+
Dispatch.registerAll("data", {
|
|
531
|
+
list = Data.list,
|
|
532
|
+
get = Data.get,
|
|
533
|
+
versions = Data.versions,
|
|
534
|
+
set = Data.set,
|
|
535
|
+
remove = Data.remove,
|
|
536
|
+
})
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
return Data
|