@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 +12 -0
- package/default.project.json +8 -1
- package/package.json +16 -16
- package/src/Server/DataStore.lua +35 -61
- package/src/Server/DataStoreLockHelper.lua +123 -0
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
|
package/default.project.json
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "datastore",
|
|
3
|
-
"globIgnorePaths": [
|
|
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.
|
|
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": "
|
|
33
|
-
"@quenty/bindtocloseservice": "
|
|
34
|
-
"@quenty/loader": "
|
|
35
|
-
"@quenty/maid": "
|
|
36
|
-
"@quenty/math": "
|
|
37
|
-
"@quenty/pagesutils": "
|
|
38
|
-
"@quenty/promise": "
|
|
39
|
-
"@quenty/promisemaid": "
|
|
40
|
-
"@quenty/rx": "
|
|
41
|
-
"@quenty/servicebag": "
|
|
42
|
-
"@quenty/signal": "
|
|
43
|
-
"@quenty/symbol": "
|
|
44
|
-
"@quenty/table": "
|
|
45
|
-
"@quenty/valueobject": "
|
|
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": "
|
|
50
|
+
"gitHead": "55f4a7407de7247aceea574de4d2f57dab70cb91"
|
|
51
51
|
}
|
package/src/Server/DataStore.lua
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
446
|
-
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
|
|
489
|
-
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.
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
576
|
-
|
|
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
|
-
|
|
556
|
+
loadPromise:Resolve(lockResult.unlockedProfile)
|
|
583
557
|
|
|
584
|
-
return
|
|
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
|
-
--
|
|
600
|
-
exponential = 1.
|
|
601
|
-
initialWaitTime =
|
|
602
|
-
maxAttempts =
|
|
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
|