@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.44.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": "b7e59984e586064ea3cec6176e79b3f7451ecdc5"
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