Data Management System Code Review

Hey guys, I just finished working on a modular DataManagement system for my Roblox game, and was wondering if you guys would be down to review my code? This is my first time writing a system like this and I really enjoyed it but please remember that while reviewing my code! I will post the features down below, and then ill post the main module, and then each module after that is a child of the main one!

Features

  • Session Lock Management:

    • Prevents concurrent modifications of player data by using GUIDS.
    • Verifies the validity of session locks upon every save to ensure data integrity.
    • Automatically handles expired or invalid session locks.
    • Customizable expiry time for session locks, ensuring old sessions are automatically cleared.
  • Save Slot Management:

    • Supports multiple save slots for each player.
    • Allows developers to specify which save slot to load or save data to.
    • Handles loading and saving data asynchronously with error handling.
  • Optimized Auto Save system:

    • Fully customizable.
    • Saves every players data every x seconds only if data has been updated.
    • Settings allows you to specify certain keys to skip for efficiency e.g an inventory with hundreds of items.
    • Force save every x amount of cycles.
  • Automatic Default Data Fallback:

    • If no data is found for a player’s save slot, a fully customizable default data table is returned.
  • Dedicated Getter, Setter, and Checker functions:

    • By using these dedicated functions, security is maximized and efficiency is prioritized.
  • Dedicated functions for player inventory management:

  • Functions such as item lookups, adding and removing, clearing etc.

  • Data validation and error handling:

    • All incoming data is properly checked.
    • Errors are handled gracefully
    • Many functions will return specific values
  • Debug Mode:

    • Option to enable debug messages for developers to monitor the system’s operations.

Main module:

local PlayerData = {}
PlayerData.__index = PlayerData

--//Modules

local DataStoreHandle = require(script.DataStoreHandle)
local Settings = require(script.PlayerDataSettings)

--//Variables

local DebugMode = Settings.DebugMode
local AutoSaveInterval = Settings.AutoSaveInterval
local PeriodicSaveInterval = Settings.PeriodicSaveInterval
local SaveCycleCount = 0

--//Tables

PlayerData.DataTable = {} 
local PreviousTable = {}
local skipKeys = Settings.skipKeys

function PlayerData.new(PlayerInfo, ChosenSlot)
	local self = setmetatable(PlayerInfo or {}, PlayerData)
	
	--//Core Values, removing these will break everything if they are not defined in the default table!
	
	self.UserId = self.UserId
	self.SlotNum = ChosenSlot
	self.Inventory = self.Inventory or {}
	self.GiftBank = self.GiftBank or {}
		
	return self 
end

local function CompareTables(CurrentData, PreviousData)

	for key, Current in pairs(CurrentData) do
		local Previous = PreviousData[key]
		if typeof(Current) == "table" and typeof(Previous) == "table" then

			if CompareTables(Current, Previous) then
				return true
			end

			continue
		end

		if Current ~= Previous then
			return true
		end

	end

	for key, _ in pairs(PreviousData) do
		if CurrentData[key] == nil then
			return true 
		end
	end

	for key, _ in pairs(CurrentData) do
		if PreviousData[key] == nil then
			return true 
		end
	end

	return false 
end

local function DeepCopy(original)

	if type(original) ~= "table" then
		if DebugMode then warn("DeepCopy called with invalid input: expected table, got", type(original)) end
		return nil
	end

	local copy = {}

	for key, value in next, original do
		if skipKeys and skipKeys[key] then
			if DebugMode then print("Skipping key: " .. tostring(key)) end
		else
			if type(value) == "table" then
				copy[key] = DeepCopy(value)
			else
				copy[key] = value
			end
		end
	end

	return copy
end

--//Autosaving 

