@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 +12 -0
- package/package.json +2 -2
- package/src/Server/DataStore.Overflow.spec.lua +109 -0
- package/src/Server/InMemoryDataStore.lua +73 -0
- package/src/Server/InMemoryDataStore.spec.lua +248 -0
- package/src/Server/Mocks/DataStoreMock.lua +65 -0
- package/src/Server/Mocks/DataStoreMock.spec.lua +77 -0
- package/src/Shared/Utility/DataStoreStringUtils.spec.lua +59 -0
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.
|
|
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": "
|
|
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)
|