@quenty/datastore 13.43.0 → 13.45.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,18 @@
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.45.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.44.0...@quenty/datastore@13.45.0) (2026-07-21)
7
+
8
+ ### Features
9
+
10
+ - Add ephemeral save slots ([16bd91b](https://github.com/Quenty/NevermoreEngine/commit/16bd91b87943a65165245cba90d44274585903d6))
11
+
12
+ # [13.44.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.43.0...@quenty/datastore@13.44.0) (2026-07-21)
13
+
14
+ ### Features
15
+
16
+ - Add data store overflow handling ([96df5f5](https://github.com/Quenty/NevermoreEngine/commit/96df5f57bff20c41a8293dfaadb951e1831297b3))
17
+
6
18
  # [13.43.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.42.0...@quenty/datastore@13.43.0) (2026-07-20)
7
19
 
8
20
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/datastore",
3
- "version": "13.43.0",
3
+ "version": "13.45.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": "f04837e2cf40c9cb66cb672f1f4d3f28ddb0088c"
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)
@@ -0,0 +1,73 @@
1
+ --!strict
2
+ --[=[
3
+ A [DataStoreStage] root that lives entirely in memory: it is never backed by a Roblox datastore, so
4
+ nothing it holds is ever written or read across sessions. Reads resolve to whatever has been staged in
5
+ memory (defaults when unset) and writes stay local to this object, vanishing when it is destroyed.
6
+
7
+ Every read/write/substore/observe method comes from [DataStoreStage] unchanged -- the base class computes
8
+ its view purely from in-memory snapshots. The only thing a stage normally needs a parent for is
9
+ [DataStoreStage.PromiseViewUpToDate] (loading its base layer from the datastore above it); a root has no
10
+ parent, so this class resolves that immediately against its own view. That single override is the whole
11
+ difference between this and [DataStore], which loads/saves through Roblox.
12
+
13
+ Use it wherever code wants a real store surface for data that must not persist -- e.g. a throwaway session
14
+ slot -- without paying for [DataStore]'s load, save, autosave, and session-locking machinery.
15
+
16
+ ```lua
17
+ local store = InMemoryDataStore.new()
18
+ store:Store("coins", 5)
19
+ print(store:Load("coins"):Yield()) -- 5, never touches a datastore
20
+ ```
21
+
22
+ @server
23
+ @class InMemoryDataStore
24
+ ]=]
25
+
26
+ local require = require(script.Parent.loader).load(script)
27
+
28
+ local DataStoreStage = require("DataStoreStage")
29
+ local Promise = require("Promise")
30
+
31
+ local InMemoryDataStore = setmetatable({}, DataStoreStage)
32
+ InMemoryDataStore.ClassName = "InMemoryDataStore"
33
+ InMemoryDataStore.__index = InMemoryDataStore
34
+
35
+ export type InMemoryDataStore =
36
+ typeof(setmetatable({} :: {}, {} :: typeof({ __index = InMemoryDataStore })))
37
+ & DataStoreStage.DataStoreStage
38
+
39
+ --[=[
40
+ Constructs a new in-memory data store.
41
+
42
+ @param loadName (string | number)? -- diagnostic name only (see [DataStoreStage.GetFullPath]); defaults to "InMemoryDataStore"
43
+ @return InMemoryDataStore
44
+ ]=]
45
+ function InMemoryDataStore.new(loadName: (string | number)?): InMemoryDataStore
46
+ local self: InMemoryDataStore =
47
+ setmetatable(DataStoreStage.new(loadName or "InMemoryDataStore") :: any, InMemoryDataStore)
48
+
49
+ return self
50
+ end
51
+
52
+ --[=[
53
+ The view is always exactly what has been staged in memory, so it is never out of date: there is no
54
+ parent or datastore to sync from. Overriding this (the base class errors on a parentless stage) is what
55
+ makes every inherited read work in-memory.
56
+
57
+ @return Promise
58
+ ]=]
59
+ function InMemoryDataStore.PromiseViewUpToDate(_self: InMemoryDataStore): Promise.Promise<()>
60
+ return Promise.resolved()
61
+ end
62
+
63
+ --[=[
64
+ A no-op that resolves: there is no backing datastore to flush to. Present so this is a drop-in root for
65
+ code that expects to be able to call `:Save()` on its store.
66
+
67
+ @return Promise
68
+ ]=]
69
+ function InMemoryDataStore.Save(_self: InMemoryDataStore): Promise.Promise<()>
70
+ return Promise.resolved()
71
+ end
72
+
73
+ return InMemoryDataStore
@@ -0,0 +1,248 @@
1
+ --!nonstrict
2
+ --[[
3
+ Coverage for InMemoryDataStore.
4
+
5
+ The bulk is a *matrix* suite: one shared battery of DataStoreStage-surface behaviors run against both
6
+ store roots -- the real DataStore (over a DataStoreMock) and InMemoryDataStore -- so the in-memory store
7
+ is proven to behave identically to the persisted one on every read/write/substore/observe path they share.
8
+ The store-specific describe blocks then cover what only the in-memory store guarantees: isolation between
9
+ instances, a no-op save, and never reaching a datastore at all.
10
+
11
+ @class InMemoryDataStore.spec.lua
12
+ ]]
13
+ local require = require(script.Parent.loader).load(script)
14
+
15
+ local DataStoreTestUtils = require("DataStoreTestUtils")
16
+ local InMemoryDataStore = require("InMemoryDataStore")
17
+ local Jest = require("Jest")
18
+ local Maid = require("Maid")
19
+ local PromiseTestUtils = require("PromiseTestUtils")
20
+
21
+ local describe = Jest.Globals.describe
22
+ local expect = Jest.Globals.expect
23
+ local it = Jest.Globals.it
24
+
25
+ -- Asserts the promise settled within the timeout and returns its resolved value, so a hung promise fails
26
+ -- the test here instead of freezing the runner on a later :Yield().
27
+ local function resolve(promise, timeout: number?)
28
+ expect(PromiseTestUtils.awaitSettled(promise, timeout or 10)).toEqual(true)
29
+ local ok, value = promise:Yield()
30
+ expect(ok).toEqual(true)
31
+ return value
32
+ end
33
+
34
+ -- A controller exposes makeStore() (a fresh store root) and destroy() (tears down everything it created).
35
+ -- The matrix runs the same suite against one of these per store implementation.
36
+ local function newInMemoryController()
37
+ local maid = Maid.new()
38
+ return {
39
+ makeStore = function()
40
+ return maid:Add(InMemoryDataStore.new())
41
+ end,
42
+ destroy = function()
43
+ maid:DoCleaning()
44
+ end,
45
+ }
46
+ end
47
+
48
+ local function newDataStoreController()
49
+ local controller = DataStoreTestUtils.setup()
50
+ return {
51
+ makeStore = function()
52
+ return controller.newDataStore()
53
+ end,
54
+ destroy = controller.destroy,
55
+ }
56
+ end
57
+
58
+ -- The shared behavior battery. `newController` returns a fresh controller so each test is isolated.
59
+ local function describeSharedBehavior(caseName: string, newController)
60
+ describe(caseName, function()
61
+ it("loads the default value when the key is empty", function()
62
+ local c = newController()
63
+ expect(resolve(c.makeStore():Load("coins", 99))).toEqual(99)
64
+ c.destroy()
65
+ end)
66
+
67
+ it("round-trips a stored value", function()
68
+ local c = newController()
69
+ local store = c.makeStore()
70
+ store:Store("coins", 5)
71
+ expect(resolve(store:Load("coins"))).toEqual(5)
72
+ c.destroy()
73
+ end)
74
+
75
+ it("round-trips multiple keys and loads defaults for missing ones", function()
76
+ local c = newController()
77
+ local store = c.makeStore()
78
+ store:Store("coins", 5)
79
+ store:Store("gems", 10)
80
+
81
+ local all = resolve(store:LoadAll())
82
+ expect(all.coins).toEqual(5)
83
+ expect(all.gems).toEqual(10)
84
+ expect(resolve(store:Load("missing", "default"))).toEqual("default")
85
+ c.destroy()
86
+ end)
87
+
88
+ it("deletes a key so it no longer loads", function()
89
+ local c = newController()
90
+ local store = c.makeStore()
91
+ store:Store("a", 1)
92
+ store:Store("b", 2)
93
+ store:Delete("a")
94
+
95
+ local all = resolve(store:LoadAll())
96
+ expect(all.a).toEqual(nil)
97
+ expect(all.b).toEqual(2)
98
+ c.destroy()
99
+ end)
100
+
101
+ it("overwrites the whole view", function()
102
+ local c = newController()
103
+ local store = c.makeStore()
104
+ store:Store("a", 1)
105
+ store:Store("b", 2)
106
+ store:Overwrite({ c = 3 })
107
+
108
+ local all = resolve(store:LoadAll())
109
+ expect(all.a).toEqual(nil)
110
+ expect(all.b).toEqual(nil)
111
+ expect(all.c).toEqual(3)
112
+ c.destroy()
113
+ end)
114
+
115
+ it("wipes to empty", function()
116
+ local c = newController()
117
+ local store = c.makeStore()
118
+ store:Store("a", 1)
119
+ store:Wipe()
120
+ expect(resolve(store:LoadAll({}))).toEqual({})
121
+ c.destroy()
122
+ end)
123
+
124
+ it("round-trips substore values and nests them under the parent", function()
125
+ local c = newController()
126
+ local store = c.makeStore()
127
+ store:GetSubStore("inventory"):Store("sword", true)
128
+
129
+ expect(resolve(store:GetSubStore("inventory"):Load("sword"))).toEqual(true)
130
+
131
+ local all = resolve(store:LoadAll())
132
+ expect(all.inventory.sword).toEqual(true)
133
+ c.destroy()
134
+ end)
135
+
136
+ it("lists the top-level keys", function()
137
+ local c = newController()
138
+ local store = c.makeStore()
139
+ store:Store("a", 1)
140
+ store:Store("b", 2)
141
+
142
+ local keys = resolve(store:PromiseKeyList())
143
+ table.sort(keys)
144
+ expect(keys).toEqual({ "a", "b" })
145
+ c.destroy()
146
+ end)
147
+
148
+ it("stores table values by deep copy, immune to later mutation of the source", function()
149
+ local c = newController()
150
+ local store = c.makeStore()
151
+ local source = { count = 1 }
152
+ store:Store("data", source)
153
+ source.count = 999 -- Mutating after the store must not change what was stored.
154
+
155
+ expect(resolve(store:Load("data")).count).toEqual(1)
156
+ c.destroy()
157
+ end)
158
+
159
+ it("observes a key: emits the initial value then updates on store", function()
160
+ local c = newController()
161
+ local store = c.makeStore()
162
+
163
+ local maid = Maid.new()
164
+ local seen = {}
165
+ maid:GiveTask(store:Observe("coins", 0):Subscribe(function(value)
166
+ table.insert(seen, value)
167
+ end))
168
+
169
+ expect(PromiseTestUtils.awaitValue(function()
170
+ return #seen >= 1
171
+ end, 5)).toEqual(true)
172
+ expect(seen[1]).toEqual(0) -- default before anything is stored
173
+
174
+ store:Store("coins", 7)
175
+ expect(PromiseTestUtils.awaitValue(function()
176
+ return seen[#seen] == 7
177
+ end, 5)).toEqual(true)
178
+
179
+ maid:DoCleaning()
180
+ c.destroy()
181
+ end)
182
+ end)
183
+ end
184
+
185
+ describeSharedBehavior("matrix: DataStore over DataStoreMock", newDataStoreController)
186
+ describeSharedBehavior("matrix: InMemoryDataStore", newInMemoryController)
187
+
188
+ -- A single test that runs an identical op sequence against both roots at once and asserts they land on the
189
+ -- exact same view -- the matrix's consistency guarantee stated directly, not just implied by parallel suites.
190
+ describe("matrix: cross-implementation consistency", function()
191
+ it("yields the same view for the same op sequence on both roots", function()
192
+ local dataStoreController = newDataStoreController()
193
+ local inMemoryController = newInMemoryController()
194
+
195
+ local function runOps(store)
196
+ store:Store("coins", 5)
197
+ store:Store("gems", 10)
198
+ store:GetSubStore("inventory"):Store("sword", true)
199
+ store:Delete("gems")
200
+ return resolve(store:LoadAll())
201
+ end
202
+
203
+ local persistedView = runOps(dataStoreController.makeStore())
204
+ local inMemoryView = runOps(inMemoryController.makeStore())
205
+
206
+ expect(inMemoryView).toEqual(persistedView)
207
+
208
+ dataStoreController.destroy()
209
+ inMemoryController.destroy()
210
+ end)
211
+ end)
212
+
213
+ describe("InMemoryDataStore isolation and non-persistence", function()
214
+ it("does not share data between separate instances", function()
215
+ local maid = Maid.new()
216
+ local first = maid:Add(InMemoryDataStore.new())
217
+ first:Store("coins", 5)
218
+
219
+ local second = maid:Add(InMemoryDataStore.new())
220
+ expect(resolve(second:Load("coins", 0))).toEqual(0)
221
+
222
+ maid:DoCleaning()
223
+ end)
224
+
225
+ it("resolves Save as a no-op", function()
226
+ local maid = Maid.new()
227
+ local store = maid:Add(InMemoryDataStore.new())
228
+ store:Store("coins", 5)
229
+
230
+ expect(resolve(store:Save())).toEqual(nil)
231
+ -- Data is still readable in memory after the (no-op) save.
232
+ expect(resolve(store:Load("coins"))).toEqual(5)
233
+
234
+ maid:DoCleaning()
235
+ end)
236
+
237
+ it("resolves reads immediately with no parent to sync from", function()
238
+ local maid = Maid.new()
239
+ local store = maid:Add(InMemoryDataStore.new())
240
+
241
+ -- The base class errors on Load for a parentless stage; this proves the override took.
242
+ expect(resolve(store:LoadAll({}))).toEqual({})
243
+
244
+ maid:DoCleaning()
245
+ end)
246
+ end)
247
+
248
+ return nil
@@ -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)