local function AutoSaveData()

	while task.wait(AutoSaveInterval) do 

		SaveCycleCount = SaveCycleCount + 1

		local Players = game:GetService("Players"):GetPlayers()

		for _, Player in pairs(Players) do

			local FindCurrent = PlayerData.DataTable[Player.UserId]
			local Current = DeepCopy(FindCurrent)
			local Previous = PreviousTable[Player.UserId]

			if SaveCycleCount == PeriodicSaveInterval then 

				local TableCopy = DeepCopy(Current)

				PreviousTable[Player.UserId] = TableCopy

				if DebugMode then print("Force Saving data for:".." "..Player.Name) end
				FindCurrent:SavePlayerSlot(Player)

				SaveCycleCount = 0

				continue

			end

			local HasChanged = CompareTables(Current, Previous) 

			if HasChanged == false then
				if DebugMode then
					print("No data change has been detected for:".." "..Player.Name.."Skipping AutoSave")
					print(Current)
					print(Previous)
					print("CycleCount".." "..SaveCycleCount)
				end
			else

				local TableCopy = DeepCopy(Current)
				PreviousTable[Player.UserId] = TableCopy

				if DebugMode then
					print("Data change detetected for:".." "..Player.Name.."Saving data")
					print("CycleCount".." "..SaveCycleCount)
				end
				FindCurrent:SavePlayerSlot(Player)

			end

		end

	end
end

--//Data handling

function PlayerData.RetrieveData(Player)
if not Player then return end
	return PlayerData.DataTable[Player.UserId]
end

function PlayerData:SavePlayerSlot(Player)

	local succcess = DataStoreHandle.SaveDataToSlot(Player, self, self.SlotNum)
	if not succcess then
		if DebugMode then warn("Failed to Save Slot for:"..Player.Name) end
	end

end

function PlayerData.LoadChosenSlot(Player, ChosenSlot)
	
	if not Player or not ChosenSlot then
		if DebugMode then
			print("Error: Missing required arguments - Player or ChosenSlot.")
		end
		return
	end

	if ChosenSlot then
		if Settings.SingleSaveSlot == true then
			ChosenSlot = "Slot1"
		end

		local Data = DataStoreHandle.LoadSaveSlot(Player, ChosenSlot)
		local DataObject = PlayerData.new(Data, ChosenSlot)

		DataObject:AddPlayerData(Player)

		return Data
	end

end

function PlayerData.StartSession(Player)

	local success = DataStoreHandle.EngageSessionLock(Player)

	if not success then
		-- Optionally, handle fallback logic (e.g., retry or notify player)
		if DebugMode then Player:Kick("Unfortunately an error occured when attempting to initialize your session, please rejoin or try again later") end
	end
	
end

function PlayerData.EndSession(Player)

	local RetreivedData = PlayerData.RetrieveData(Player)
	RetreivedData:RemovePlayerData(Player)
	RetreivedData:SavePlayerSlot(Player)

	local attempts = 3  -- Max attempts
	local retryDelay = 5  -- Delay, totaling 15 seconds

	for attempt = 1, attempts do
		local success, errorMessage = pcall(function()
			return DataStoreHandle.RemoveSessionLock(Player)
		end)

		if success then
			return  -- All clear
		else

			if DebugMode then warn("Failed to remove session lock for player: " .. Player.Name .. " - Attempt " .. attempt .. " - " .. errorMessage) end

			if attempt < attempts then
				task.wait(retryDelay)  -- Wait before retrying
			else
				-- If all attempts fail, notify the player and handle the failure
				if DebugMode then warn("All 3 retry attempts failed to remove session lock for: " .. Player.Name) end

			end
		end
	end

end

--//Adding and Removing methods for player data

function PlayerData:AddPlayerData(Player)
	self.UserId = Player.UserId	

	if not PlayerData.DataTable[self.UserId] then	
		PlayerData.DataTable[self.UserId] = self
		local DeepCopy = DeepCopy(self)
		PreviousTable[self.UserId] = DeepCopy
		if DebugMode then print("Player data added for: " .. Player.Name) end			
	else		
		if DebugMode then print("Player data already exists for: " .. Player.Name) end
	end	

