@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.
Files changed (75) hide show
  1. package/README.md +28 -2
  2. package/dist/bridge/console.js +182 -0
  3. package/dist/bridge/console.js.map +1 -1
  4. package/dist/index.js +8 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/cloudassets.js +233 -0
  7. package/dist/lib/cloudassets.js.map +1 -0
  8. package/dist/lib/credentials.js +180 -0
  9. package/dist/lib/credentials.js.map +1 -0
  10. package/dist/lib/livedata.js +325 -0
  11. package/dist/lib/livedata.js.map +1 -0
  12. package/dist/lib/liveluau.js +83 -0
  13. package/dist/lib/liveluau.js.map +1 -0
  14. package/dist/lib/liveops.js +358 -0
  15. package/dist/lib/liveops.js.map +1 -0
  16. package/dist/lib/opencloud.js +235 -0
  17. package/dist/lib/opencloud.js.map +1 -0
  18. package/dist/tools/anim.js +159 -0
  19. package/dist/tools/anim.js.map +1 -0
  20. package/dist/tools/audio.js +96 -0
  21. package/dist/tools/audio.js.map +1 -0
  22. package/dist/tools/character.js +95 -5
  23. package/dist/tools/character.js.map +1 -1
  24. package/dist/tools/data.js +292 -0
  25. package/dist/tools/data.js.map +1 -0
  26. package/dist/tools/device.js +77 -7
  27. package/dist/tools/device.js.map +1 -1
  28. package/dist/tools/discover.js +80 -4
  29. package/dist/tools/discover.js.map +1 -1
  30. package/dist/tools/exec.js +96 -2
  31. package/dist/tools/exec.js.map +1 -1
  32. package/dist/tools/input.js +35 -9
  33. package/dist/tools/input.js.map +1 -1
  34. package/dist/tools/perf.js +74 -7
  35. package/dist/tools/perf.js.map +1 -1
  36. package/dist/tools/scripts.js +162 -6
  37. package/dist/tools/scripts.js.map +1 -1
  38. package/dist/tools/spatial.js +135 -0
  39. package/dist/tools/spatial.js.map +1 -0
  40. package/dist/tools/universe.js +177 -0
  41. package/dist/tools/universe.js.map +1 -0
  42. package/dist/tools/upload.js +294 -0
  43. package/dist/tools/upload.js.map +1 -0
  44. package/dist/tools/world.js +675 -51
  45. package/dist/tools/world.js.map +1 -1
  46. package/package.json +2 -2
  47. package/plugin/src/Commands.luau +31 -7
  48. package/plugin/src/Config.luau +65 -65
  49. package/plugin/src/Console.luau +1909 -1843
  50. package/plugin/src/Emulation.luau +172 -0
  51. package/plugin/src/Phrase.luau +816 -618
  52. package/plugin/src/Png.luau +8 -4
  53. package/plugin/src/Prompt.luau +965 -961
  54. package/plugin/src/Secret.luau +86 -0
  55. package/plugin/src/Serialize.luau +440 -8
  56. package/plugin/src/Undo.luau +94 -6
  57. package/plugin/src/handlers/Anim.luau +897 -0
  58. package/plugin/src/handlers/Assets.luau +286 -2
  59. package/plugin/src/handlers/Audio.luau +411 -0
  60. package/plugin/src/handlers/Capture.luau +155 -20
  61. package/plugin/src/handlers/Character.luau +823 -361
  62. package/plugin/src/handlers/Data.luau +539 -0
  63. package/plugin/src/handlers/Device.luau +394 -139
  64. package/plugin/src/handlers/Discover.luau +685 -363
  65. package/plugin/src/handlers/Geometry.luau +722 -450
  66. package/plugin/src/handlers/Instances.luau +84 -4
  67. package/plugin/src/handlers/Perf.luau +227 -0
  68. package/plugin/src/handlers/Scripts.luau +673 -539
  69. package/plugin/src/handlers/Session.luau +3 -0
  70. package/plugin/src/handlers/Spatial.luau +334 -0
  71. package/plugin/src/handlers/Viewport.luau +268 -0
  72. package/plugin/src/handlers/World.luau +89 -15
  73. package/plugin/src/init.server.luau +9 -1
  74. package/scripts/build-plugin.mjs +20 -0
  75. 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