@quenty/datastore 13.44.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,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.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
|
+
|
|
6
12
|
# [13.44.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.43.0...@quenty/datastore@13.44.0) (2026-07-21)
|
|
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.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,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
|