end

function PlayerData:RemovePlayerData(Player)

	if PlayerData.DataTable[self.UserId] then
		PlayerData.DataTable[self.UserId] = nil
		PreviousTable[self.UserId] = nil
		if DebugMode then print("Player data removed for:"..Player.Name) end
	else
		if DebugMode then print("No player data found for: " .. Player.Name) end
	end

end

--//Setters and Getters for non inventory data

function PlayerData:GetData(Attribute, Key)

	if self[Attribute] then
		if typeof(self[Attribute]) == "table" then
			return self[Attribute][Key] -- Get table data
		else
			return self[Attribute] -- Get simple data
		end
	else
		if DebugMode then print("Error: Invalid attribute") end
		return nil
	end

end

function PlayerData:SetData(Attribute, Key, Value)

	if self[Attribute] then
		if typeof(self[Attribute]) == "table" then
			self[Attribute][Key] = Value -- Set table data
		else
			self[Attribute] = Key -- Set simple data
		end
	else
		if DebugMode then print("Error: Invalid attribute") end
	end

end

function PlayerData:IncrementData(Attribute, Key, Value)
	local FetchedAttribute = self:GetData(Attribute, Key)

	if typeof(FetchedAttribute) ~= "number" then
		if DebugMode then warn("Error: Attempting to increment a non-numeric value") end
		return
	end
	if FetchedAttribute then
		if typeof(self[Attribute]) == "table" then
			local NewValue = FetchedAttribute + Value
			self[Attribute][Key] = NewValue -- Set table data
		else
			local NewValue = FetchedAttribute + Key
			self[Attribute] = NewValue -- Set simple data
		end
	else
		if DebugMode then print("Error: Invalid attribute") end
	end

end

function PlayerData:DecrementData(Attribute, Key, Value)
	local FetchedAttribute = self:GetData(Attribute, Key) or 0

	if FetchedAttribute then
		if typeof(FetchedAttribute) ~= "number"  then
			if DebugMode then warn("Error: Attempting to decrement a non-numeric value") end
			return
		end
		local NewValue = FetchedAttribute - Value
		if NewValue < 0 and not Settings.AllowNegativeValues then 
			if DebugMode then warn("Update aborted: AllowNegativeValues has been set to false") end
			return
		end
		if NewValue >= 0  then
			if typeof(self[Attribute]) == "table" then
				self[Attribute][Key] = NewValue -- Set table data
			else
				self[Attribute] = NewValue -- Set simple data
			end
		else 
			if DebugMode then print("Value is negative, update aborted") end
		end
	else
		if DebugMode then print("Error: Invalid attribute") end
	end
end

--//Inventory methods

function PlayerData:AddItemToInventory(Item, Ammount)
	if typeof(Item) == "table" then
		local found = false

		for _, Object in pairs(self.Inventory) do
			if typeof(Object) == "table" and typeof(Ammount) == "number" then 
				if Object.Name == Item.Name then 
					Object.Quantity = Object.Quantity + Ammount
					found = true
					if DebugMode then print(Item.Name.." ".."already exists, adding".." "..Ammount.." ".."to Quantity") end
					break
				end
			else
				if DebugMode then print("Invalid arguments, check your code!") end
			end
		end

		if found == false then
			table.insert(self.Inventory, Item)
			if DebugMode then print(Item.Name.." ".."not found, adding to table") end
		end

	elseif typeof(Item)  == "string" then
		local found = false

		for _, Object in pairs(self.Inventory) do
			if Object.Name == Item then
				Object.Quantity = Object.Quantity + Ammount
				if DebugMode then print(Item.." ".."has been found, adding".." "..Ammount.." ".."to table") end
				found = true
				break  
			end
		end

		if found == false then
			if DebugMode then print(Item.." ".."not found".." ".."Returning false") end
			return false
		end

	end
