@quenty/datastore 7.23.0 → 7.23.1-canary.402.5852ffd.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,26 @@
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
+ ## [7.23.1-canary.402.5852ffd.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@7.23.0...@quenty/datastore@7.23.1-canary.402.5852ffd.0) (2023-08-16)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * Fix additional components but data store writing ping-pings back and forth ([16d024d](https://github.com/Quenty/NevermoreEngine/commit/16d024d226057f75249e326be6941db2d519dae7))
12
+ * More data store improvements ([42ab23d](https://github.com/Quenty/NevermoreEngine/commit/42ab23d66a40aa81d034974e57234ee5337a208d))
13
+
14
+
15
+ ### Features
16
+
17
+ * DataStores appear to be working, but require more testing ([d93a6b9](https://github.com/Quenty/NevermoreEngine/commit/d93a6b96da1c3a1ad411cedfcf6c9df571fd9a1f))
18
+ * More untested datastore syncing code ([8dd4cbe](https://github.com/Quenty/NevermoreEngine/commit/8dd4cbe00500933ef6d9fc98ed34c94a5eb34943))
19
+ * Semi-broken datastore changes ([351c44d](https://github.com/Quenty/NevermoreEngine/commit/351c44d48d1f7d7e26b8fbb30a14c7bb4cbac0f1))
20
+ * Unfinished datastore changes ([0bbd600](https://github.com/Quenty/NevermoreEngine/commit/0bbd6005148787f0d06e22650f67e9886318f2f8))
21
+
22
+
23
+
24
+
25
+
6
26
  # [7.23.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@7.22.0...@quenty/datastore@7.23.0) (2023-08-01)
7
27
 
8
28
  **Note:** Version bump only for package @quenty/datastore
package/README.md CHANGED
@@ -15,6 +15,17 @@ This system is a reliable datastore system designed with promises and asyncronio
15
15
 
16
16
  <div align="center"><a href="https://quenty.github.io/NevermoreEngine/api/DataStore">View docs →</a></div>
17
17
 
18
+ ## Executive overiew
19
+ This datastore prevents data loss by being explicit about what we're writing to, and only modifying the data that exists there instead of modifying the whole structure.
20
+
21
+ ## How syncing works
22
+ Sometimes datastores (like a global game data store) need to be synced live instead of upon server or player start. This is if we expect multiple servers to write to the same datastore at once we can use thie sync method to
23
+
24
+ Syncing is like saving. However, instead of treating the current datastore as a session lock, we load in additional data from our "source-of-truth". From here, we merge that data into the datastore, which means both clearing any matching write tokens that our sync says is done.
25
+
26
+ This is best for a "shared" memory that can be temporarily not correct. Deleting with a sync is less effective.
27
+
28
+
18
29
  ## Installation
19
30
  ```
20
31
  npm install @quenty/datastore --save
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/datastore",
3
- "version": "7.23.0",
3
+ "version": "7.23.1-canary.402.5852ffd.0",
4
4
  "description": "Quenty's Datastore implementation for Roblox",
5
5
  "keywords": [
6
6
  "Roblox",
@@ -26,18 +26,21 @@
26
26
  "Quenty"
27
27
  ],
28
28
  "dependencies": {
29
- "@quenty/baseobject": "^6.2.1",
30
- "@quenty/bindtocloseservice": "^2.18.0",
31
- "@quenty/loader": "^6.2.1",
32
- "@quenty/maid": "^2.5.0",
33
- "@quenty/promise": "^6.7.0",
34
- "@quenty/rx": "^7.14.0",
35
- "@quenty/signal": "^2.4.0",
36
- "@quenty/symbol": "^2.2.0",
37
- "@quenty/table": "^3.2.0"
29
+ "@quenty/baseobject": "6.2.2-canary.402.5852ffd.0",
30
+ "@quenty/bindtocloseservice": "2.18.1-canary.402.5852ffd.0",
31
+ "@quenty/loader": "6.2.2-canary.402.5852ffd.0",
32
+ "@quenty/maid": "2.5.0",
33
+ "@quenty/math": "2.4.1-canary.402.5852ffd.0",
34
+ "@quenty/promise": "6.7.1-canary.402.5852ffd.0",
35
+ "@quenty/rx": "7.14.1-canary.402.5852ffd.0",
36
+ "@quenty/servicebag": "6.8.1-canary.402.5852ffd.0",
37
+ "@quenty/signal": "2.4.0",
38
+ "@quenty/symbol": "2.2.0",
39
+ "@quenty/table": "3.2.1-canary.402.5852ffd.0",
40
+ "@quenty/valueobject": "7.21.1-canary.402.5852ffd.0"
38
41
  },
39
42
  "publishConfig": {
40
43
  "access": "public"
41
44
  },
42
- "gitHead": "839b4d929e4d6154aadf719f1ecfb9add4c8b247"
45
+ "gitHead": "5852ffdd4ee4f857a5b7086330d59d852de49b86"
43
46
  }
@@ -71,13 +71,14 @@ local DataStoreStage = require("DataStoreStage")
71
71
  local Maid = require("Maid")
72
72
  local Promise = require("Promise")
73
73
  local Signal = require("Signal")
74
+ local Math = require("Math")
75
+ local ValueObject = require("ValueObject")
76
+ local Rx = require("Rx")
74
77
 
75
- local DEBUG_WRITING = false
78
+ local DEFAULT_DEBUG_WRITING = false
76
79
 
77
- local AUTO_SAVE_TIME = 60*5
78
- local CHECK_DIVISION = 15
79
- local JITTER = 20 -- Randomly assign jitter so if a ton of players join at once we don't hit the datastore at once
80
- local DEFAULT_CACHE_TIME_SECONDS = math.huge
80
+ local DEFAULT_AUTO_SAVE_TIME_SECONDS = 60*5
81
+ local DEFAULT_JITTER_PROPORTION = 0.1 -- Randomly assign jitter so if a ton of players join at once we don't hit the datastore at once
81
82
 
82
83
  local DataStore = setmetatable({}, DataStoreStage)
83
84
  DataStore.ClassName = "DataStore"
@@ -85,16 +86,35 @@ DataStore.__index = DataStore
85
86
 
86
87
  --[=[
87
88
  Constructs a new DataStore. See [DataStoreStage] for more API.
89
+
90
+ ```lua
91
+ local dataStore = serviceBag:GetService(PlayerDataStoreService):PromiseDataStore(player):Yield()
92
+ ```
93
+
88
94
  @param robloxDataStore DataStore
89
95
  @param key string
90
96
  @return DataStore
91
97
  ]=]
92
98
  function DataStore.new(robloxDataStore, key)
93
- local self = setmetatable(DataStoreStage.new(), DataStore)
99
+ local self = setmetatable(DataStoreStage.new(key), DataStore)
94
100
 
95
101
  self._key = key or error("No key")
96
102
  self._robloxDataStore = robloxDataStore or error("No robloxDataStore")
97
- self._cacheTimeSeconds = DEFAULT_CACHE_TIME_SECONDS
103
+ self._debugWriting = DEFAULT_DEBUG_WRITING
104
+
105
+ self._autoSaveTimeSeconds = ValueObject.new(DEFAULT_AUTO_SAVE_TIME_SECONDS)
106
+ self._maid:GiveTask(self._autoSaveTimeSeconds)
107
+
108
+ self._jitterProportion = ValueObject.new(DEFAULT_JITTER_PROPORTION, "number")
109
+ self._maid:GiveTask(self._jitterProportion)
110
+
111
+ self._syncOnSave = ValueObject.new(false, "boolean")
112
+ self._maid:GiveTask(self._syncOnSave)
113
+
114
+ self._loadedOk = ValueObject.new(false, "boolean")
115
+ self._maid:GiveTask(self._loadedOk)
116
+
117
+ self._userIdList = nil
98
118
 
99
119
  if self._key == "" then
100
120
  error("[DataStore] - Key cannot be an empty string")
@@ -108,41 +128,20 @@ function DataStore.new(robloxDataStore, key)
108
128
  self.Saving = Signal.new() -- :Fire(promise)
109
129
  self._maid:GiveTask(self.Saving)
110
130
 
111
- task.spawn(function()
112
- while self.Destroy do
113
- for _=1, CHECK_DIVISION do
114
- task.wait(AUTO_SAVE_TIME/CHECK_DIVISION)
115
- if not self.Destroy then
116
- break
117
- end
118
- end
119
-
120
- if not self.Destroy then
121
- break
122
- end
123
-
124
- -- Apply additional jitter on auto-save
125
- task.wait(math.random(1, JITTER))
126
-
127
- if not self.Destroy then
128
- break
129
- end
130
-
131
- self:Save()
132
- end
133
- end)
131
+ self:_setupAutoSaving()
134
132
 
135
133
  return self
136
134
  end
137
135
 
138
136
  --[=[
139
- Sets how long the datastore will cache for
140
- @param cacheTimeSeconds number?
137
+ Set to true to debug writing this data store
138
+
139
+ @param debugWriting boolean
141
140
  ]=]
142
- function DataStore:SetCacheTime(cacheTimeSeconds)
143
- assert(type(cacheTimeSeconds) == "number" or cacheTimeSeconds == nil, "Bad cacheTimeSeconds")
141
+ function DataStore:SetDoDebugWriting(debugWriting)
142
+ assert(type(debugWriting) == "boolean", "Bad debugWriting")
144
143
 
145
- self._cacheTimeSeconds = cacheTimeSeconds or DEFAULT_CACHE_TIME_SECONDS
144
+ self._debugWriting = debugWriting
146
145
  end
147
146
 
148
147
  --[=[
@@ -153,16 +152,39 @@ function DataStore:GetFullPath()
153
152
  return ("RobloxDataStore@%s"):format(self._key)
154
153
  end
155
154
 
155
+ --[=[
156
+ How frequent the data store will autosave (or sync) to the cloud. If set to nil then the datastore
157
+ will not do any syncing.
158
+
159
+ @param autoSaveTimeSeconds number | nil
160
+ ]=]
161
+ function DataStore:SetAutoSaveTimeSeconds(autoSaveTimeSeconds)
162
+ assert(type(autoSaveTimeSeconds) == "number" or autoSaveTimeSeconds == nil, "Bad autoSaveTimeSeconds")
163
+
164
+ self._autoSaveTimeSeconds.Value = autoSaveTimeSeconds
165
+ end
166
+
167
+ --[=[
168
+ How frequent the data store will autosave (or sync) to the cloud
169
+
170
+ @param syncEnabled boolean
171
+ ]=]
172
+ function DataStore:SetSyncOnSave(syncEnabled)
173
+ assert(type(syncEnabled) == "boolean", "Bad syncEnabled")
174
+
175
+ self._syncOnSave.Value = syncEnabled
176
+ end
177
+
156
178
  --[=[
157
179
  Returns whether the datastore failed.
158
180
  @return boolean
159
181
  ]=]
160
182
  function DataStore:DidLoadFail()
161
- if not self._loadPromise then
183
+ if not self._firstLoadPromise then
162
184
  return false
163
185
  end
164
186
 
165
- if self._loadPromise:IsRejected() then
187
+ if self._firstLoadPromise:IsRejected() then
166
188
  return true
167
189
  end
168
190
 
@@ -175,7 +197,7 @@ end
175
197
  @return Promise<boolean>
176
198
  ]=]
177
199
  function DataStore:PromiseLoadSuccessful()
178
- return self._maid:GivePromise(self:_promiseLoad()):Then(function()
200
+ return self._maid:GivePromise(self:PromiseViewUpToDate()):Then(function()
179
201
  return true
180
202
  end, function()
181
203
  return false
@@ -187,67 +209,208 @@ end
187
209
  @return Promise
188
210
  ]=]
189
211
  function DataStore:Save()
190
- if self:DidLoadFail() then
191
- warn("[DataStore] - Not saving, failed to load")
192
- return Promise.rejected("Load not successful, not saving")
212
+ return self:_syncData(false)
213
+ end
214
+
215
+ --[=[
216
+ Same as saving the data but it also loads fresh data from the datastore, which may consume
217
+ additional data-store query calls.
218
+
219
+ @return Promise
220
+ ]=]
221
+ function DataStore:Sync()
222
+ return self:_syncData(true)
223
+ end
224
+
225
+ --[=[
226
+ Sets the user id list associated with this datastore. Can be useful for GDPR compliance.
227
+
228
+ @param userIdList { number } | nil
229
+ ]=]
230
+ function DataStore:SetUserIdList(userIdList)
231
+ assert(type(userIdList) == "table" or userIdList == nil, "Bad userIdList")
232
+
233
+ self._userIdList = userIdList
234
+ end
235
+
236
+ --[=[
237
+ Returns a list of user ids or nil
238
+
239
+ @return { number } | nil
240
+ ]=]
241
+ function DataStore:GetUserIdList()
242
+ return self._userIdList
243
+ end
244
+
245
+ --[=[
246
+ Overridden helper method for data store stage below.
247
+
248
+ @return Promise
249
+ ]=]
250
+ function DataStore:PromiseViewUpToDate()
251
+ if self._firstLoadPromise then
252
+ return self._firstLoadPromise
193
253
  end
194
254
 
195
- if DEBUG_WRITING then
196
- print("[DataStore.Save] - Starting save routine")
255
+ self._firstLoadPromise = self:_promiseGetAsyncNoCache()
256
+
257
+ self._firstLoadPromise:Tap(function()
258
+ self._loadedOk.Value = true
259
+ end)
260
+
261
+ return self._firstLoadPromise
262
+ end
263
+
264
+ function DataStore:_setupAutoSaving()
265
+ local startTime = os.clock()
266
+
267
+ self._maid:GiveTask(Rx.combineLatest({
268
+ autoSaveTimeSeconds = self._autoSaveTimeSeconds:Observe();
269
+ jitterProportion = self._jitterProportion:Observe();
270
+ syncOnSave = self._syncOnSave:Observe();
271
+ loadedOk = self._loadedOk:Observe();
272
+ }):Subscribe(function(state)
273
+ if state.autoSaveTimeSeconds and state.loadedOk then
274
+ local maid = Maid.new()
275
+ if self._debugWriting then
276
+ print("Auto-saving loop started")
277
+ end
278
+
279
+ -- TODO: First jitter is way noisier to differentiate servers
280
+ maid:GiveTask(task.spawn(function()
281
+ while true do
282
+ local jitterBase = math.random()
283
+ local timeElapsed = os.clock() - startTime
284
+ local totalWaitTime = Math.jitter(state.autoSaveTimeSeconds, state.jitterProportion*state.autoSaveTimeSeconds, jitterBase)
285
+ local timeRemaining = totalWaitTime - timeElapsed
286
+
287
+ if timeRemaining > 0 then
288
+ task.wait(timeRemaining)
289
+ end
290
+
291
+ startTime = os.clock()
292
+
293
+ if state.syncOnSave then
294
+ self:Sync()
295
+ else
296
+ self:Save()
297
+ end
298
+
299
+ task.wait(0.1)
300
+ end
301
+ end))
302
+
303
+ self._maid._autoSavingMaid = maid
304
+ else
305
+ self._maid._autoSavingMaid = nil
306
+ end
307
+ end))
308
+ end
309
+
310
+ function DataStore:_syncData(doMergeNewData)
311
+ if self:DidLoadFail() then
312
+ warn("[DataStore] - Not syncing, failed to load")
313
+ return Promise.rejected("Load not successful, not syncing")
197
314
  end
198
315
 
199
- -- Avoid constructing promises for every callback down the datastore
200
- -- upon save.
201
- return (self:_promiseInvokeSavingCallbacks() or Promise.resolved())
316
+ return self._maid:GivePromise(self:PromiseViewUpToDate())
317
+ :Then(function()
318
+ return self._maid:GivePromise(self:PromiseInvokeSavingCallbacks())
319
+ end)
202
320
  :Then(function()
203
321
  if not self:HasWritableData() then
322
+ if doMergeNewData then
323
+ -- Reads are cheaper than update async calls
324
+ return self:_promiseGetAsyncNoCache()
325
+ end
326
+
204
327
  -- Nothing to save, don't update anything
205
- if DEBUG_WRITING then
206
- print("[DataStore.Save] - Not saving, nothing staged")
328
+ if self._debugWriting then
329
+ print("[DataStore] - Not saving, nothing staged")
207
330
  end
331
+
208
332
  return nil
209
333
  else
210
- return self:_saveData(self:GetNewWriter())
334
+ return self:_doDataSync(self:GetNewWriter(), doMergeNewData)
211
335
  end
212
336
  end)
213
337
  end
214
338
 
215
- --[=[
216
- Loads data. This returns the originally loaded data.
217
- @param keyName string
218
- @param defaultValue any?
219
- @return any?
220
- ]=]
221
- function DataStore:Load(keyName, defaultValue)
222
- return self:_promiseLoad()
223
- :Then(function(data)
224
- return self:_afterLoadGetAndApplyStagedData(keyName, data, defaultValue)
225
- end)
226
- end
339
+ function DataStore:_doDataSync(writer, doMergeNewData)
340
+ assert(type(doMergeNewData) == "boolean", "Bad doMergeNewData")
341
+
342
+ -- Cache user id list
343
+ writer:SetUserIdList(self:GetUserIdList())
227
344
 
228
- function DataStore:_saveData(writer)
229
345
  local maid = Maid.new()
230
346
 
231
347
  local promise = Promise.new()
232
- promise:Resolve(maid:GivePromise(DataStorePromises.updateAsync(self._robloxDataStore, self._key, function(data)
233
- if promise:IsRejected() then
234
- -- Cancel if we have another request
235
- return nil
348
+
349
+ if writer:IsCompleteWipe() then
350
+ if self._debugWriting then
351
+ print(string.format("[DataStore] - DataStorePromises.removeAsync(%q)", self._key))
236
352
  end
237
353
 
238
- data = writer:WriteMerge(data or {})
239
- assert(data ~= DataStoreDeleteToken, "Cannot delete from UpdateAsync")
354
+ -- This is, of course, dangerous, because we won't merge
355
+ promise:Resolve(maid:GivePromise(DataStorePromises.removeAsync(self._robloxDataStore, self._key)):Then(function()
356
+ if doMergeNewData then
357
+ -- Write our data
358
+ self:MarkDataAsSaved(writer)
240
359
 
241
- if DEBUG_WRITING then
242
- print("[DataStore] - Writing", game:GetService("HttpService"):JSONEncode(data))
360
+ -- Do syncing after
361
+ return self:_promiseGetAsyncNoCache()
362
+ end
363
+ end))
364
+ else
365
+ if self._debugWriting then
366
+ print(string.format("[DataStore] - DataStorePromises.updateAsync(%q)", self._key))
243
367
  end
244
368
 
245
- return data
246
- end, function(err)
369
+ promise:Resolve(maid:GivePromise(DataStorePromises.updateAsync(self._robloxDataStore, self._key, function(original, datastoreKeyInfo)
370
+ if promise:IsRejected() then
371
+ -- Cancel if we have another request
372
+ return nil
373
+ end
374
+
375
+ local diffSnapshot
376
+ if doMergeNewData then
377
+ diffSnapshot = writer:ComputeDiffSnapshot(original)
378
+ end
379
+
380
+ local result = writer:WriteMerge(original)
381
+
382
+ if result == DataStoreDeleteToken or result == nil then
383
+ result = {}
384
+ end
385
+
386
+ if self._debugWriting then
387
+ print("[DataStore] - Writing", result)
388
+ end
389
+
390
+ if doMergeNewData then
391
+ -- This prevents resaving at high frequency
392
+ self:MarkDataAsSaved(writer)
393
+ self:MergeDiffSnapshot(diffSnapshot)
394
+ end
395
+
396
+ local userIdList = writer:GetUserIdList()
397
+ if datastoreKeyInfo then
398
+ userIdList = datastoreKeyInfo:GetUserIds()
399
+ end
400
+
401
+ local metadata = nil
402
+ if datastoreKeyInfo then
403
+ metadata = datastoreKeyInfo:GetMetadata()
404
+ end
405
+
406
+ return result, userIdList, metadata
407
+ end)))
408
+ end
409
+
410
+ promise:Tap(nil, function(err)
247
411
  -- Might be caused by Maid rejecting state
248
- warn("[DataStore] - Failed to UpdateAsync data", err)
249
- return Promise.rejected(err)
250
- end)))
412
+ warn("[DataStore] - Failed to sync data", err)
413
+ end)
251
414
 
252
415
  self._maid._saveMaid = maid
253
416
 
@@ -258,27 +421,23 @@ function DataStore:_saveData(writer)
258
421
  return promise
259
422
  end
260
423
 
261
- function DataStore:_promiseLoad()
262
- if self._loadPromise then
263
- return self._loadPromise
264
- end
265
-
266
- self._loadPromise = self._maid:GivePromise(DataStorePromises.getAsync(self._robloxDataStore, self._key)
267
- :Then(function(data)
268
- if data == nil then
269
- return {}
270
- elseif type(data) == "table" then
271
- return data
272
- else
273
- return Promise.rejected("Failed to load data. Wrong type '" .. type(data) .. "'")
274
- end
275
- end, function(err)
276
- -- Log:
277
- warn("[DataStore] - Failed to GetAsync data", err)
424
+ function DataStore:_promiseGetAsyncNoCache()
425
+ return self._maid:GivePromise(DataStorePromises.getAsync(self._robloxDataStore, self._key))
426
+ :Catch(function(err)
427
+ warn(string.format("DataStorePromises.getAsync(%q) -> warning - ", self._key), err)
278
428
  return Promise.rejected(err)
279
- end))
429
+ end)
430
+ :Then(function(data)
431
+ local writer = self:GetNewWriter()
432
+ local diffSnapshot = writer:ComputeDiffSnapshot(data)
280
433
 
281
- return self._loadPromise
434
+ self:MergeDiffSnapshot(diffSnapshot)
435
+
436
+ if self._debugWriting then
437
+ print(string.format("DataStorePromises.getAsync(%q) -> Got ", self._key), data, "with diff snapshot", diffSnapshot, "to view", self._viewSnapshot)
438
+ -- print(string.format("DataStorePromises.getAsync(%q) -> Got ", self._key), data)
439
+ end
440
+ end)
282
441
  end
283
442
 
284
443
  return DataStore
@@ -10,6 +10,7 @@ local require = require(script.Parent.loader).load(script)
10
10
  local DataStore = require("DataStore")
11
11
  local DataStorePromises = require("DataStorePromises")
12
12
  local Maid = require("Maid")
13
+ local Promise = require("Promise")
13
14
 
14
15
  local GameDataStoreService = {}
15
16
  GameDataStoreService.ServiceName = "GameDataStoreService"
@@ -30,11 +31,16 @@ function GameDataStoreService:PromiseDataStore()
30
31
 
31
32
  self._dataStorePromise = self:_promiseRobloxDataStore()
32
33
  :Then(function(robloxDataStore)
34
+ -- Live sync this stuff pretty frequently
33
35
  local dataStore = DataStore.new(robloxDataStore, self:_getKey())
36
+ dataStore:SetSyncOnSave(true)
37
+ dataStore:SetAutoSaveTimeSeconds(15)
34
38
  self._maid:GiveTask(dataStore)
35
39
 
36
40
  self._maid:GiveTask(self._bindToCloseService:RegisterPromiseOnCloseCallback(function()
37
- return dataStore:Save()
41
+ return Promise.defer(function(resolve)
42
+ return resolve(dataStore:Save())
43
+ end)
38
44
  end))
39
45
 
40
46
  return dataStore
@@ -0,0 +1,13 @@
1
+ --[=[
2
+ @class DataStoreSnapshotUtils
3
+ ]=]
4
+
5
+ local require = require(script.Parent.loader).load(script)
6
+
7
+ local DataStoreSnapshotUtils = {}
8
+
9
+ function DataStoreSnapshotUtils.isEmptySnapshot(snapshot)
10
+ return type(snapshot) == "table" and next(snapshot) == nil
11
+ end
12
+
13
+ return DataStoreSnapshotUtils