@quenty/datastore 13.22.1 → 13.23.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,17 @@
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.23.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.22.1...@quenty/datastore@13.23.0) (2025-08-29)
7
+
8
+
9
+ ### Features
10
+
11
+ * Add datastore session locking system ([#580](https://github.com/Quenty/NevermoreEngine/issues/580)) ([06ecef6](https://github.com/Quenty/NevermoreEngine/commit/06ecef60eab81ac0b44d9e408313fb4cc4d59488))
12
+
13
+
14
+
15
+
16
+
6
17
  ## [13.22.1](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.22.0...@quenty/datastore@13.22.1) (2025-08-12)
7
18
 
8
19
  **Note:** Version bump only for package @quenty/datastore
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/datastore",
3
- "version": "13.22.1",
3
+ "version": "13.23.0",
4
4
  "description": "Quenty's Datastore implementation for Roblox",
5
5
  "keywords": [
6
6
  "Roblox",
@@ -27,21 +27,22 @@
27
27
  ],
28
28
  "dependencies": {
29
29
  "@quenty/baseobject": "^10.9.0",
30
- "@quenty/bindtocloseservice": "^8.18.1",
30
+ "@quenty/bindtocloseservice": "^8.19.0",
31
31
  "@quenty/loader": "^10.9.0",
32
32
  "@quenty/maid": "^3.5.0",
33
33
  "@quenty/math": "^2.7.3",
34
- "@quenty/pagesutils": "^5.12.0",
35
- "@quenty/promise": "^10.11.0",
36
- "@quenty/rx": "^13.18.1",
34
+ "@quenty/pagesutils": "^5.13.0",
35
+ "@quenty/promise": "^10.12.0",
36
+ "@quenty/promisemaid": "^5.12.0",
37
+ "@quenty/rx": "^13.19.0",
37
38
  "@quenty/servicebag": "^11.13.1",
38
39
  "@quenty/signal": "^7.11.1",
39
40
  "@quenty/symbol": "^3.5.0",
40
41
  "@quenty/table": "^3.8.0",
41
- "@quenty/valueobject": "^13.18.1"
42
+ "@quenty/valueobject": "^13.19.0"
42
43
  },
43
44
  "publishConfig": {
44
45
  "access": "public"
45
46
  },
46
- "gitHead": "847e9e65e518392c1e2f30669ee2589ed3d7e870"
47
+ "gitHead": "5f62fff9bdc4089be64a8380e8deafa77647c85a"
47
48
  }
@@ -66,12 +66,16 @@
66
66
 
67
67
  local require = require(script.Parent.loader).load(script)
68
68
 
69
+ local RunService = game:GetService("RunService")
70
+
69
71
  local DataStoreDeleteToken = require("DataStoreDeleteToken")
70
72
  local DataStorePromises = require("DataStorePromises")
71
73
  local DataStoreStage = require("DataStoreStage")
72
74
  local Maid = require("Maid")
73
75
  local Math = require("Math")
74
76
  local Promise = require("Promise")
77
+ local PromiseMaidUtils = require("PromiseMaidUtils")
78
+ local PromiseRetryUtils = require("PromiseRetryUtils")
75
79
  local Rx = require("Rx")
76
80
  local Signal = require("Signal")
77
81
  local Symbol = require("Symbol")
@@ -81,6 +85,7 @@ local DEFAULT_DEBUG_WRITING = false
81
85
 
82
86
  local DEFAULT_AUTO_SAVE_TIME_SECONDS = 60 * 5
83
87
  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
88
+ local UNLOCK_BY_DEFAULT_TIME_MULTIPLIER = 2.1
84
89
 
85
90
  local DataStore = setmetatable({}, DataStoreStage)
86
91
  DataStore.ClassName = "DataStore"
@@ -92,11 +97,13 @@ export type DataStore = typeof(setmetatable(
92
97
  _userIdList: { number }?,
93
98
  _robloxDataStore: DataStorePromises.RobloxDataStore,
94
99
  _debugWriting: boolean,
100
+ _sessionLockingEnabled: boolean,
95
101
  _autoSaveTimeSeconds: ValueObject.ValueObject<number?>,
96
102
  _jitterProportion: ValueObject.ValueObject<number>,
97
103
  _syncOnSave: ValueObject.ValueObject<boolean>,
98
104
  _loadedOk: ValueObject.ValueObject<boolean>,
99
105
  _firstLoadPromise: Promise.Promise<()>,
106
+ _promiseSessionLockingFailed: Promise.Promise<()>,
100
107
  Saving: Signal.Signal<Promise.Promise<()>>,
101
108
  },
102
109
  {} :: typeof({ __index = DataStore })
@@ -124,6 +131,7 @@ function DataStore.new(robloxDataStore: DataStorePromises.RobloxDataStore, key:
124
131
  self._jitterProportion = self._maid:Add(ValueObject.new(DEFAULT_JITTER_PROPORTION, "number"))
125
132
  self._syncOnSave = self._maid:Add(ValueObject.new(false, "boolean"))
126
133
  self._loadedOk = self._maid:Add(ValueObject.new(false, "boolean"))
134
+ self._promiseSessionLockingFailed = self._maid:Add(Promise.new())
127
135
 
128
136
  self._userIdList = nil
129
137
 
@@ -143,6 +151,27 @@ function DataStore.new(robloxDataStore: DataStorePromises.RobloxDataStore, key:
143
151
  return self
144
152
  end
145
153
 
154
+ --[=[
155
+ Sets session locking enabled
156
+
157
+ @param sessionLockingEnabled boolean
158
+ ]=]
159
+ function DataStore.SetSessionLockingEnabled(self: DataStore, sessionLockingEnabled: boolean)
160
+ assert(not self._firstLoadPromise, "Must set session locking before datastore is loaded")
161
+
162
+ self._sessionLockingEnabled = sessionLockingEnabled
163
+ end
164
+
165
+ --[=[
166
+ Returns a promise that rejects on datastore cleanup, and resolves only when the session locking
167
+ code completely fails
168
+
169
+ @return Promise<>
170
+ ]=]
171
+ function DataStore.PromiseSessionLockingFailed(self: DataStore)
172
+ return self._promiseSessionLockingFailed
173
+ end
174
+
146
175
  --[=[
147
176
  Set to true to debug writing this data store
148
177
 
@@ -222,6 +251,16 @@ function DataStore.Save(self: DataStore): Promise.Promise<()>
222
251
  return self:_syncData(false)
223
252
  end
224
253
 
254
+ --[=[
255
+ Saves all stored data.
256
+ @return Promise
257
+ ]=]
258
+ function DataStore.SaveAndCloseSession(self: DataStore): Promise.Promise<()>
259
+ assert(self._sessionLockingEnabled, "Cannot invoke unless session locking is enabled")
260
+
261
+ return self:_syncData(false, true)
262
+ end
263
+
225
264
  --[=[
226
265
  Same as saving the data but it also loads fresh data from the datastore, which may consume
227
266
  additional data-store query calls.
@@ -323,7 +362,7 @@ function DataStore._setupAutoSaving(self: DataStore)
323
362
  end))
324
363
  end
325
364
 
326
- function DataStore._syncData(self: DataStore, doMergeNewData: boolean)
365
+ function DataStore._syncData(self: DataStore, doMergeNewData: boolean, doCloseSession: boolean?)
327
366
  if self:DidLoadFail() then
328
367
  warn("[DataStore] - Not syncing, failed to load")
329
368
  return Promise.rejected("Load not successful, not syncing")
@@ -335,7 +374,7 @@ function DataStore._syncData(self: DataStore, doMergeNewData: boolean)
335
374
  return self._maid:GivePromise(self:PromiseInvokeSavingCallbacks())
336
375
  end)
337
376
  :Then(function(): Promise.Promise<()>?
338
- if not self:HasWritableData() then
377
+ if not self:HasWritableData() and not doCloseSession then
339
378
  if doMergeNewData then
340
379
  -- Reads are cheaper than update async calls
341
380
  return self:_promiseGetAsyncNoCache()
@@ -348,12 +387,17 @@ function DataStore._syncData(self: DataStore, doMergeNewData: boolean)
348
387
 
349
388
  return nil
350
389
  else
351
- return self:_doDataSync(self:GetNewWriter(), doMergeNewData)
390
+ return self:_doDataSync(self:GetNewWriter(), doMergeNewData, doCloseSession)
352
391
  end
353
392
  end)
354
393
  end
355
394
 
356
- function DataStore._doDataSync(self: DataStore, writer, doMergeNewData: boolean): Promise.Promise<()>
395
+ function DataStore._doDataSync(
396
+ self: DataStore,
397
+ writer,
398
+ doMergeNewData: boolean,
399
+ doCloseSession: boolean?
400
+ ): Promise.Promise<()>
357
401
  assert(type(doMergeNewData) == "boolean", "Bad doMergeNewData")
358
402
 
359
403
  -- Cache user id list
@@ -396,6 +440,11 @@ function DataStore._doDataSync(self: DataStore, writer, doMergeNewData: boolean)
396
440
  promise:Resolve(
397
441
  maid:GivePromise(
398
442
  DataStorePromises.updateAsync(self._robloxDataStore, self._key, function(original, datastoreKeyInfo)
443
+ if self._sessionLockingEnabled then
444
+ original = table.clone(original)
445
+ original.lock = nil
446
+ end
447
+
399
448
  if promise:IsRejected() then
400
449
  -- Cancel if we have another request
401
450
  return nil
@@ -434,6 +483,17 @@ function DataStore._doDataSync(self: DataStore, writer, doMergeNewData: boolean)
434
483
  metadata = datastoreKeyInfo:GetMetadata()
435
484
  end
436
485
 
486
+ if doCloseSession then
487
+ result = table.clone(result)
488
+ result.lock = nil
489
+ elseif self._sessionLockingEnabled then
490
+ -- Maintain the lock with the latest time
491
+ result = table.clone(result)
492
+ if result.lock then
493
+ result.lock = os.time()
494
+ end
495
+ end
496
+
437
497
  return result, userIdList, metadata
438
498
  end)
439
499
  )
@@ -455,36 +515,128 @@ function DataStore._doDataSync(self: DataStore, writer, doMergeNewData: boolean)
455
515
  end
456
516
 
457
517
  function DataStore._promiseGetAsyncNoCache(self: DataStore): Promise.Promise<()>
458
- return self._maid
459
- :GivePromise(DataStorePromises.getAsync(self._robloxDataStore, self._key))
460
- :Catch(function(err)
461
- warn(
462
- string.format(
463
- "DataStorePromises.getAsync(%q) -> warning - %s",
464
- tostring(self._key),
465
- tostring(err or "empty error")
518
+ local promise
519
+ if self._sessionLockingEnabled then
520
+ local function promiseLoadUnlockedProfile()
521
+ local loadPromise = Promise.new()
522
+ self._maid[loadPromise] = loadPromise
523
+
524
+ PromiseMaidUtils.whilePromise(loadPromise, function(maid)
525
+ maid:GivePromise(
526
+ DataStorePromises.updateAsync(self._robloxDataStore, self._key, function(data, datastoreKeyInfo)
527
+ local userIdList = self._userIdList
528
+ if datastoreKeyInfo then
529
+ userIdList = datastoreKeyInfo:GetUserIds()
530
+ end
531
+
532
+ local metadata = nil
533
+ if datastoreKeyInfo then
534
+ metadata = datastoreKeyInfo:GetMetadata()
535
+ end
536
+
537
+ if self._debugWriting then
538
+ print(string.format("DataStorePromises.updateAsync(%q) -> Got ", tostring(self._key)), data)
539
+ end
540
+
541
+ if data.lock then
542
+ local isInvalidLock = false
543
+ if type(data.lock) == "number" then
544
+ local timeElapsed = os.time() - data.lock
545
+ local autoSaveSeconds = self._autoSaveTimeSeconds.Value
546
+ if
547
+ autoSaveSeconds
548
+ and timeElapsed > (autoSaveSeconds * UNLOCK_BY_DEFAULT_TIME_MULTIPLIER)
549
+ then
550
+ isInvalidLock = true
551
+ end
552
+ end
553
+
554
+ -- Allow data locked to load in studio because otherwise testing gets really messy
555
+ if RunService:IsStudio() then
556
+ isInvalidLock = true
557
+ end
558
+
559
+ if not isInvalidLock then
560
+ loadPromise:Reject(string.format("Profile is locked (%s)", tostring(data.lock)))
561
+
562
+ -- Cancel write to avoid maintaining lock
563
+ return nil
564
+ end
565
+
566
+ -- Be sure to cleanup stuff
567
+ data.lock = nil :: number?
568
+ end
569
+
570
+ -- TODO: Retry
571
+ loadPromise:Resolve(data)
572
+
573
+ data.lock = os.time()
574
+
575
+ return data, userIdList, metadata
576
+ end)
466
577
  )
467
- )
468
- return Promise.rejected(err)
469
- end)
470
- :Then(function(data)
471
- local writer = self:GetNewWriter()
472
- local diffSnapshot = writer:ComputeDiffSnapshot(data)
578
+ end)
473
579
 
474
- self:MergeDiffSnapshot(diffSnapshot)
580
+ loadPromise:Finally(function()
581
+ self._maid[loadPromise] = nil
582
+ end)
475
583
 
476
- if self._debugWriting then
477
- print(
478
- string.format("DataStorePromises.getAsync(%q) -> Got ", tostring(self._key)),
479
- data,
480
- "with diff snapshot",
481
- diffSnapshot,
482
- "to view",
483
- self._viewSnapshot
584
+ return loadPromise
585
+ end
586
+
587
+ promise = self._maid
588
+ :Add(PromiseRetryUtils.retry(promiseLoadUnlockedProfile, {
589
+ -- https://exponentialbackoffcalculator.com/
590
+ -- ~10 minutes
591
+ exponential = 1.5,
592
+ initialWaitTime = 1,
593
+ maxAttempts = 15,
594
+ printWarning = true,
595
+ }))
596
+ :Catch(function(err)
597
+ warn(
598
+ string.format(
599
+ "DataStorePromises.updateAsync(%q) -> warning - %s",
600
+ tostring(self._key),
601
+ tostring(err or "empty error")
602
+ )
484
603
  )
485
- -- print(string.format("DataStorePromises.getAsync(%q) -> Got ", self._key), data)
486
- end
487
- end)
604
+ self._promiseSessionLockingFailed:Resolve()
605
+ return Promise.rejected(err)
606
+ end)
607
+ else
608
+ promise = self._maid
609
+ :GivePromise(DataStorePromises.getAsync(self._robloxDataStore, self._key))
610
+ :Catch(function(err)
611
+ warn(
612
+ string.format(
613
+ "DataStorePromises.getAsync(%q) -> warning - %s",
614
+ tostring(self._key),
615
+ tostring(err or "empty error")
616
+ )
617
+ )
618
+ return Promise.rejected(err)
619
+ end)
620
+ end
621
+
622
+ return promise:Then(function(data)
623
+ local writer = self:GetNewWriter()
624
+ local diffSnapshot = writer:ComputeDiffSnapshot(data)
625
+
626
+ self:MergeDiffSnapshot(diffSnapshot)
627
+
628
+ if self._debugWriting then
629
+ print(
630
+ string.format("DataStorePromises.getAsync(%q) -> Got ", tostring(self._key)),
631
+ data,
632
+ "with diff snapshot",
633
+ diffSnapshot,
634
+ "to view",
635
+ self._viewSnapshot
636
+ )
637
+ -- print(string.format("DataStorePromises.getAsync(%q) -> Got ", self._key), data)
638
+ end
639
+ end)
488
640
  end
489
641
 
490
642
  return DataStore
@@ -131,7 +131,7 @@ end
131
131
  For if you want to disable saving in studio for faster close time!
132
132
  ]=]
133
133
  function PlayerDataStoreManager.DisableSaveOnCloseStudio(self: PlayerDataStoreManager): ()
134
- assert(RunService:IsStudio())
134
+ assert(RunService:IsStudio(), "Must invoke in studio")
135
135
 
136
136
  self._disableSavingInStudio = true
137
137
  end
@@ -196,8 +196,13 @@ function PlayerDataStoreManager._createDataStore(self: PlayerDataStoreManager, p
196
196
  assert(not self._datastores[player], "Bad player")
197
197
 
198
198
  local datastore = DataStore.new(self._robloxDataStore, self:_getKey(player))
199
+ datastore:SetSessionLockingEnabled(true)
199
200
  datastore:SetUserIdList({ player.UserId })
200
201
 
202
+ datastore:PromiseSessionLockingFailed():Then(function()
203
+ player:Kick("DataStore session lock failed to load. Please message developers.")
204
+ end)
205
+
201
206
  self._maid._savingConns[player] = datastore.Saving:Connect(function(promise)
202
207
  self._pendingSaves:Add(promise)
203
208
  end)
@@ -228,7 +233,7 @@ function PlayerDataStoreManager._removePlayerDataStore(self: PlayerDataStoreMana
228
233
 
229
234
  PromiseUtils.all(removingPromises)
230
235
  :Then(function()
231
- return datastore:Save()
236
+ return datastore:SaveAndCloseSession()
232
237
  end)
233
238
  :Finally(function()
234
239
  datastore:Destroy()