end

function PlayerData:RemoveItemFromInventory(Item, Ammount)
	if typeof(Item) == "table" then
		local found = false

		for index, Object in pairs(self.Inventory) do
			if typeof(Object) == "table" then 
				if Object.Name == Item.Name then 
					Object.Quantity = Object.Quantity - Ammount
					if DebugMode then print("Removed:".." "..Ammount.."From".." "..Item.Name) end
					if Object.Quantity <= 0 then
						if DebugMode then print(Item.Name.." ".."Quantity is or has fallen below 0".." ".."Removing item from table") end
						table.remove(self.Inventory, index)
					end
					found = true
					break
				end
			end
		end

		if not found then
			if DebugMode then print(Item.Name.." ".."not found, returning false") end
			return false
		end

	elseif typeof(Item)  == "string" then
		local found = false

		for index, Object in pairs(self.Inventory) do
			if Object.Name == Item then
				Object.Quantity = Object.Quantity - Ammount
				if DebugMode then print("Removed:".." "..Ammount.."From".." "..Item) end
				if Object.Quantity <= 0 then
					table.remove(self.Inventory, index)
				end
				found = true
				break
			end
		end
		if not found then
			if DebugMode then print(Item.." ".."not found".." ".."Returning false") end
			return false
		end

	end
end

function PlayerData:EditInventoryItem(Item, Key, Value)
	if typeof(Item) ~= "string" then return end
	for index, Object in pairs(self.Inventory) do
		if typeof(Object) ~= "table" then return end
		if Object.Name == Item then
			Object[Key] = Value
			return Object[Key]
		end
	end
	return false	
end

function PlayerData:LookUpInventoryItem(Item,Key)
	if typeof(Item) == "string" then

		for index, Object in pairs(self.Inventory) do
			if typeof(Object) ~= "table" then return end
			if Object.Name == Item then
				if Key then
					return Object[Key]
				else
					return Object
				end
			end
		end

		if DebugMode then print("Could not find:".." "..Item) end
		return false

	else
		if DebugMode then print("Invalid argument detected, must be a string value") end
		return false
	end
end

function PlayerData:ClearInventory(Player)
	if #self.Inventory <= 0 then
		if DebugMode then print("Inventory is already empty") end
	else
		table.clear(self.Inventory)
		if DebugMode then print("Inventory cleared for player:", Player.Name or "Unknown") end
	end
end

spawn(function()
	AutoSaveData()
end)

return PlayerData

DataStoreHandle

local DataStoreHandle = {}

--//Services

local DataStoreService = game:GetService("DataStoreService")
local ServerStorage = game:GetService("ServerStorage")
local HttpService = game:GetService("HttpService")

--//Modules

local Settings = require(script.Parent.PlayerDataSettings)

--//Variables

local ExpiryInterval = Settings.ExpiryTime
local DebugMode = Settings.DebugMode

--//Tables

local ActiveSessionLocks = {}
local DefaultTable = Settings.DefaultData

local function GenerateSessionGUID()
	local GUID = HttpService:GenerateGUID(false)
	local ExpiryTime = os.time() + ExpiryInterval

	table.insert(ActiveSessionLocks, GUID)

	return GUID, ExpiryTime
end

local function VerifySessionLock(Player)

	local SessionLockkey = "SessionLock_"..Player.UserId
	local PlayerDataStore = DataStoreService:GetDataStore("PlayerData")

	local ExistingLock = PlayerDataStore:GetAsync(SessionLockkey)

	if not ExistingLock then
		return "Nil"
	end

	local PlayerId, sessionGUID, expiryTime = ExistingLock:match("^(%d+)_(%S+)_(%d+)$")
	expiryTime = tonumber(expiryTime)	

	if os.time() > expiryTime then
		DataStoreHandle.RemoveSessionLock(Player)
		return "Expired"  -- Lock expired
	end

	if PlayerId == tostring(Player.UserId) then	

		local CurrentLock = table.find(ActiveSessionLocks, sessionGUID)

		if CurrentLock then
			if DebugMode then print("Session lock valid for player: ".." ".. Player.Name) end
			return "Valid" -- Valid session lock
		else
			return "Invalid" -- Invalid session lock
		end
	else
		if DebugMode then warn("PlayerId not found for:".." ".. Player.Name) end
		return "Invalid" -- Invalid session lock
	end

