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