@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.43.0",
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": "d2d7ae433f3fa756e038522986144b4f5c616756"
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)