end

local function RetrieveSaveSlot(Player, SaveSlot)

	local ChosenSlot = "PlayerData_"..Player.UserId.."_"..SaveSlot
	local PlayerDataStore = DataStoreService:GetDataStore("PlayerData")	

	if PlayerDataStore then
		if DebugMode then print("Successfully retrieved the data store for " .. ChosenSlot) end
	else
		if DebugMode then warn("Failed to retrieve the data store for"..Player.Name..SaveSlot) end
		return false
	end
	return PlayerDataStore, ChosenSlot

end

function DataStoreHandle.RemoveSessionLock(Player)

	local SessionLockkey = "SessionLock_"..Player.UserId
	local PlayerDataStore = DataStoreService:GetDataStore("PlayerData")
	local ExistingLock = PlayerDataStore:GetAsync(SessionLockkey)

	if ExistingLock then
		local PlayerId, sessionGUID, expiryTime = ExistingLock:match("^(%d+)_(%S+)_(%d+)$")	

		-- Remove the session GUID from the ActiveSessionLocks table
		for i, lock in ipairs(ActiveSessionLocks) do
			if lock == sessionGUID then
				table.remove(ActiveSessionLocks, i)
				break
			end
		end

		-- Remove the session lock from the DataStore
		local success, errorMessage = pcall(function()
			PlayerDataStore:RemoveAsync(SessionLockkey)
		end)

		if success then
			if DebugMode then print("Session lock successfully removed for player: " .. Player.Name) end
			return true
		else
			if DebugMode then warn("Failed to remove session lock for player: " .. Player.Name .. " - " .. errorMessage) end
			return false
		end
	else
		if DebugMode then warn("No session lock found for player: " .. Player.Name) end
		return false
	end

end

function DataStoreHandle.EngageSessionLock(Player)

	local SessionLockkey = "SessionLock_"..Player.UserId
	local PlayerDataStore = DataStoreService:GetDataStore("PlayerData")
	local ExistingLock = PlayerDataStore:GetAsync(SessionLockkey)

	if ExistingLock then
		if DebugMode then print("Session lock already exists for player: " .. Player.Name) end
		return -- Session lock already exists
	end

	local SessionGUID, ExpiryTime = GenerateSessionGUID()
	local SessionLockData = Player.UserId.."_"..SessionGUID.."_"..ExpiryTime

	local success, errorMessage = pcall(function()
		PlayerDataStore:SetAsync(SessionLockkey, SessionLockData)
	end)

	if success then
		if DebugMode then print("Session lock created and saved for player: " .. Player.Name) end
		return true
	else
		if DebugMode then warn("Failed to save session lock for player: " .. Player.Name .. " - " .. errorMessage) end
		return false
	end

end

function DataStoreHandle.InvalidSession(Player, SessionLockStatus)

	if SessionLockStatus == "Invalid" then
		--Lock is corrupted, we will take immediate action
		if DebugMode then warn("Corrupted Session Detected for:"..Player.Name) end
		Player:Kick("Corrupted Session detected, please rejoin")
		DataStoreHandle.RemoveSessionLock(Player)

	elseif SessionLockStatus == "Nil" then
		--[[Lock not found, could be a number of issues such as script error, 
		timing error, or exploiter. In this case, we will deny the data request--]]
		if DebugMode then warn("SessionLock not found for:"..Player.Name.."Data Request Denied") end
		return false
	elseif SessionLockStatus == "Expired" then
		--[[Lock has expired due to a number of reasons such as prolonged session, 
		server crash etc. In thise case, we will simply create a new lock--]]
		if DebugMode then warn("SessionLock has expired for:"..Player.Name.."Engaging new Lock") end
		DataStoreHandle.EngageSessionLock()
	end

