@quenty/datastore 13.43.0 → 13.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
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.44.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.43.0...@quenty/datastore@13.44.0) (2026-07-21)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- Add data store overflow handling ([96df5f5](https://github.com/Quenty/NevermoreEngine/commit/96df5f57bff20c41a8293dfaadb951e1831297b3))
|
|
11
|
+
|
|
6
12
|
# [13.43.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.42.0...@quenty/datastore@13.43.0) (2026-07-20)
|
|
7
13
|
|
|
8
14
|
### Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quenty/datastore",
|
|
3
|
-
"version": "13.
|
|
3
|
+
"version": "13.44.0",
|
|
4
4
|
"description": "Quenty's Datastore implementation for Roblox",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Roblox",
|
|
@@ -50,5 +50,5 @@
|
|
|
50
50
|
"publishConfig": {
|
|
51
51
|
"access": "public"
|
|
52
52
|
},
|
|
53
|
-
"gitHead": "
|
|
53
|
+
"gitHead": "b7e59984e586064ea3cec6176e79b3f7451ecdc5"
|
|
54
54
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
--!nonstrict
|
|
2
|
+
--[[
|
|
3
|
+
Integration coverage for the overflow-save failure: when a key accumulates more data than Roblox
|
|
4
|
+
can serialize under its per-key size ceiling, the write throws and the save must fail loudly rather
|
|
5
|
+
than silently drop the data or corrupt what was already stored. Driven through the full DataStore
|
|
6
|
+
stack against a DataStoreMock configured with a small [DataStoreMock.SetMaxValueLength] so the
|
|
7
|
+
overflow triggers without a multi-megabyte payload.
|
|
8
|
+
|
|
9
|
+
@class DataStore.Overflow.spec.lua
|
|
10
|
+
]]
|
|
11
|
+
local require = require(script.Parent.loader).load(script)
|
|
12
|
+
|
|
13
|
+
local DataStoreMock = require("DataStoreMock")
|
|
14
|
+
local DataStoreTestUtils = require("DataStoreTestUtils")
|
|
15
|
+
local Jest = require("Jest")
|
|
16
|
+
local PromiseTestUtils = require("PromiseTestUtils")
|
|
17
|
+
|
|
18
|
+
local describe = Jest.Globals.describe
|
|
19
|
+
local expect = Jest.Globals.expect
|
|
20
|
+
local it = Jest.Globals.it
|
|
21
|
+
|
|
22
|
+
-- Asserts the promise settled within the timeout, so a hung promise fails the test (here) instead of
|
|
23
|
+
-- freezing the runner on the following :Yield().
|
|
24
|
+
local function expectSettled(promise, timeout: number?)
|
|
25
|
+
expect(PromiseTestUtils.awaitSettled(promise, timeout)).toEqual(true)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe("DataStore overflow save", function()
|
|
29
|
+
it("should reject a save whose serialized value exceeds the datastore size limit", function()
|
|
30
|
+
local controller = DataStoreTestUtils.setup()
|
|
31
|
+
local dataStore = controller.newDataStore()
|
|
32
|
+
|
|
33
|
+
expectSettled(dataStore:Load("data"))
|
|
34
|
+
|
|
35
|
+
controller.mock:SetMaxValueLength(1024)
|
|
36
|
+
dataStore:Store("data", string.rep("A", 8192))
|
|
37
|
+
|
|
38
|
+
local savePromise = dataStore:Save()
|
|
39
|
+
expectSettled(savePromise, 5)
|
|
40
|
+
expect((savePromise:Yield())).toEqual(false)
|
|
41
|
+
|
|
42
|
+
controller:destroy()
|
|
43
|
+
end)
|
|
44
|
+
|
|
45
|
+
it("should leave the previously saved value intact when an oversized save fails", function()
|
|
46
|
+
local controller = DataStoreTestUtils.setup()
|
|
47
|
+
local writer = controller.newDataStore()
|
|
48
|
+
|
|
49
|
+
writer:Store("data", "known-good")
|
|
50
|
+
expectSettled(writer:Save())
|
|
51
|
+
|
|
52
|
+
-- Now grow the same key past the ceiling and try to save again.
|
|
53
|
+
controller.mock:SetMaxValueLength(1024)
|
|
54
|
+
writer:Store("data", string.rep("A", 8192))
|
|
55
|
+
|
|
56
|
+
local savePromise = writer:Save()
|
|
57
|
+
expectSettled(savePromise, 5)
|
|
58
|
+
expect((savePromise:Yield())).toEqual(false)
|
|
59
|
+
|
|
60
|
+
-- The failed write must not have clobbered the stored value.
|
|
61
|
+
controller.mock:SetMaxValueLength(nil)
|
|
62
|
+
local reader = controller.newDataStore()
|
|
63
|
+
local loadPromise = reader:Load("data")
|
|
64
|
+
expectSettled(loadPromise)
|
|
65
|
+
|
|
66
|
+
local ok, value = loadPromise:Yield()
|
|
67
|
+
expect(ok).toEqual(true)
|
|
68
|
+
expect(value).toEqual("known-good")
|
|
69
|
+
|
|
70
|
+
controller:destroy()
|
|
71
|
+
end)
|
|
72
|
+
|
|
73
|
+
it("should still save values that fit within the real 4 MB ceiling", function()
|
|
74
|
+
local controller = DataStoreTestUtils.setup()
|
|
75
|
+
local dataStore = controller.newDataStore()
|
|
76
|
+
|
|
77
|
+
controller.mock:SetMaxValueLength(DataStoreMock.MAX_VALUE_LENGTH)
|
|
78
|
+
dataStore:Store("coins", 5)
|
|
79
|
+
|
|
80
|
+
local savePromise = dataStore:Save()
|
|
81
|
+
expectSettled(savePromise)
|
|
82
|
+
expect((savePromise:Yield())).toEqual(true)
|
|
83
|
+
|
|
84
|
+
local reader = controller.newDataStore()
|
|
85
|
+
local loadPromise = reader:Load("coins")
|
|
86
|
+
expectSettled(loadPromise)
|
|
87
|
+
expect((loadPromise:Yield())).toEqual(true)
|
|
88
|
+
|
|
89
|
+
controller:destroy()
|
|
90
|
+
end)
|
|
91
|
+
|
|
92
|
+
it("should fail the save when data accumulated across substores overflows the key", function()
|
|
93
|
+
local controller = DataStoreTestUtils.setup()
|
|
94
|
+
local dataStore = controller.newDataStore()
|
|
95
|
+
|
|
96
|
+
expectSettled(dataStore:Load("data"))
|
|
97
|
+
|
|
98
|
+
-- The whole key value -- every substore merged together -- is what gets serialized, so a single
|
|
99
|
+
-- oversized substore entry overflows the entire save.
|
|
100
|
+
controller.mock:SetMaxValueLength(1024)
|
|
101
|
+
dataStore:GetSubStore("inventory"):Store("blob", string.rep("A", 8192))
|
|
102
|
+
|
|
103
|
+
local savePromise = dataStore:Save()
|
|
104
|
+
expectSettled(savePromise, 5)
|
|
105
|
+
expect((savePromise:Yield())).toEqual(false)
|
|
106
|
+
|
|
107
|
+
controller:destroy()
|
|
108
|
+
end)
|
|
109
|
+
end)
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
|
|
24
24
|
local require = require(script.Parent.loader).load(script)
|
|
25
25
|
|
|
26
|
+
local HttpService = game:GetService("HttpService")
|
|
27
|
+
|
|
26
28
|
local Table = require("Table")
|
|
27
29
|
|
|
28
30
|
local function deepCopy(value: any): any
|
|
@@ -33,6 +35,14 @@ local function deepCopy(value: any): any
|
|
|
33
35
|
return Table.deepCopy(value)
|
|
34
36
|
end
|
|
35
37
|
|
|
38
|
+
-- Mirrors how Roblox reports a value that serializes past the per-key size ceiling.
|
|
39
|
+
local function valueTooLargeMessage(maxValueLength: number): string
|
|
40
|
+
return string.format(
|
|
41
|
+
"105: The value provided exceeds the %d byte maximum size limit for a data store value",
|
|
42
|
+
maxValueLength
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
36
46
|
local DataStoreMock = {}
|
|
37
47
|
DataStoreMock.ClassName = "DataStoreMock"
|
|
38
48
|
DataStoreMock.__index = DataStoreMock
|
|
@@ -46,6 +56,17 @@ DataStoreMock.__index = DataStoreMock
|
|
|
46
56
|
DataStoreMock.OPERATION_NOT_ALLOWED_509 =
|
|
47
57
|
"509: Data Store operations blocked while running on a Personal RCC to prevent possible data corruption"
|
|
48
58
|
|
|
59
|
+
--[=[
|
|
60
|
+
The per-key serialized-value ceiling Roblox enforces (4 MB). Real datastores serialize a key's
|
|
61
|
+
whole value to JSON and reject the write when that blob is larger than this, which is how a save
|
|
62
|
+
fails once too much data accumulates under one key. Pass this (or a smaller value, to trigger it
|
|
63
|
+
without a multi-megabyte payload) to [DataStoreMock.SetMaxValueLength].
|
|
64
|
+
|
|
65
|
+
@prop MAX_VALUE_LENGTH number
|
|
66
|
+
@within DataStoreMock
|
|
67
|
+
]=]
|
|
68
|
+
DataStoreMock.MAX_VALUE_LENGTH = 4194304
|
|
69
|
+
|
|
49
70
|
export type ErrorInjectorContext = {
|
|
50
71
|
method: string,
|
|
51
72
|
key: string,
|
|
@@ -68,6 +89,7 @@ export type DataStoreMock = typeof(setmetatable(
|
|
|
68
89
|
_yieldTime: number,
|
|
69
90
|
_errorInjector: ErrorInjector?,
|
|
70
91
|
_blocked: boolean,
|
|
92
|
+
_maxValueLength: number?,
|
|
71
93
|
},
|
|
72
94
|
{} :: typeof({ __index = DataStoreMock })
|
|
73
95
|
))
|
|
@@ -106,6 +128,7 @@ function DataStoreMock.new(name: string?, scope: string?): DataStoreMock
|
|
|
106
128
|
self._yieldTime = 0
|
|
107
129
|
self._errorInjector = nil
|
|
108
130
|
self._blocked = false
|
|
131
|
+
self._maxValueLength = nil
|
|
109
132
|
|
|
110
133
|
return self
|
|
111
134
|
end
|
|
@@ -122,6 +145,24 @@ function DataStoreMock.SetYieldTime(self: DataStoreMock, yieldTime: number): ()
|
|
|
122
145
|
self._yieldTime = yieldTime
|
|
123
146
|
end
|
|
124
147
|
|
|
148
|
+
--[=[
|
|
149
|
+
Enforces a serialized-value ceiling on `SetAsync`/`UpdateAsync`, mirroring the way real
|
|
150
|
+
datastores reject a write once a key's whole value serializes past their per-key size limit.
|
|
151
|
+
A write whose value JSON-encodes to more than `maxValueLength` bytes throws (and stores nothing),
|
|
152
|
+
so tests can exercise the overflow-save failure path without a multi-megabyte payload. A value the
|
|
153
|
+
mock cannot serialize at all throws the same way a real datastore rejects non-UTF-8 data.
|
|
154
|
+
|
|
155
|
+
Pass [DataStoreMock.MAX_VALUE_LENGTH] for the real 4 MB ceiling, a smaller number to trigger it
|
|
156
|
+
cheaply, or nil to disable the check (the default, so existing tests are unaffected).
|
|
157
|
+
|
|
158
|
+
@param maxValueLength number?
|
|
159
|
+
]=]
|
|
160
|
+
function DataStoreMock.SetMaxValueLength(self: DataStoreMock, maxValueLength: number?): ()
|
|
161
|
+
assert(maxValueLength == nil or (type(maxValueLength) == "number" and maxValueLength >= 0), "Bad maxValueLength")
|
|
162
|
+
|
|
163
|
+
self._maxValueLength = maxValueLength
|
|
164
|
+
end
|
|
165
|
+
|
|
125
166
|
--[=[
|
|
126
167
|
Injects a callback consulted before every request. Returning a string from the callback
|
|
127
168
|
makes that request throw the string as its error; returning nil lets the request proceed.
|
|
@@ -261,6 +302,27 @@ function DataStoreMock._beginRequest(self: DataStoreMock, method: string, key: s
|
|
|
261
302
|
end
|
|
262
303
|
end
|
|
263
304
|
|
|
305
|
+
-- Rejects a write whose value serializes past the configured size ceiling, the way real datastores
|
|
306
|
+
-- reject a key value that grew too large to store safely. A no-op unless SetMaxValueLength was set.
|
|
307
|
+
function DataStoreMock._assertWithinSizeLimit(self: DataStoreMock, value: any): ()
|
|
308
|
+
local maxValueLength = self._maxValueLength
|
|
309
|
+
if maxValueLength == nil or value == nil then
|
|
310
|
+
return
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
local ok, encoded = pcall(function()
|
|
314
|
+
return HttpService:JSONEncode(value)
|
|
315
|
+
end)
|
|
316
|
+
if not ok then
|
|
317
|
+
-- A real datastore likewise refuses a value it cannot serialize.
|
|
318
|
+
error("104: Cannot store value in data store. Data stores can only accept valid UTF-8 characters", 0)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
if #encoded > maxValueLength then
|
|
322
|
+
error(valueTooLargeMessage(maxValueLength), 0)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
264
326
|
function DataStoreMock._makeKeyInfo(self: DataStoreMock, key: string)
|
|
265
327
|
local userIds = self._userIds[key]
|
|
266
328
|
local metadata = self._metadata[key]
|
|
@@ -312,6 +374,7 @@ function DataStoreMock.SetAsync(
|
|
|
312
374
|
assert(type(key) == "string", "Bad key")
|
|
313
375
|
|
|
314
376
|
self:_beginRequest("SetAsync", key)
|
|
377
|
+
self:_assertWithinSizeLimit(value)
|
|
315
378
|
|
|
316
379
|
self._store[key] = deepCopy(value)
|
|
317
380
|
self._userIds[key] = userIds and Table.deepCopy(userIds) or nil
|
|
@@ -351,6 +414,8 @@ function DataStoreMock.UpdateAsync(
|
|
|
351
414
|
return nil, keyInfo
|
|
352
415
|
end
|
|
353
416
|
|
|
417
|
+
self:_assertWithinSizeLimit(newValue)
|
|
418
|
+
|
|
354
419
|
self._store[key] = deepCopy(newValue)
|
|
355
420
|
self._userIds[key] = userIds and Table.deepCopy(userIds) or nil
|
|
356
421
|
self._metadata[key] = metadata and Table.deepCopy(metadata) or nil
|
|
@@ -359,6 +359,83 @@ describe("DataStoreMock call counting", function()
|
|
|
359
359
|
end)
|
|
360
360
|
end)
|
|
361
361
|
|
|
362
|
+
describe("DataStoreMock serialized-size overflow", function()
|
|
363
|
+
it("should not enforce any size limit by default", function()
|
|
364
|
+
local store = DataStoreMock.new()
|
|
365
|
+
-- A value far larger than a small limit persists fine when no limit is configured.
|
|
366
|
+
expect(function()
|
|
367
|
+
store:SetAsync("key", string.rep("A", 100000))
|
|
368
|
+
end).never.toThrow()
|
|
369
|
+
end)
|
|
370
|
+
|
|
371
|
+
it("should reject a SetAsync whose value exceeds the configured limit", function()
|
|
372
|
+
local store = DataStoreMock.new()
|
|
373
|
+
store:SetMaxValueLength(1024)
|
|
374
|
+
|
|
375
|
+
expect(function()
|
|
376
|
+
store:SetAsync("key", string.rep("A", 4096))
|
|
377
|
+
end).toThrow("maximum size limit")
|
|
378
|
+
end)
|
|
379
|
+
|
|
380
|
+
it("should allow a SetAsync whose value fits within the configured limit", function()
|
|
381
|
+
local store = DataStoreMock.new()
|
|
382
|
+
store:SetMaxValueLength(1024)
|
|
383
|
+
|
|
384
|
+
expect(function()
|
|
385
|
+
store:SetAsync("key", "small")
|
|
386
|
+
end).never.toThrow()
|
|
387
|
+
expect((store:GetAsync("key"))).toEqual("small")
|
|
388
|
+
end)
|
|
389
|
+
|
|
390
|
+
it("should not persist an oversized value that was rejected", function()
|
|
391
|
+
local store = DataStoreMock.new()
|
|
392
|
+
store:SetAsync("key", "original")
|
|
393
|
+
store:SetMaxValueLength(1024)
|
|
394
|
+
|
|
395
|
+
pcall(function()
|
|
396
|
+
store:SetAsync("key", string.rep("A", 4096))
|
|
397
|
+
end)
|
|
398
|
+
|
|
399
|
+
store:SetMaxValueLength(nil)
|
|
400
|
+
expect((store:GetAsync("key"))).toEqual("original")
|
|
401
|
+
end)
|
|
402
|
+
|
|
403
|
+
it("should reject an UpdateAsync whose returned value exceeds the configured limit", function()
|
|
404
|
+
local store = DataStoreMock.new()
|
|
405
|
+
store:SetMaxValueLength(1024)
|
|
406
|
+
|
|
407
|
+
expect(function()
|
|
408
|
+
store:UpdateAsync("key", function()
|
|
409
|
+
return string.rep("A", 4096)
|
|
410
|
+
end)
|
|
411
|
+
end).toThrow("maximum size limit")
|
|
412
|
+
end)
|
|
413
|
+
|
|
414
|
+
it("should measure the serialized size of tables, not their length", function()
|
|
415
|
+
local store = DataStoreMock.new()
|
|
416
|
+
store:SetMaxValueLength(64)
|
|
417
|
+
|
|
418
|
+
local big = {}
|
|
419
|
+
for i = 1, 100 do
|
|
420
|
+
big[i] = i
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
expect(function()
|
|
424
|
+
store:SetAsync("key", big)
|
|
425
|
+
end).toThrow("maximum size limit")
|
|
426
|
+
end)
|
|
427
|
+
|
|
428
|
+
it("should stop enforcing once the limit is cleared", function()
|
|
429
|
+
local store = DataStoreMock.new()
|
|
430
|
+
store:SetMaxValueLength(1024)
|
|
431
|
+
store:SetMaxValueLength(nil)
|
|
432
|
+
|
|
433
|
+
expect(function()
|
|
434
|
+
store:SetAsync("key", string.rep("A", 4096))
|
|
435
|
+
end).never.toThrow()
|
|
436
|
+
end)
|
|
437
|
+
end)
|
|
438
|
+
|
|
362
439
|
describe("DataStoreMock:SetRaw / GetRaw", function()
|
|
363
440
|
it("should seed and read without triggering failures", function()
|
|
364
441
|
local store = DataStoreMock.new()
|
|
@@ -29,4 +29,63 @@ describe("DataStoreStringUtils.isValidUTF8(str)", function()
|
|
|
29
29
|
local result = DataStoreStringUtils.isValidUTF8("")
|
|
30
30
|
expect(result).toEqual(true)
|
|
31
31
|
end)
|
|
32
|
+
|
|
33
|
+
it("should return true across the full 7-bit ASCII range", function()
|
|
34
|
+
local bytes = {}
|
|
35
|
+
for i = 0, 127 do
|
|
36
|
+
bytes[i + 1] = string.char(i)
|
|
37
|
+
end
|
|
38
|
+
local result = DataStoreStringUtils.isValidUTF8(table.concat(bytes))
|
|
39
|
+
expect(result).toEqual(true)
|
|
40
|
+
end)
|
|
41
|
+
|
|
42
|
+
it("should return true for control characters and embedded NUL bytes", function()
|
|
43
|
+
expect(DataStoreStringUtils.isValidUTF8("line\r\n\tvalue")).toEqual(true)
|
|
44
|
+
expect(DataStoreStringUtils.isValidUTF8("with\0null")).toEqual(true)
|
|
45
|
+
end)
|
|
46
|
+
|
|
47
|
+
it("should return true at the 127 boundary and false at 128", function()
|
|
48
|
+
expect(DataStoreStringUtils.isValidUTF8(string.char(127))).toEqual(true)
|
|
49
|
+
|
|
50
|
+
local result, reason = DataStoreStringUtils.isValidUTF8(string.char(128))
|
|
51
|
+
expect(result).toEqual(false)
|
|
52
|
+
expect(reason).toEqual("Invalid string")
|
|
53
|
+
end)
|
|
54
|
+
|
|
55
|
+
it("should reject well-formed UTF-8 that contains non-ASCII code points", function()
|
|
56
|
+
-- "café" is valid UTF-8 but the accented character is above the ASCII range, which is the
|
|
57
|
+
-- string-saving-attack surface isValidUTF8 exists to block.
|
|
58
|
+
local result, reason = DataStoreStringUtils.isValidUTF8("caf\u{00E9}")
|
|
59
|
+
expect(result).toEqual(false)
|
|
60
|
+
expect(reason).toEqual("Invalid string")
|
|
61
|
+
end)
|
|
62
|
+
|
|
63
|
+
it("should reject emoji and other multi-byte code points", function()
|
|
64
|
+
local result, reason = DataStoreStringUtils.isValidUTF8("party \u{1F389}")
|
|
65
|
+
expect(result).toEqual(false)
|
|
66
|
+
expect(reason).toEqual("Invalid string")
|
|
67
|
+
end)
|
|
68
|
+
|
|
69
|
+
it("should reject bytes that are not valid UTF-8 at all", function()
|
|
70
|
+
-- 0xFF is never a legal UTF-8 byte, so utf8.len fails before the ASCII check runs.
|
|
71
|
+
local result, reason = DataStoreStringUtils.isValidUTF8(string.char(255))
|
|
72
|
+
expect(result).toEqual(false)
|
|
73
|
+
expect(reason).toEqual("Invalid string")
|
|
74
|
+
end)
|
|
75
|
+
|
|
76
|
+
it("should reject a lone continuation byte", function()
|
|
77
|
+
local result, reason = DataStoreStringUtils.isValidUTF8(string.char(0x80))
|
|
78
|
+
expect(result).toEqual(false)
|
|
79
|
+
expect(reason).toEqual("Invalid string")
|
|
80
|
+
end)
|
|
81
|
+
|
|
82
|
+
it("should return false with a reason for nil and table values", function()
|
|
83
|
+
local nilResult, nilReason = DataStoreStringUtils.isValidUTF8(nil :: any)
|
|
84
|
+
expect(nilResult).toEqual(false)
|
|
85
|
+
expect(nilReason).toEqual("Not a string")
|
|
86
|
+
|
|
87
|
+
local tableResult, tableReason = DataStoreStringUtils.isValidUTF8({} :: any)
|
|
88
|
+
expect(tableResult).toEqual(false)
|
|
89
|
+
expect(tableReason).toEqual("Not a string")
|
|
90
|
+
end)
|
|
32
91
|
end)
|