@quenty/datastore 13.28.1 → 13.28.2

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,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.28.2](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.28.1...@quenty/datastore@13.28.2) (2026-01-12)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * Fix dependency installation process for pnpm ([9651b09](https://github.com/Quenty/NevermoreEngine/commit/9651b09af34f252ef3f9c7c539793cc878dd8cba))
12
+ * Update DataStore to handle locking on non-locked items a bit better ([572bd59](https://github.com/Quenty/NevermoreEngine/commit/572bd59b4946fa8c1a2413a50c5402ca32e57b33))
13
+
14
+
15
+
16
+
17
+
6
18
  ## [13.28.1](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.28.0...@quenty/datastore@13.28.1) (2026-01-10)
7
19
 
8
20
  **Note:** Version bump only for package @quenty/datastore
@@ -1,6 +1,13 @@
1
1
  {
2
2
  "name": "datastore",
3
- "globIgnorePaths": [ "**/.package-lock.json" ],
3
+ "globIgnorePaths": [
4
+ "**/.package-lock.json",
5
+ "**/.pnpm",
6
+ "**/.pnpm-workspace-state-v1.json",
7
+ "**/.modules.yaml",
8
+ "**/.ignored",
9
+ "**/.ignored_*"
10
+ ],
4
11
  "tree": {
5
12
  "$path": "src"
6
13
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/datastore",
3
- "version": "13.28.1",
3
+ "version": "13.28.2",
4
4
  "description": "Quenty's Datastore implementation for Roblox",
5
5
  "keywords": [
6
6
  "Roblox",
@@ -29,23 +29,23 @@
29
29
  "Quenty"
30
30
  ],
31
31
  "dependencies": {
32
- "@quenty/baseobject": "^10.9.2",
33
- "@quenty/bindtocloseservice": "^8.23.1",
34
- "@quenty/loader": "^10.9.2",
35
- "@quenty/maid": "^3.5.2",
36
- "@quenty/math": "^2.7.4",
37
- "@quenty/pagesutils": "^5.13.5",
38
- "@quenty/promise": "^10.12.5",
39
- "@quenty/promisemaid": "^5.12.5",
40
- "@quenty/rx": "^13.21.1",
41
- "@quenty/servicebag": "^11.13.5",
42
- "@quenty/signal": "^7.11.4",
43
- "@quenty/symbol": "^3.5.1",
44
- "@quenty/table": "^3.9.1",
45
- "@quenty/valueobject": "^13.23.1"
32
+ "@quenty/baseobject": "10.9.3",
33
+ "@quenty/bindtocloseservice": "8.23.2",
34
+ "@quenty/loader": "10.9.3",
35
+ "@quenty/maid": "3.5.3",
36
+ "@quenty/math": "2.7.5",
37
+ "@quenty/pagesutils": "5.13.6",
38
+ "@quenty/promise": "10.12.6",
39
+ "@quenty/promisemaid": "5.12.6",
40
+ "@quenty/rx": "13.21.2",
41
+ "@quenty/servicebag": "11.13.6",
42
+ "@quenty/signal": "7.11.5",
43
+ "@quenty/symbol": "3.5.2",
44
+ "@quenty/table": "3.9.2",
45
+ "@quenty/valueobject": "13.23.2"
46
46
  },
47
47
  "publishConfig": {
48
48
  "access": "public"
49
49
  },
50
- "gitHead": "76f1dfb1936388838b2fd734e263a6af2ae9277b"
50
+ "gitHead": "55f4a7407de7247aceea574de4d2f57dab70cb91"
51
51
  }
@@ -66,9 +66,8 @@
66
66
 
67
67
  local require = require(script.Parent.loader).load(script)
68
68
 
69
- local RunService = game:GetService("RunService")
70
-
71
69
  local DataStoreDeleteToken = require("DataStoreDeleteToken")
70
+ local DataStoreLockHelper = require("DataStoreLockHelper")
72
71
  local DataStorePromises = require("DataStorePromises")
73
72
  local DataStoreStage = require("DataStoreStage")
74
73
  local Maid = require("Maid")
@@ -85,7 +84,6 @@ local DEFAULT_DEBUG_WRITING = false
85
84
 
86
85
  local DEFAULT_AUTO_SAVE_TIME_SECONDS = 60 * 5
87
86
  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
89
87
 
90
88
  local DataStore = setmetatable({}, DataStoreStage)
91
89
  DataStore.ClassName = "DataStore"
@@ -98,7 +96,7 @@ export type DataStore =
98
96
  _userIdList: { number }?,
99
97
  _robloxDataStore: DataStorePromises.RobloxDataStore,
100
98
  _debugWriting: boolean,
101
- _sessionLockingEnabled: boolean,
99
+ _sessionLockingEnabledHelper: DataStoreLockHelper.DataStoreLockHelper?,
102
100
  _autoSaveTimeSeconds: ValueObject.ValueObject<number?>,
103
101
  _jitterProportion: ValueObject.ValueObject<number>,
104
102
  _syncOnSave: ValueObject.ValueObject<boolean>,
@@ -161,7 +159,15 @@ end
161
159
  function DataStore.SetSessionLockingEnabled(self: DataStore, sessionLockingEnabled: boolean)
162
160
  assert(not self._firstLoadPromise, "Must set session locking before datastore is loaded")
163
161
 
164
- self._sessionLockingEnabled = sessionLockingEnabled
162
+ if sessionLockingEnabled then
163
+ if not self._sessionLockingEnabledHelper then
164
+ self._sessionLockingEnabledHelper = DataStoreLockHelper.new(self)
165
+ self._maid._sessionLockingEnabledHelper = self._sessionLockingEnabledHelper
166
+ end
167
+ else
168
+ self._sessionLockingEnabledHelper = nil
169
+ self._maid._sessionLockingEnabledHelper = nil
170
+ end
165
171
  end
166
172
 
167
173
  --[=[
@@ -205,6 +211,13 @@ function DataStore.SetAutoSaveTimeSeconds(self: DataStore, autoSaveTimeSeconds:
205
211
  self._autoSaveTimeSeconds.Value = autoSaveTimeSeconds
206
212
  end
207
213
 
214
+ --[=[
215
+ Returns how frequent the data store will autosave (or sync) to the cloud.
216
+ ]=]
217
+ function DataStore.GetAutoSaveTimeSeconds(self: DataStore): number?
218
+ return self._autoSaveTimeSeconds.Value
219
+ end
220
+
208
221
  --[=[
209
222
  How frequent the data store will autosave (or sync) to the cloud
210
223
 
@@ -258,7 +271,7 @@ end
258
271
  @return Promise
259
272
  ]=]
260
273
  function DataStore.SaveAndCloseSession(self: DataStore): Promise.Promise<()>
261
- assert(self._sessionLockingEnabled, "Cannot invoke unless session locking is enabled")
274
+ assert(self._sessionLockingEnabledHelper, "Cannot invoke unless session locking is enabled")
262
275
 
263
276
  return self:_syncData(false, true)
264
277
  end
@@ -442,9 +455,8 @@ function DataStore._doDataSync(
442
455
  promise:Resolve(
443
456
  maid:GivePromise(
444
457
  DataStorePromises.updateAsync(self._robloxDataStore, self._key, function(original, datastoreKeyInfo)
445
- if self._sessionLockingEnabled then
446
- original = table.clone(original)
447
- original.lock = nil
458
+ if self._sessionLockingEnabledHelper then
459
+ original = self._sessionLockingEnabledHelper:ToUnlockedProfile(original)
448
460
  end
449
461
 
450
462
  if promise:IsRejected() then
@@ -485,15 +497,8 @@ function DataStore._doDataSync(
485
497
  metadata = datastoreKeyInfo:GetMetadata()
486
498
  end
487
499
 
488
- if doCloseSession then
489
- result = table.clone(result)
490
- result.lock = nil
491
- elseif self._sessionLockingEnabled then
492
- -- Maintain the lock with the latest time
493
- result = table.clone(result)
494
- if result.lock then
495
- result.lock = os.time()
496
- end
500
+ if self._sessionLockingEnabledHelper then
501
+ result = self._sessionLockingEnabledHelper:ToLockedProfile(result, doCloseSession)
497
502
  end
498
503
 
499
504
  return result, userIdList, metadata
@@ -518,7 +523,7 @@ end
518
523
 
519
524
  function DataStore._promiseGetAsyncNoCache(self: DataStore): Promise.Promise<()>
520
525
  local promise
521
- if self._sessionLockingEnabled then
526
+ if self._sessionLockingEnabledHelper then
522
527
  local function promiseLoadUnlockedProfile()
523
528
  local loadPromise = Promise.new()
524
529
  self._maid[loadPromise] = loadPromise
@@ -540,48 +545,17 @@ function DataStore._promiseGetAsyncNoCache(self: DataStore): Promise.Promise<()>
540
545
  print(string.format("DataStorePromises.updateAsync(%q) -> Got ", tostring(self._key)), data)
541
546
  end
542
547
 
543
- if type(data) == "table" and data.lock then
544
- local isInvalidLock = false
545
- if type(data.lock) == "number" then
546
- local timeElapsed = os.time() - data.lock
547
- local autoSaveSeconds = self._autoSaveTimeSeconds.Value
548
- if
549
- autoSaveSeconds
550
- and timeElapsed > (autoSaveSeconds * UNLOCK_BY_DEFAULT_TIME_MULTIPLIER)
551
- then
552
- isInvalidLock = true
553
- end
554
- end
555
-
556
- -- Allow data locked to load in studio because otherwise testing gets really messy
557
- if RunService:IsStudio() then
558
- isInvalidLock = true
559
- end
560
-
561
- if not isInvalidLock then
562
- loadPromise:Reject(string.format("Profile is locked (%s)", tostring(data.lock)))
563
-
564
- -- Cancel write to avoid maintaining lock
565
- return nil
566
- end
567
-
568
- -- Be sure to cleanup stuff
569
- data.lock = nil :: number?
570
- end
571
-
572
- -- TODO: Retry
573
- loadPromise:Resolve(data)
548
+ local lockResult = self._sessionLockingEnabledHelper:AcquireLock(data)
549
+ if not lockResult.isValid then
550
+ loadPromise:Reject(string.format("Profile is locked (%s)", tostring(data.lock)))
574
551
 
575
- if data == nil then
576
- data = {}
577
- elseif type(data) ~= "table" then
578
- warn("[DataStore] - Data session locking is not available for non-table entries")
579
- return data, userIdList, metadata
552
+ -- Cancel write to avoid maintaining lock
553
+ return nil
580
554
  end
581
555
 
582
- data.lock = os.time()
556
+ loadPromise:Resolve(lockResult.unlockedProfile)
583
557
 
584
- return data, userIdList, metadata
558
+ return lockResult.lockedProfile, userIdList, metadata
585
559
  end)
586
560
  )
587
561
  end)
@@ -596,10 +570,10 @@ function DataStore._promiseGetAsyncNoCache(self: DataStore): Promise.Promise<()>
596
570
  promise = self._maid
597
571
  :Add(PromiseRetryUtils.retry(promiseLoadUnlockedProfile, {
598
572
  -- https://exponentialbackoffcalculator.com/
599
- -- ~10 minutes
600
- exponential = 1.5,
601
- initialWaitTime = 1,
602
- maxAttempts = 15,
573
+ -- 56.294 seconds
574
+ exponential = 1.25,
575
+ initialWaitTime = 5,
576
+ maxAttempts = 7,
603
577
  printWarning = true,
604
578
  }))
605
579
  :Catch(function(err)
@@ -0,0 +1,123 @@
1
+ --!strict
2
+ --[=[
3
+ @class DataStoreLockHelper
4
+ ]=]
5
+
6
+ local HttpService = game:GetService("HttpService")
7
+ local RunService = game:GetService("RunService")
8
+
9
+ local DataStoreLockHelper = {}
10
+ DataStoreLockHelper.ClassName = "DataStoreLockHelper"
11
+ DataStoreLockHelper.__index = DataStoreLockHelper
12
+
13
+ local ALWAYS_STEAL_LOCKS_IN_STUDIO = false
14
+ local UNLOCK_BY_DEFAULT_TIME_MULTIPLIER = 2.1
15
+
16
+ export type DataStoreLockHelper = typeof(setmetatable(
17
+ {} :: {
18
+ _sessionId: string?,
19
+ _dataStore: any,
20
+ },
21
+ {} :: typeof({ __index = DataStoreLockHelper })
22
+ ))
23
+
24
+ export type LockData = number
25
+ export type ValidLockResult = {
26
+ isValid: true,
27
+ unlockedProfile: any,
28
+ lockedProfile: any,
29
+ }
30
+
31
+ export type InvalidLockResult = {
32
+ isValid: false,
33
+ }
34
+
35
+ export type AcquireLockResult = ValidLockResult | InvalidLockResult
36
+
37
+ function DataStoreLockHelper.new(dataStore: any): DataStoreLockHelper
38
+ local self: DataStoreLockHelper = setmetatable({} :: any, DataStoreLockHelper)
39
+
40
+ self._sessionId = HttpService:GenerateGUID(false)
41
+ self._dataStore = dataStore
42
+
43
+ return self
44
+ end
45
+
46
+ function DataStoreLockHelper.ToUnlockedProfile(_self: DataStoreLockHelper, original: any): any
47
+ if original == nil then
48
+ return {}
49
+ elseif type(original) ~= "table" then
50
+ warn("[DataStore] - Data session locking is not available for non-table entries")
51
+ return original
52
+ else
53
+ local copy = table.clone(original)
54
+ copy.lock = nil
55
+ return copy
56
+ end
57
+ end
58
+
59
+ function DataStoreLockHelper.ToLockedProfile(_self: DataStoreLockHelper, original: any, doCloseSession: boolean?): any
60
+ if original == nil then
61
+ return {
62
+ lock = if doCloseSession then nil else os.time(),
63
+ }
64
+ elseif type(original) ~= "table" then
65
+ warn("[DataStore] - Data session locking is not available for non-table entries")
66
+ return original
67
+ else
68
+ local copy = table.clone(original)
69
+ if doCloseSession then
70
+ copy.lock = nil :: LockData?
71
+ else
72
+ copy.lock = os.time()
73
+ end
74
+ return copy
75
+ end
76
+ end
77
+
78
+ function DataStoreLockHelper.AcquireLock(self: DataStoreLockHelper, data: any): AcquireLockResult
79
+ if data == nil then
80
+ return {
81
+ isValid = true,
82
+ unlockedProfile = {},
83
+ lockedProfile = {
84
+ lock = os.time(),
85
+ },
86
+ }
87
+ elseif type(data) ~= "table" then
88
+ warn("[DataStore] - Data session locking is not available for non-table entries")
89
+ return {
90
+ isValid = true,
91
+ unlockedProfile = data,
92
+ lockedProfile = data,
93
+ }
94
+ end
95
+
96
+ local isValid = true
97
+ if type(data.lock) == "number" then
98
+ local timeElapsed = os.time() - data.lock
99
+ local autoSaveSeconds = self._dataStore:GetAutoSaveTimeSeconds()
100
+ if autoSaveSeconds and timeElapsed > (autoSaveSeconds * UNLOCK_BY_DEFAULT_TIME_MULTIPLIER) then
101
+ isValid = false
102
+ end
103
+ end
104
+
105
+ -- Allow data locked to load in studio because otherwise testing gets really messy
106
+ if ALWAYS_STEAL_LOCKS_IN_STUDIO and RunService:IsStudio() then
107
+ isValid = false
108
+ end
109
+
110
+ if isValid then
111
+ return {
112
+ isValid = true,
113
+ unlockedProfile = self:ToUnlockedProfile(data),
114
+ lockedProfile = self:ToLockedProfile(data),
115
+ }
116
+ else
117
+ return {
118
+ isValid = false,
119
+ }
120
+ end
121
+ end
122
+
123
+ return DataStoreLockHelper