end

function DataStoreHandle.SaveDataToSlot(Player, Data, SaveSlot) 
	local PlayerDataStore, ChosenSlot = RetrieveSaveSlot(Player, SaveSlot)

	local SessionLockStatus = VerifySessionLock(Player)

	if SessionLockStatus == "Valid" then

		local success, errorMessage = pcall(function()
			PlayerDataStore:SetAsync(ChosenSlot, Data)
		end)

		if success then
			if DebugMode then print("Successfully saved"..SaveSlot) end
			return true
		else		
			if DebugMode then warn("Could not save"..SaveSlot) end
			return false
		end

	else
		DataStoreHandle.InvalidSession(Player, SessionLockStatus)
	end

end

function DataStoreHandle.LoadSaveSlot(Player, SaveSlot)
	local PlayerDataStore, ChosenSlot = RetrieveSaveSlot(Player, SaveSlot)

	local SessionLockStatus = VerifySessionLock(Player)

	if SessionLockStatus == "Valid" then

		local success, data

		--//Load attempt

		success, data = pcall(function()
			return PlayerDataStore:GetAsync(ChosenSlot)	
		end)

		if success then
			if data then
				if DebugMode then print("Successfully loaded"..SaveSlot.."for"..Player.Name) end
				return data
			else
				if DebugMode then print("Could not load"..SaveSlot.."for"..Player.Name.."Setting Default Data") end
				return DefaultTable
			end
		else
			if DebugMode then warn("Error when loading"..SaveSlot.."for"..Player.Name) end
			return false
		end

	else
		DataStoreHandle.InvalidSession(Player, SessionLockStatus)
	end

end

return DataStoreHandle

Settings module

local PlayerDataSettings = {}

PlayerDataSettings.AutoSaveInterval = 30 --in seconds, how often the autosave system should save data? Remember, the lower the interval the more performane heavy 
PlayerDataSettings.ExpiryTime = 1800  --In seconds e.g 1800 = 30 minutes
PlayerDataSettings.PeriodicSaveInterval = 4 -- in cycles of AutoSaveInterval e.g 10 == every 5 minutes
PlayerDataSettings.SingleSaveSlot = false -- If set to true, players will only be able to have one slot 
PlayerDataSettings.AllowNegativeValues = true -- If false and a value is negative, it will not be updated
PlayerDataSettings.DebugMode = true --Prints and warns will be output to the console if enabled 

--[[Keys added to this table will be skipped during each autosave cycle, meaning that they won't be checked
for upated values. The data still saves though. It is recommended that Inventory be skipped if your game allows
players to oobtain a large amount of items, or if items are complex and contain a lot of data - More info in documentation]]

 PlayerDataSettings.skipKeys = {
	Inventory = false 
 }

--[[Default table, add your values here!]]

 PlayerDataSettings.DefaultData = {
	
	Currency = 500,
	Level = 1,
	CurrentXP = 0,
	XPtoNextLevel = 1000,
	
	Inventory = {
	{Name = "Starter Sword", ItemType = "Object", Quantity = 1},
	{Name = "Bread", ItemType = "Object",Quantity = 5}
	},
	
	EquippedItems = {
		
		["Weapon"] = nil,
		["Head"] = nil,
		["Torso"] = nil,
		["Legs"] = nil,
		["Boots"] = nil,
		["Neck"] = nil,
		["Ring1"] = nil,
		["Ring2"] = nil,
	},
	
	Reputation = {
		["Nordraxia"] = 1
	}
		
 }

return PlayerDataSettings