Store.lua
230 lines
local DataStoreService = game:GetService("DataStoreService")
local HttpService = game:GetService("HttpService")
local Players = game:GetService("Players")
local Util = require(script.Parent.Util)
local Profile = require(script.Parent.Profile)
local Config = require(script.Parent.Config)
local Store = {}
Store.__index = Store
local function makeKey(userId)
return "Player_" .. tostring(userId)
end
function Store.new(name, template, options)
assert(type(name) == "string" and #name > 0, "store name must be a non-empty string")
assert(type(template) == "table", "template must be a table")
options = options or {}
local self = setmetatable({
Name = name,
Template = Util.DeepCopy(template),
SchemaVersion = options.SchemaVersion or 1,
Options = options,
Config = options.Config or Config,
Profiles = {},
_closing = false,
_store = DataStoreService:GetDataStore(name, options.Scope),
ProfileLoaded = Instance.new("BindableEvent"),
ProfileReleased = Instance.new("BindableEvent"),
}, Store)
return self
end
function Store:_retry(operation, attempts)
local lastError
for attempt = 1, attempts do
local ok, result = pcall(operation)
if ok then return true, result end
lastError = result
if attempt < attempts then
task.wait(Util.Backoff(attempt, self.Config.RetryBaseDelay, self.Config.RetryMaxDelay))
end
end
return false, lastError
end
function Store:LoadProfileAsync(userId)
assert(type(userId) == "number", "userId must be a number")
if self._closing then return nil, "store is closing" end
if self.Profiles[userId] then return self.Profiles[userId], nil end
local key = makeKey(userId)
local sessionId = game.JobId .. ":" .. HttpService:GenerateGUID(false)
local loadedData, keyInfo
local success, result = self:_retry(function()
return self._store:UpdateAsync(key, function(oldData, oldKeyInfo)
local data = oldData or Util.DeepCopy(self.Template)
local meta = data.__NexusData
if type(meta) ~= "table" then meta = {} end
local lock = meta.Session
if lock and lock.SessionId ~= sessionId then
local lastSeen = tonumber(lock.Timestamp) or 0
if os.time() - lastSeen < self.Config.SessionLockTimeout then
return nil
end
end
meta.Session = {
JobId = game.JobId,
SessionId = sessionId,
Timestamp = os.time(),
}
data.__NexusData = meta
loadedData = data
keyInfo = oldKeyInfo
return data
end)
end, self.Config.LoadAttempts)
if not success then return nil, result end
if result == nil or loadedData == nil then
return nil, "profile is locked by another server or unavailable"
end
loadedData.__NexusData = loadedData.__NexusData or {}
local profile = Profile.new(self, userId, key, loadedData, sessionId, keyInfo)
profile:Reconcile()
self.Profiles[userId] = profile
self.ProfileLoaded:Fire(profile)
return profile, nil
end
function Store:SaveProfile(userId)
local profile = self.Profiles[userId]
if not profile or not profile.IsActive then return false, "profile is not active" end
local snapshot = Util.DeepCopy(profile.Data)
local success, result = self:_retry(function()
return self._store:UpdateAsync(profile.Key, function(current)
if type(current) ~= "table" then return nil end
local meta = current.__NexusData
local lock = type(meta) == "table" and meta.Session
if type(lock) ~= "table" or lock.SessionId ~= profile.SessionId then
return nil
end
snapshot.__NexusData = snapshot.__NexusData or {}
snapshot.__NexusData.Session = {
JobId = game.JobId,
SessionId = profile.SessionId,
Timestamp = os.time(),
}
return snapshot
end)
end, self.Config.SaveAttempts)
if not success or result == nil then
return false, result or "session ownership lost; save cancelled"
end
profile.IsDirty = false
profile._lastSaved = os.clock()
return true
end
function Store:ReleaseProfile(userId)
local profile = self.Profiles[userId]
if not profile then return true end
if profile._released then return true end
local saved, err = self:SaveProfile(userId)
if not saved then
warn(("[NexusData] Failed final save for %s: %s"):format(tostring(userId), tostring(err)))
end
local releaseSuccess, releaseResult = self:_retry(function()
return self._store:UpdateAsync(profile.Key, function(current)
if type(current) ~= "table" then return nil end
local meta = current.__NexusData
local lock = type(meta) == "table" and meta.Session
if type(lock) ~= "table" or lock.SessionId ~= profile.SessionId then
return nil
end
meta.Session = nil
current.__NexusData = meta
return current
end)
end, self.Config.SaveAttempts)
profile.IsActive = false
profile._released = true
self.Profiles[userId] = nil
self.ProfileReleased:Fire(userId, saved, err)
if not releaseSuccess then
warn(("[NexusData] Failed to release session for %s: %s"):format(tostring(userId), tostring(releaseResult)))
end
return saved, err
end
function Store:_refreshSession(profile)
local success, result = self:_retry(function()
return self._store:UpdateAsync(profile.Key, function(current)
if type(current) ~= "table" then return nil end
local meta = current.__NexusData
local lock = type(meta) == "table" and meta.Session
if type(lock) ~= "table" or lock.SessionId ~= profile.SessionId then
return nil
end
lock.Timestamp = os.time()
current.__NexusData = meta
return current
end)
end, self.Config.SaveAttempts)
if not success or result == nil then
return false, result or "session ownership lost"
end
return true
end
function Store:StartAutosave()
if self._autosaveStarted then return end
self._autosaveStarted = true
task.spawn(function()
while not self._closing do
task.wait(self.Config.AutosaveInterval)
for userId, profile in pairs(self.Profiles) do
if profile.IsActive then
-- Refresh the session timestamp even when the profile has no changes.
-- Otherwise a quiet profile could look abandoned to another server.
if profile.IsDirty then
local ok, err = self:SaveProfile(userId)
if not ok then
warn(("[NexusData] Autosave failed for %s: %s"):format(tostring(userId), tostring(err)))
end
else
local ok, err = self:_refreshSession(profile)
if not ok then
warn(("[NexusData] Session refresh failed for %s: %s"):format(tostring(userId), tostring(err)))
end
end
end
end
end
end)
end
function Store:Close()
if self._closing then return end
self._closing = true
local deadline = os.clock() + self.Config.BindToCloseTimeout
for userId in pairs(self.Profiles) do
if os.clock() >= deadline then break end
self:ReleaseProfile(userId)
end
end
function Store:GetProfile(userId)
return self.Profiles[userId]
end
return Store