From fd11e0d855bbc49e4b777b44148697ca908e1f33 Mon Sep 17 00:00:00 2001
From: BerkieBb <82737367+BerkieBb@users.noreply.github.com>
Date: Tue, 14 Jan 2025 14:32:07 +0100
Subject: [PATCH 01/15] feat: live database updates

---
 bridge/qb/client/functions.lua |  27 +++
 bridge/qb/server/commands.lua  |   3 +
 bridge/qb/server/events.lua    |   3 +
 bridge/qb/server/functions.lua |  46 ++++-
 bridge/qb/server/main.lua      |   3 +
 bridge/qb/shared/compat.lua    |   4 +-
 bridge/qb/shared/main.lua      |   6 +
 client/character.lua           |   6 +-
 client/events.lua              |  24 ++-
 client/functions.lua           |   2 +-
 client/main.lua                |   2 +
 client/vehicle-persistence.lua |   3 +
 config/server.lua              |   3 +-
 data/nationalities.lua         |   2 +-
 modules/hooks.lua              |   1 +
 modules/lib.lua                |  36 ++--
 modules/logger.lua             |   4 +-
 server/character.lua           |   5 +-
 server/commands.lua            |   2 +-
 server/events.lua              |  27 +--
 server/functions.lua           |  77 +++++----
 server/groups.lua              |   8 +
 server/loops.lua               |  37 ++--
 server/main.lua                |   8 +-
 server/motd.lua                |   2 -
 server/player.lua              | 300 +++++++++++++++------------------
 server/queue.lua               |  12 +-
 server/storage/players.lua     | 136 ++++++++++++++-
 server/vehicle-persistence.lua |   3 +
 shared/functions.lua           |   9 +-
 types.lua                      |   2 +
 31 files changed, 532 insertions(+), 271 deletions(-)

diff --git a/bridge/qb/client/functions.lua b/bridge/qb/client/functions.lua
index 76c244645..237d7f9d5 100644
--- a/bridge/qb/client/functions.lua
+++ b/bridge/qb/client/functions.lua
@@ -8,6 +8,7 @@ local functions = {}
 ---@return PlayerData? playerData
 function functions.GetPlayerData(cb)
     if not cb then return QBX.PlayerData end
+
     cb(QBX.PlayerData)
 end
 
@@ -27,8 +28,10 @@ functions.HasItem = function(items, amount)
                 return false
             end
         end
+
         return true
     end
+
     return count >= amount
 end
 
@@ -137,6 +140,7 @@ local function getEntities(pool, ignoreList) -- luacheck: ignore
             entities[#entities + 1] = entity
         end
     end
+
     return entities
 end
 
@@ -175,6 +179,7 @@ local function getClosestEntity(entities, coords) -- luacheck: ignore
             closestDistance = distance
         end
     end
+
     return closestEntity, closestDistance
 end
 
@@ -233,11 +238,13 @@ functions.GetClosestBone = function(entity, list)
             distance = boneDistance
         end
     end
+
     if not bone then
         bone = {id = GetEntityBoneIndexByName(entity, 'bodyshell'), type = 'remains', name = 'bodyshell'}
         coords = GetWorldPositionOfEntityBone(entity, bone.id)
         distance = #(coords - playerCoords)
     end
+
     return bone, coords, distance
 end
 
@@ -285,7 +292,9 @@ function functions.SpawnVehicle(model, cb, coords, isnetworked, teleportInto)
     SetVehRadioStation(veh, 'OFF')
     SetVehicleFuelLevel(veh, 100.0)
     SetModelAsNoLongerNeeded(model)
+
     if teleportInto then TaskWarpPedIntoVehicle(cache.ped, veh, -1) end
+
     if cb then cb(veh) end
 end
 
@@ -295,12 +304,14 @@ functions.DeleteVehicle = qbx.deleteVehicle
 ---@deprecated use qbx.getVehiclePlate from modules/lib.lua
 functions.GetPlate = function(vehicle)
     if vehicle == 0 then return end
+
     return qbx.getVehiclePlate(vehicle)
 end
 
 ---@deprecated use qbx.getVehicleDisplayName from modules/lib.lua
 functions.GetVehicleLabel = function(vehicle)
     if vehicle == nil or vehicle == 0 then return end
+
     return qbx.getVehicleDisplayName(vehicle)
 end
 
@@ -317,6 +328,7 @@ functions.SpawnClear = function(coords, radius)
             closeVeh[#closeVeh + 1] = vehicles[i]
         end
     end
+
     return #closeVeh == 0
 end
 
@@ -375,6 +387,7 @@ function functions.SetVehicleProperties(vehicle, props)
             SetVehicleWheelHealth(vehicle, wheelIndex, health)
         end
     end
+
     if props.headlightColor then
         SetVehicleHeadlightsColour(vehicle, props.headlightColor)
     end
@@ -779,24 +792,30 @@ functions.StartParticleAtCoord = function(dict, ptName, looped, coords, rot, sca
     lib.requestNamedPtfxAsset(dict)
     UseParticleFxAssetNextCall(dict)
     SetPtfxAssetNextCall(dict)
+
     local particleHandle
     if looped then
         particleHandle = StartParticleFxLoopedAtCoord(ptName, coords.x, coords.y, coords.z, rot.x, rot.y, rot.z, scale or 1.0, false, false, false, false)
         if color then
             SetParticleFxLoopedColour(particleHandle, color.r, color.g, color.b, false)
         end
+
         SetParticleFxLoopedAlpha(particleHandle, alpha or 10.0)
+
         if duration then
             Wait(duration)
             StopParticleFxLooped(particleHandle, false)
         end
     else
         SetParticleFxNonLoopedAlpha(alpha or 1.0)
+
         if color then
             SetParticleFxNonLoopedColour(color.r, color.g, color.b)
         end
+
         StartParticleFxNonLoopedAtCoord(ptName, coords.x, coords.y, coords.z, rot.x, rot.y, rot.z, scale or 1.0, false, false, false)
     end
+
     return particleHandle
 end
 
@@ -804,6 +823,7 @@ end
 functions.StartParticleOnEntity = function(dict, ptName, looped, entity, bone, offset, rot, scale, alpha, color, evolution, duration) -- luacheck: ignore
     lib.requestNamedPtfxAsset(dict)
     UseParticleFxAssetNextCall(dict)
+
     local particleHandle = nil
     ---@cast bone number
     local pedBoneIndex = bone and GetPedBoneIndex(entity, bone) or 0
@@ -817,28 +837,35 @@ functions.StartParticleOnEntity = function(dict, ptName, looped, entity, bone, o
         else
             particleHandle = StartParticleFxLoopedOnEntity(ptName, entity, offset.x, offset.y, offset.z, rot.x, rot.y, rot.z, scale or 1.0, false, false, false)
         end
+
         if evolution then
             SetParticleFxLoopedEvolution(particleHandle, evolution.name, evolution.amount, false)
         end
+
         if color then
             SetParticleFxLoopedColour(particleHandle, color.r, color.g, color.b, false)
         end
+
         SetParticleFxLoopedAlpha(particleHandle, alpha or 1.0)
+
         if duration then
             Wait(duration)
             StopParticleFxLooped(particleHandle, false)
         end
     else
         SetParticleFxNonLoopedAlpha(alpha or 1.0)
+
         if color then
             SetParticleFxNonLoopedColour(color.r, color.g, color.b)
         end
+
         if boneID then
             StartParticleFxNonLoopedOnPedBone(ptName, entity, offset.x, offset.y, offset.z, rot.x, rot.y, rot.z, boneID, scale or 1.0, false, false, false)
         else
             StartParticleFxNonLoopedOnEntity(ptName, entity, offset.x, offset.y, offset.z, rot.x, rot.y, rot.z, scale or 1.0, false, false, false)
         end
     end
+
     return particleHandle
 end
 
diff --git a/bridge/qb/server/commands.lua b/bridge/qb/server/commands.lua
index c9d23b1ce..3943ceca5 100644
--- a/bridge/qb/server/commands.lua
+++ b/bridge/qb/server/commands.lua
@@ -7,6 +7,7 @@ function commands.Add(name, help, arguments, argsrequired, callback, permission)
         restricted = permission and permission ~= 'user' and 'group.'..permission or false,
         params = {}
     }
+
     for i = 1, #arguments do
         local argument = arguments[i]
         properties.params[i] = {
@@ -16,6 +17,7 @@ function commands.Add(name, help, arguments, argsrequired, callback, permission)
             optional = not argsrequired or argument?.optional
         }
     end
+
     lib.addCommand(name, properties, function(source, args, raw)
         local _args = {}
         if #args > 0 then
@@ -25,6 +27,7 @@ function commands.Add(name, help, arguments, argsrequired, callback, permission)
                 _args[i] = args[arguments[i].name]
             end
         end
+
         callback(source, _args, raw)
     end)
 end
diff --git a/bridge/qb/server/events.lua b/bridge/qb/server/events.lua
index d4d1e77b7..ba5d99da7 100644
--- a/bridge/qb/server/events.lua
+++ b/bridge/qb/server/events.lua
@@ -8,6 +8,7 @@ RegisterServerEvent('baseevents:enteringVehicle', function(veh, seat, modelName,
         netId = netId,
         event = 'Entering'
     }
+
     TriggerClientEvent('QBCore:Client:VehicleInfo', src, data)
 end)
 
@@ -20,6 +21,7 @@ RegisterServerEvent('baseevents:enteredVehicle', function(veh, seat, modelName,
         netId = netId,
         event = 'Entered'
     }
+
     TriggerClientEvent('QBCore:Client:VehicleInfo', src, data)
 end)
 
@@ -37,5 +39,6 @@ RegisterServerEvent('baseevents:leftVehicle', function(veh, seat, modelName, net
         netId = netId,
         event = 'Left'
     }
+
     TriggerClientEvent('QBCore:Client:VehicleInfo', src, data)
 end)
\ No newline at end of file
diff --git a/bridge/qb/server/functions.lua b/bridge/qb/server/functions.lua
index 3688da6ad..e90363cc1 100644
--- a/bridge/qb/server/functions.lua
+++ b/bridge/qb/server/functions.lua
@@ -31,26 +31,33 @@ end
 ---@return number?
 function functions.SpawnVehicle(source, model, coords, warp)
     local ped = GetPlayerPed(source)
+
     model = type(model) == 'string' and joaat(model) or model
+
     if not coords then coords = GetEntityCoords(ped) end
+
     local heading = coords.w and coords.w or 0.0
     local veh = CreateVehicle(model, coords.x, coords.y, coords.z, heading, true, true)
+
     while not DoesEntityExist(veh) do Wait(0) end
+
     if warp then
         while GetVehiclePedIsIn(ped, false) ~= veh do
             Wait(0)
             TaskWarpPedIntoVehicle(ped, veh, -1)
         end
     end
+
     while NetworkGetEntityOwner(veh) ~= source do Wait(0) end
+
     return veh
 end
 
 ---@deprecated use qbx.spawnVehicle from modules/lib.lua
 function functions.CreateVehicle(source, model, _, coords, warp)
     model = type(model) == 'string' and joaat(model) or (model --[[@as integer]])
-    local ped = GetPlayerPed(source)
 
+    local ped = GetPlayerPed(source)
     local netId = qbx.spawnVehicle({
         model = model,
         spawnSource = coords or ped,
@@ -74,26 +81,32 @@ functions.Kick = function(source, reason, setKickReason, deferrals)
     if setKickReason then
         setKickReason(reason)
     end
+
     CreateThread(function()
         if deferrals then
             deferrals.update(reason)
             Wait(2500)
         end
+
         if source then
             DropPlayer(source --[[@as string]], reason)
         end
+
         for _ = 0, 4 do
             while true do
                 if source then
                     if GetPlayerPing(source --[[@as string]]) >= 0 then
                         break
                     end
+
                     Wait(100)
+
                     CreateThread(function()
                         DropPlayer(source --[[@as string]], reason)
                     end)
                 end
             end
+
             Wait(5000)
         end
     end)
@@ -126,8 +139,10 @@ functions.HasItem = function(source, items, amount) -- luacheck: ignore
                 return false
             end
         end
+
         return true
     end
+
     return count >= amount
 end
 
@@ -152,6 +167,7 @@ local function AddItem(itemName, item)
 
     TriggerClientEvent('QBCore:Client:OnSharedUpdate', -1, 'Items', itemName, item)
     TriggerEvent('QBCore:Server:UpdateObject')
+
     return true, 'success'
 end
 
@@ -165,12 +181,15 @@ local function UpdateItem(itemName, item)
     if type(itemName) ~= 'string' then
         return false, 'invalid_item_name'
     end
+
     if not qbCoreCompat.Shared.Items[itemName] then
         return false, 'item_not_exists'
     end
+
     qbCoreCompat.Shared.Items[itemName] = item
     TriggerClientEvent('QBCore:Client:OnSharedUpdate', -1, 'Items', itemName, item)
     TriggerEvent('QBCore:Server:UpdateObject')
+
     return true, 'success'
 end
 
@@ -184,7 +203,6 @@ local function AddItems(items)
     local shouldContinue = true
     local message = 'success'
     local errorItem = nil
-
     for key, value in pairs(items) do
         if type(key) ~= 'string' then
             message = 'invalid_item_name'
@@ -199,6 +217,7 @@ local function AddItems(items)
             errorItem = items[key]
             break
         end
+
         lib.print.warn(('New item %s added but not found in ox_inventory. Printing item data'):format(key))
         lib.print.warn(value)
 
@@ -206,8 +225,10 @@ local function AddItems(items)
     end
 
     if not shouldContinue then return false, message, errorItem end
+
     TriggerClientEvent('QBCore:Client:OnSharedUpdateMultiple', -1, 'Items', items)
     TriggerEvent('QBCore:Server:UpdateObject')
+
     return true, message, nil
 end
 
@@ -230,6 +251,7 @@ local function RemoveItem(itemName)
 
     TriggerClientEvent('QBCore:Client:OnSharedUpdate', -1, 'Items', itemName, nil)
     TriggerEvent('QBCore:Server:UpdateObject')
+
     return true, 'success'
 end
 
@@ -252,12 +274,14 @@ local function addJob(jobName, job)
     end
 
     CreateJobs({[jobName] = job})
+
     return true, 'success'
 end
 
 functions.AddJob = function(jobName, job)
     return exports['qb-core']:AddJob(jobName, job)
 end
+
 createQbExport('AddJob', addJob)
 
 -- Multiple Add Jobs
@@ -278,12 +302,14 @@ local function addJobs(jobs)
     end
 
     CreateJobs(jobs)
+
     return true, 'success'
 end
 
 functions.AddJobs = function(jobs)
     return exports['qb-core']:AddJobs(jobs)
 end
+
 createQbExport('AddJobs', addJobs)
 
 -- Single Update Job
@@ -302,12 +328,14 @@ local function updateJob(jobName, job)
     end
 
     CreateJobs({[jobName] = job})
+
     return true, 'success'
 end
 
 functions.UpdateJob = function(jobName, job)
     return exports['qb-core']:UpdateJob(jobName, job)
 end
+
 createQbExport('UpdateJob', updateJob)
 
 -- Single Add Gang
@@ -326,12 +354,14 @@ local function addGang(gangName, gang)
     end
 
     CreateGangs({[gangName] = gang})
+
     return true, 'success'
 end
 
 functions.AddGang = function(gangName, gang)
     return exports['qb-core']:AddGang(gangName, gang)
 end
+
 createQbExport('AddGang', addGang)
 
 -- Single Update Gang
@@ -350,12 +380,14 @@ local function updateGang(gangName, gang)
     end
 
     CreateGangs({[gangName] = gang})
+
     return true, 'success'
 end
 
 functions.UpdateGang = function(gangName, gang)
     return exports['qb-core']:UpdateGang(gangName, gang)
 end
+
 createQbExport('UpdateGang', updateGang)
 
 -- Multiple Add Gangs
@@ -376,22 +408,26 @@ local function addGangs(gangs)
     end
 
     CreateGangs(gangs)
+
     return true, 'success'
 end
 
 functions.AddGangs = function(gangs)
     return exports['qb-core']:AddGangs(gangs)
 end
+
 createQbExport('AddGangs', addGangs)
 
 functions.RemoveJob = function(jobName)
     return exports.qbx_core:RemoveJob(jobName)
 end
+
 createQbExport('RemoveJob', RemoveJob)
 
 functions.RemoveGang = function(gangName)
     return exports.qbx_core:RemoveGang(gangName)
 end
+
 createQbExport('RemoveGang', RemoveGang)
 
 local function checkExistingMethod(method, methodName)
@@ -401,8 +437,10 @@ local function checkExistingMethod(method, methodName)
         if not disableMethodOverrideWarning then
             lib.print.warn(warnMessage:format(methodName))
         end
+
         return allowMethodOverrides
     end
+
     return true
 end
 
@@ -430,6 +468,7 @@ function functions.AddPlayerMethod(ids, methodName, handler)
             end
         else
             if not QBX.Players[ids] then return end
+
             if checkExistingMethod(QBX.Players[ids].Functions[methodName], methodName) then
                 QBX.Players[ids].Functions[methodName] = handler
             end
@@ -517,7 +556,7 @@ end
 functions.SetField = SetField
 exports('SetField', SetField)
 
----@param identifier Identifier
+---@param identifier string
 ---@return integer source of the player with the matching identifier or 0 if no player found
 function functions.GetSource(identifier)
     return exports.qbx_core:GetSource(identifier)
@@ -556,6 +595,7 @@ function functions.GetQBPlayers()
     for k, player in pairs(players) do
         deprecatedPlayers[k] = AddDeprecatedFunctions(player)
     end
+
     return deprecatedPlayers
 end
 
diff --git a/bridge/qb/server/main.lua b/bridge/qb/server/main.lua
index ff69c4e5d..2f5d64216 100644
--- a/bridge/qb/server/main.lua
+++ b/bridge/qb/server/main.lua
@@ -63,11 +63,13 @@ RegisterNetEvent('QBCore:CallCommand', function(command, args)
     local src = source --[[@as Source]]
     local player = GetPlayer(src)
     if not player then return end
+
     if IsPlayerAceAllowed(src --[[@as string]], ('command.%s'):format(command)) then
         local commandString = command
         for _, value in pairs(args) do
             commandString = ('%s %s'):format(commandString, value)
         end
+
         TriggerClientEvent('QBCore:Command:CallCommand', src, commandString)
     end
 end)
@@ -90,6 +92,7 @@ end
 ---@deprecated call a function instead
 function qbCoreCompat.Functions.TriggerCallback(name, source, cb, ...)
     if not qbCoreCompat.ServerCallbacks[name] then return end
+
     qbCoreCompat.ServerCallbacks[name](source, cb, ...)
 end
 
diff --git a/bridge/qb/shared/compat.lua b/bridge/qb/shared/compat.lua
index c93d9e663..a3feb2bfa 100644
--- a/bridge/qb/shared/compat.lua
+++ b/bridge/qb/shared/compat.lua
@@ -28,6 +28,7 @@ return {
                     return true
                 end
             end
+
             return false
         end
 
@@ -70,7 +71,6 @@ return {
 ]]
 
             local fileSize = #file
-
             for _, item in pairs(dump) do
                 if not ItemList[item.name] then
                     fileSize += 1
@@ -88,7 +88,7 @@ return {
                 end
             end
 
-            file[fileSize+1] = '}'
+            file[fileSize + 1] = '}'
 
             SaveResourceFile('ox_inventory', 'data/items.lua', table.concat(file), -1)
             CreateThread(function()
diff --git a/bridge/qb/shared/main.lua b/bridge/qb/shared/main.lua
index 62053012f..859d1de7c 100644
--- a/bridge/qb/shared/main.lua
+++ b/bridge/qb/shared/main.lua
@@ -1,6 +1,7 @@
 local qbShared = require 'shared.main'
 
 qbShared.Items = {}
+
 local oxItems = require '@ox_inventory.data.items'
 for item, data in pairs(oxItems) do
     qbShared.Items[item] = {
@@ -16,6 +17,7 @@ for item, data in pairs(oxItems) do
         description = data.description or nil
     }
 end
+
 local oxWeapons = require '@ox_inventory.data.weapons'
 for weapon, data in pairs(oxWeapons.Weapons) do
     weapon = string.lower(weapon)
@@ -33,6 +35,7 @@ for weapon, data in pairs(oxWeapons.Weapons) do
         description = nil
     }
 end
+
 for component, data in pairs(oxWeapons.Components) do
     component = string.lower(component)
     qbShared.Items[component] = {
@@ -48,6 +51,7 @@ for component, data in pairs(oxWeapons.Components) do
         description = data.description
     }
 end
+
 for ammo, data in pairs(oxWeapons.Ammo) do
     ammo = string.lower(ammo)
     qbShared.Items[ammo] = {
@@ -110,12 +114,14 @@ end
 ---@deprecated use qbx.string.trim from modules/lib.lua
 qbShared.Trim = function(str)
     if not str then return nil end
+
     return qbx.string.trim(str)
 end
 
 ---@deprecated use qbx.string.capitalize from modules/lib.lua
 qbShared.FirstToUpper = function(str)
     if not str then return nil end
+
     return qbx.string.capitalize(str)
 end
 
diff --git a/client/character.lua b/client/character.lua
index 159c2243f..fff49924a 100644
--- a/client/character.lua
+++ b/client/character.lua
@@ -284,6 +284,7 @@ local function spawnDefault() -- We use a callback to make the server wait on th
     while not IsScreenFadedIn() do
         Wait(0)
     end
+
     TriggerEvent('qb-clothes:client:CreateFirstCharacter')
 end
 
@@ -321,7 +322,6 @@ local function createCharacter(cid)
     :: noMatch ::
 
     local dialog = characterDialog()
-
     if not dialog then return false end
 
     for input = 1, 3 do -- Run through first 3 inputs, aka first name, last name and nationality
@@ -353,6 +353,7 @@ local function createCharacter(cid)
     end
 
     destroyPreviewCam()
+
     return true
 end
 
@@ -445,6 +446,7 @@ local function chooseCharacter()
                             else
                                 spawnLastLocation()
                             end
+
                             destroyPreviewCam()
                         end
                     },
@@ -459,6 +461,7 @@ local function chooseCharacter()
                                 centered = true,
                                 cancel = true
                             })
+
                             if alert == 'confirm' then
                                 TriggerServerEvent('qbx_core:server:deleteCharacter', character.citizenid)
                                 destroyPreviewCam()
@@ -504,6 +507,7 @@ end)
 
 RegisterNetEvent('qbx_core:client:playerLoggedOut', function()
     if GetInvokingResource() then return end -- Make sure this can only be triggered from the server
+
     chooseCharacter()
 end)
 
diff --git a/client/events.lua b/client/events.lua
index b100e75d0..f216f84ca 100644
--- a/client/events.lua
+++ b/client/events.lua
@@ -15,15 +15,16 @@ RegisterNetEvent('QBCore:Client:OnPlayerLoaded', function()
     end
 
     local motd = GetConvar('qbx:motd', '')
-    if motd ~= '' then
-        exports.chat:addMessage({ template = motd })
-    end
+    if motd == '' then return end
+
+    exports.chat:addMessage({ template = motd })
 end)
 
 ---@param val PlayerData
 RegisterNetEvent('QBCore:Player:SetPlayerData', function(val)
     local invokingResource = GetInvokingResource()
     if invokingResource and invokingResource ~= cache.resource then return end
+
     QBX.PlayerData = val
 end)
 
@@ -34,10 +35,10 @@ end)
 ---@param value boolean
 ---@diagnostic disable-next-line: param-type-mismatch
 AddStateBagChangeHandler('PVPEnabled', nil, function(bagName, _, value)
-    if bagName == 'global' then
-        SetCanAttackFriendly(cache.ped, value, false)
-        NetworkSetFriendlyFireOption(value)
-    end
+    if bagName ~= 'global' then return end
+
+    SetCanAttackFriendly(cache.ped, value, false)
+    NetworkSetFriendlyFireOption(value)
 end)
 
 -- Teleport Commands
@@ -56,12 +57,11 @@ RegisterNetEvent('QBCore:Command:TeleportToCoords', function(x, y, z, h)
     SetEntityHeading(cache.ped, h or GetEntityHeading(cache.ped))
 end)
 
----@return 'marker'? error present if player did not place a blip
 RegisterNetEvent('QBCore:Command:GoToMarker', function()
     local blipMarker <const> = GetFirstBlipInfoId(8)
     if not DoesBlipExist(blipMarker) then
         Notify(locale('error.no_waypoint'), 'error')
-        return 'marker'
+        return
     end
 
     -- Fade screen to hide how clients get teleported.
@@ -96,8 +96,10 @@ RegisterNetEvent('QBCore:Command:GoToMarker', function()
             if GetGameTimer() - curTime > 1000 then
                 break
             end
+
             Wait(0)
         end
+
         NewLoadSceneStop()
         SetPedCoordsKeepVehicle(ped, x, y, z)
 
@@ -116,6 +118,7 @@ RegisterNetEvent('QBCore:Command:GoToMarker', function()
             SetPedCoordsKeepVehicle(ped, x, y, groundZ)
             break
         end
+
         Wait(0)
     end
 
@@ -204,6 +207,7 @@ RegisterNetEvent('QBCore:Client:OnSharedUpdateMultiple', function(tableName, val
     for key, value in pairs(values) do
         QBX.Shared[tableName][key] = value
     end
+
     TriggerEvent('QBCore:Client:UpdateObject')
 end)
 
@@ -212,6 +216,7 @@ end)
 ---@param props table<any, any>
 RegisterNetEvent('qbx_core:client:setVehicleProperties', function(netId, props)
     if not props then return end
+
     local timeOut = GetGameTimer() + 1000
     local vehicle = NetworkGetEntityFromNetworkId(netId)
     while true do
@@ -220,6 +225,7 @@ RegisterNetEvent('qbx_core:client:setVehicleProperties', function(netId, props)
                 return
             end
         end
+
         if GetGameTimer() > timeOut then
             return
         end
diff --git a/client/functions.lua b/client/functions.lua
index 544b22d01..370cc9550 100644
--- a/client/functions.lua
+++ b/client/functions.lua
@@ -20,8 +20,8 @@ function Notify(text, notifyType, duration, subTitle, notifyPosition, notifyStyl
     else
         description = text
     end
-    local position = notifyPosition or positionConfig
 
+    local position = notifyPosition or positionConfig
     lib.notify({
         id = title,
         title = title,
diff --git a/client/main.lua b/client/main.lua
index 60647773f..0c64f30d8 100644
--- a/client/main.lua
+++ b/client/main.lua
@@ -58,6 +58,7 @@ end)
 
 local mapText = require 'config.client'.pauseMapText
 if mapText == '' or type(mapText) ~= 'string' then mapText = 'FiveM' end
+
 AddTextEntry('FE_THDR_GTAO', mapText)
 
 CreateThread(function()
@@ -81,5 +82,6 @@ lib.callback.register('qbx_core:client:getVehicleClasses', function()
         local class = GetVehicleClassFromName(model)
         classes[joaat(model)] = class
     end
+
     return classes
 end)
\ No newline at end of file
diff --git a/client/vehicle-persistence.lua b/client/vehicle-persistence.lua
index 790826a08..8f0c2c8d7 100644
--- a/client/vehicle-persistence.lua
+++ b/client/vehicle-persistence.lua
@@ -43,14 +43,17 @@ end
 
 local function sendPropsDiff()
     if not Entity(vehicle).state.persisted then return end
+
     local newProps = lib.getVehicleProperties(vehicle)
     if not cachedProps then
         cachedProps = newProps
         return
     end
+
     local diff, hasChanged = calculateDiff(cachedProps, newProps)
     cachedProps = newProps
     if not hasChanged then return end
+
     TriggerServerEvent('qbx_core:server:vehiclePropsChanged', netId, diff)
 end
 
diff --git a/config/server.lua b/config/server.lua
index 21c903025..6429c9312 100644
--- a/config/server.lua
+++ b/config/server.lua
@@ -1,5 +1,6 @@
 return {
-    updateInterval = 5, -- how often to update player data in minutes
+    updateInterval = 5, -- how often to update player thirst and hunger in minutes
+    dbUpdateInterval = 15, -- how often to update the database for player data in seconds
 
     money = {
         ---@alias MoneyType 'cash' | 'bank' | 'crypto'
diff --git a/data/nationalities.lua b/data/nationalities.lua
index 2ab062c24..9e010827b 100644
--- a/data/nationalities.lua
+++ b/data/nationalities.lua
@@ -1,4 +1,4 @@
----@return table<string>
+---@return string[]
 return {
     'Afghan',
     'Albanian',
diff --git a/modules/hooks.lua b/modules/hooks.lua
index 11d98c62f..ce9f8307a 100644
--- a/modules/hooks.lua
+++ b/modules/hooks.lua
@@ -47,6 +47,7 @@ exports('registerHook', function(event, cb)
     cb.hookId = hookId
 
     eventHooks[event][#eventHooks[event] + 1] = cb
+
     return hookId
 end)
 
diff --git a/modules/lib.lua b/modules/lib.lua
index 8706bdc87..0a4e33ee7 100644
--- a/modules/lib.lua
+++ b/modules/lib.lua
@@ -86,7 +86,7 @@ qbx.armsWithoutGloves = lib.table.freeze({
         [157] = true,
         [161] = true,
         [165] = true
-    }),
+    })
 })
 
 ---Returns the given string with its trailing whitespaces removed.
@@ -111,6 +111,7 @@ end
 ---@return number rounded integer if `decimalPlaces` isn't passed, number otherwise
 function qbx.math.round(num, decimalPlaces)
     if not decimalPlaces then return math.floor(num + 0.5) end
+
     local power = 10 ^ decimalPlaces
     return math.floor((num * power) + 0.5) / power
 end
@@ -123,6 +124,7 @@ function qbx.table.size(tbl)
     for _ in pairs(tbl) do
         size += 1
     end
+
     return size
 end
 
@@ -132,10 +134,8 @@ end
 ---@return table<any, table[]>
 function qbx.table.mapBySubfield(tble, subfield)
     local map = {}
-
     for _, subTable in pairs(tble) do
         local subfieldValue = subTable[subfield]
-
         if subfieldValue then
             if not map[subfieldValue] then
                 map[subfieldValue] = {}
@@ -169,6 +169,7 @@ end
 function qbx.getVehiclePlate(vehicle)
     local plate = GetVehicleNumberPlateText(vehicle)
     if not plate then return end
+
     return qbx.string.trim(plate)
 end
 
@@ -309,6 +310,7 @@ if isServer then
             local owner = NetworkGetEntityOwner(veh)
             state:set('initVehicle', true, true)
             netId = NetworkGetNetworkIdFromEntity(veh)
+
             if props and type(props) == 'table' and props.plate then
                 TriggerClientEvent('qbx_core:client:setVehicleProperties', owner, netId, props)
                 local success = pcall(function()
@@ -317,15 +319,18 @@ if isServer then
                         if qbx.string.trim(GetVehicleNumberPlateText(veh)) == qbx.string.trim(props.plate) then
                             local currentOwner = NetworkGetEntityOwner(veh)
                             assert(currentOwner == owner, ('Owner changed during vehicle init. expected=%s, actual=%s'):format(owner, currentOwner))
+
                             --- check that the plate matches twice, 100ms apart as a bug has been observed in which server side matches but plate is not observed by clients to match
                             if plateMatched then
                                 return true
                             end
+
                             plateMatched = true
                             Wait(100)
                         end
                     end, 'Failed to set vehicle properties within 1 second', 1000)
                 end)
+
                 if success then
                     break
                 else
@@ -344,6 +349,7 @@ if isServer then
         --- prevent server from deleting a vehicle without an owner
         SetEntityOrphanMode(veh, 2)
         exports.qbx_core:EnablePersistence(veh)
+
         return netId, veh
     end
 else
@@ -376,9 +382,11 @@ else
         SetTextScale(scale, scale)
         SetTextFont(font)
         SetTextColour(math.floor(color.r), math.floor(color.g), math.floor(color.b), math.floor(color.a))
+
         if enableDropShadow then
             SetTextDropShadow()
         end
+
         if enableOutline then
             SetTextOutline()
         end
@@ -397,12 +405,11 @@ else
     ---Draws text onto the screen in 3D space for a single frame.
     ---@param params LibDrawText3DParams
     function qbx.drawText3d(params) -- luacheck: ignore
-        local isScaleparamANumber = type(params.scale) == "number"
         local text = params.text
         local coords = params.coords
-        local scale = (isScaleparamANumber and vec2(params.scale, params.scale))
-                  or params.scale
-                  or vec2(0.35, 0.35)
+        local scale = type(params.scale) == 'number' and vec2(params.scale --[[@as number]], params.scale --[[@as number]])
+            or params.scale
+            or vec2(0.35, 0.35)
         local font = params.font or 4
         local color = params.color or vec4(255, 255, 255, 255)
         local enableDropShadow = params.enableDropShadow or false
@@ -411,12 +418,15 @@ else
         SetTextScale(scale.x, scale.y)
         SetTextFont(font)
         SetTextColour(math.floor(color.r), math.floor(color.g), math.floor(color.b), math.floor(color.a))
+
         if enableDropShadow then
             SetTextDropShadow()
         end
+
         if enableOutline then
             SetTextOutline()
         end
+
         SetTextCentre(true)
         BeginTextCommandDisplayText('STRING')
         AddTextComponentSubstringPlayerName(text)
@@ -427,6 +437,7 @@ else
             local factor = #text / 370
             DrawRect(0.0, 0.0125, 0.017 + factor, 0.03, 0, 0, 0, 75)
         end
+
         ClearDrawOrigin()
     end
 
@@ -437,7 +448,6 @@ else
     ---@return integer entity, integer netId
     function qbx.getEntityAndNetIdFromBagName(bagName)
         local netId = tonumber(bagName:gsub('entity:', ''), 10)
-
         local entity = lib.waitFor(function()
             if NetworkDoesEntityExistWithNetworkId(netId) then
                 return NetworkGetEntityFromNetworkId(netId)
@@ -472,6 +482,7 @@ else
     function qbx.deleteVehicle(vehicle)
         SetEntityAsMissionEntity(vehicle, true, true)
         DeleteVehicle(vehicle)
+
         return not DoesEntityExist(vehicle)
     end
 
@@ -527,6 +538,7 @@ else
     ---@param enable boolean
     function qbx.setVehicleExtra(vehicle, extra, enable)
         if not DoesExtraExist(vehicle, extra) then return end
+
         SetVehicleExtra(vehicle, extra, not enable)
     end
 
@@ -554,8 +566,9 @@ else
     function qbx.isWearingGloves()
         local armIndex = GetPedDrawableVariation(cache.ped, 3)
         local model = GetEntityModel(cache.ped)
-        local tble = qbx.armsWithoutGloves[model == `mp_m_freemode_01` and 'male' or 'female']
-        return not tble[armIndex]
+        local tbl = qbx.armsWithoutGloves[model == `mp_m_freemode_01` and 'male' or 'female']
+
+        return not tbl[armIndex]
     end
 
     ---Attempts to load an audio bank and returns whether it was successful.
@@ -589,10 +602,9 @@ else
         local returnSoundId = params.returnSoundId or false
         local source = params.audioSource
         local range = params.range or 5.0
-
         local soundId = GetSoundId()
-
         local sourceType = type(source)
+
         if sourceType == 'vector3' then
             local coords = source
             PlaySoundFromCoord(soundId, audioName, coords.x, coords.y, coords.z, audioRef, false, range, false)
diff --git a/modules/logger.lua b/modules/logger.lua
index bb76e1447..d24d6619d 100644
--- a/modules/logger.lua
+++ b/modules/logger.lua
@@ -25,7 +25,6 @@ local Colors = { -- https://www.spycolor.com/
 local function applyRequestDelay()
     local currentTime = GetGameTimer()
     local timeDiff = currentTime - lastRequestTime
-
     if timeDiff < requestDelay then
         local remainingDelay = requestDelay - timeDiff
 
@@ -69,11 +68,9 @@ local function logPayload(payload)
 
         local remainingRequests = tonumber(headers['X-RateLimit-Remaining'])
         local resetTime = tonumber(headers['X-RateLimit-Reset'])
-
         if remainingRequests and resetTime and remainingRequests == 0 then
             local currentTime = os.time()
             local resetDelay = resetTime - currentTime
-
             if resetDelay > 0 then
                 requestDelay = resetDelay * 1000 / 10
             end
@@ -147,6 +144,7 @@ local function createLog(log)
         ---@diagnostic disable-next-line: param-type-mismatch
         discordLog(log)
     end
+
     lib.logger(log.source, log.event, log.message, log.oxLibTags) -- support for ox_lib: datadog, grafana loki logging, fivemanage
 end
 
diff --git a/server/character.lua b/server/character.lua
index 70a903aab..83f1d2c76 100644
--- a/server/character.lua
+++ b/server/character.lua
@@ -15,6 +15,7 @@ local function giveStarterItems(source)
     while not exports.ox_inventory:GetInventory(source) do
         Wait(100)
     end
+
     for i = 1, #starterItems do
         local item = starterItems[i]
         if item.metadata and type(item.metadata) == 'function' then
@@ -43,11 +44,12 @@ lib.callback.register('qbx_core:server:loadCharacter', function(source, citizenI
 
     logger.log({
         source = 'qbx_core',
-        webhook = config.logging.webhook['joinleave'],
+        webhook = config.logging.webhook.joinleave,
         event = 'Loaded',
         color = 'green',
         message = ('**%s** (%s |  ||%s|| | %s | %s | %s) loaded'):format(GetPlayerName(source), GetPlayerIdentifierByType(source, 'discord') or 'undefined', GetPlayerIdentifierByType(source, 'ip') or 'undefined', GetPlayerIdentifierByType(source, 'license2') or GetPlayerIdentifierByType(source, 'license') or 'undefined', citizenId, source)
     })
+
     lib.print.info(('%s (Citizen ID: %s ID: %s) has successfully loaded!'):format(GetPlayerName(source), citizenId, source))
 end)
 
@@ -63,6 +65,7 @@ lib.callback.register('qbx_core:server:createCharacter', function(source, data)
     giveStarterItems(source)
 
     lib.print.info(('%s has created a character'):format(GetPlayerName(source)))
+
     return newData
 end)
 
diff --git a/server/commands.lua b/server/commands.lua
index 7c7e9047f..6b6677cc3 100644
--- a/server/commands.lua
+++ b/server/commands.lua
@@ -171,7 +171,6 @@ lib.addCommand('dv', {
     local ped = GetPlayerPed(source)
     local pedCars = {GetVehiclePedIsIn(ped, false)}
     local radius = args[locale('command.dv.params.radius.name')]
-
     if pedCars[1] == 0 or radius then -- Only execute when player is not in a vehicle or radius is explicitly defined
         pedCars = lib.callback.await('qbx_core:client:getVehiclesInRadius', source, radius)
     else
@@ -382,6 +381,7 @@ lib.addCommand('me', {
     args[1] = args[locale('command.me.params.message.name')]
     args[locale('command.me.params.message.name')] = nil
     if #args < 1 then Notify(source, locale('error.missing_args2'), 'error') return end
+
     local msg = table.concat(args, ' '):gsub('[~<].-[>~]', '')
     local playerState = Player(source).state
     playerState:set('me', msg, true)
diff --git a/server/events.lua b/server/events.lua
index 1574a4a61..0ffe33f8e 100644
--- a/server/events.lua
+++ b/server/events.lua
@@ -21,10 +21,13 @@ AddEventHandler('playerJoining', function()
     local src = source --[[@as string]]
     local license = GetPlayerIdentifierByType(src, 'license2') or GetPlayerIdentifierByType(src, 'license')
     if not license then return end
+
     if queue then
         queue.removePlayerJoining(license)
     end
+
     if not serverConfig.checkDuplicateLicense then return end
+
     if usedLicenses[license] then
         Wait(0) -- mandatory wait for the drop reason to show up
         DropPlayer(src, locale('error.duplicate_license'))
@@ -38,31 +41,33 @@ AddEventHandler('playerDropped', function(reason)
     local src = source --[[@as string]]
     local license = GetPlayerIdentifierByType(src, 'license2') or GetPlayerIdentifierByType(src, 'license')
     if license then usedLicenses[license] = nil end
+
     if not QBX.Players[src] then return end
+
     GlobalState.PlayerCount -= 1
+
     local player = QBX.Players[src]
-    player.PlayerData.lastLoggedOut = os.time()
+    SetPlayerData(player.PlayerData.source, 'lastLoggedOut', nil, os.time())
+
     logger.log({
         source = 'qbx_core',
-        webhook = loggingConfig.webhook['joinleave'],
+        webhook = loggingConfig.webhook.joinleave,
         event = 'Dropped',
         color = 'red',
         message = ('**%s** (%s) left...\n **Reason:** %s'):format(GetPlayerName(src), player.PlayerData.license, reason),
     })
-    player.Functions.Save()
+
     QBX.Player_Buckets[player.PlayerData.license] = nil
     QBX.Players[src] = nil
 end)
 
----@param source Source|string
+---@param source Source | string
 ---@return table<string, string>
 local function getIdentifiers(source)
     local identifiers = {}
-
     for i = 0, GetNumPlayerIdentifiers(source --[[@as string]]) - 1 do
         local identifier = GetPlayerIdentifier(source --[[@as string]], i)
         local prefix = identifier:match('([^:]+)')
-
         if prefix ~= 'ip' then
             identifiers[prefix] = identifier
         end
@@ -98,9 +103,7 @@ local function onPlayerConnecting(name, _, deferrals)
 
     if not userId then
         local identifiers = getIdentifiers(src)
-
         identifiers.username = name
-
         storage.createUser(identifiers)
     end
 
@@ -131,6 +134,7 @@ local function onPlayerConnecting(name, _, deferrals)
         if not success then
             databasePromise:reject(err)
         end
+
         databasePromise:resolve()
     end)
 
@@ -225,6 +229,7 @@ RegisterNetEvent('QBCore:ToggleDuty', function()
     local src = source --[[@as Source]]
     local player = GetPlayer(src)
     if not player then return end
+
     if player.PlayerData.job.onduty then
         player.Functions.SetJobDuty(false)
         Notify(src, locale('info.off_duty'))
@@ -240,11 +245,13 @@ end)
 ---@param value number
 local function playerStateBagCheck(bagName, meta, value)
     if not value then return end
+
     local plySrc = GetPlayerFromStateBagName(bagName)
     if not plySrc then return end
+
     local player = QBX.Players[plySrc]
-    if not player then return end
-    if player.PlayerData.metadata[meta] == value then return end
+    if not player or player.PlayerData.metadata[meta] == value then return end
+
     player.Functions.SetMetaData(meta, value)
 end
 
diff --git a/server/functions.lua b/server/functions.lua
index 5874397bb..8afb637d2 100644
--- a/server/functions.lua
+++ b/server/functions.lua
@@ -9,45 +9,46 @@ local storage = require 'server.storage.main'
 -- ex: local player = GetPlayer(source)
 -- ex: local example = player.functionname(parameter)
 
----@alias Identifier 'steam'|'license'|'license2'|'xbl'|'ip'|'discord'|'live'
-
----@param identifier Identifier
+---@param identifier string
 ---@return integer source of the player with the matching identifier or 0 if no player found
 function GetSource(identifier)
     for src in pairs(QBX.Players) do
         local idens = GetPlayerIdentifiers(src)
-        for _, id in pairs(idens) do
-            if identifier == id then
+        for i = 1, #idens do
+            if identifier == idens[i] then
                 return src
             end
         end
     end
+
     return 0
 end
 
 exports('GetSource', GetSource)
 
----@param identifier Identifier
+---@param identifier string
 ---@return integer source of the player with the matching identifier or 0 if no player found
 function GetUserId(identifier)
     for src in pairs(QBX.Players) do
         local idens = GetPlayerIdentifiers(src)
-        for _, id in pairs(idens) do
-            if identifier == id then
+        for i = 1, #idens do
+            if identifier == idens[i] then
                 return QBX.Players[src].PlayerData.userId
             end
         end
     end
+
     return 0
 end
 
 exports('GetUserId', GetUserId)
 
----@param source Source|string source or identifier of the player
+---@param source Source | string source or identifier of the player
 ---@return Player
 function GetPlayer(source)
-    if tonumber(source) ~= nil then
-        return QBX.Players[tonumber(source)]
+    local numberSource = tonumber(source)
+    if numberSource then
+        return QBX.Players[numberSource]
     else
         return QBX.Players[GetSource(source --[[@as string]])]
     end
@@ -108,13 +109,12 @@ function GetDutyCountJob(job)
     local players = {}
     local count = 0
     for src, player in pairs(QBX.Players) do
-        if player.PlayerData.job.name == job then
-            if player.PlayerData.job.onduty then
-                players[#players + 1] = src
-                count += 1
-            end
+        if player.PlayerData.job.name == job and player.PlayerData.job.onduty then
+            players[#players + 1] = src
+            count += 1
         end
     end
+
     return count, players
 end
 
@@ -128,13 +128,12 @@ function GetDutyCountType(type)
     local players = {}
     local count = 0
     for src, player in pairs(QBX.Players) do
-        if player.PlayerData.job.type == type then
-            if player.PlayerData.job.onduty then
-                players[#players + 1] = src
-                count += 1
-            end
+        if player.PlayerData.job.type == type and player.PlayerData.job.onduty then
+            players[#players + 1] = src
+            count += 1
         end
     end
+
     return count, players
 end
 
@@ -156,11 +155,12 @@ exports('GetBucketObjects', GetBucketObjects)
 ---@param bucket integer
 ---@return boolean
 function SetPlayerBucket(source, bucket)
-    if not (source or bucket) then return false end
+    if not source or not bucket then return false end
 
     Player(source).state:set('instance', bucket, true)
     SetPlayerRoutingBucket(source --[[@as string]], bucket)
     QBX.Player_Buckets[source] = bucket
+
     return true
 end
 
@@ -171,10 +171,11 @@ exports('SetPlayerBucket', SetPlayerBucket)
 ---@param bucket integer
 ---@return boolean
 function SetEntityBucket(entity, bucket)
-    if not (entity or bucket) then return false end
+    if not entity or not bucket then return false end
 
     SetEntityRoutingBucket(entity, bucket)
     QBX.Entity_Buckets[entity] = bucket
+
     return true
 end
 
@@ -185,7 +186,7 @@ exports('SetEntityBucket', SetEntityBucket)
 ---@return Source[]|boolean
 function GetPlayersInBucket(bucket)
     local curr_bucket_pool = {}
-    if not (QBX.Player_Buckets or next(QBX.Player_Buckets)) then
+    if not QBX.Player_Buckets or table.type(QBX.Player_Buckets) == 'empty' then
         return false
     end
 
@@ -205,7 +206,7 @@ exports('GetPlayersInBucket', GetPlayersInBucket)
 ---@return boolean | integer[]
 function GetEntitiesInBucket(bucket)
     local curr_bucket_pool = {}
-    if not (QBX.Entity_Buckets or next(QBX.Entity_Buckets)) then
+    if not QBX.Entity_Buckets or table.type(QBX.Entity_Buckets) == 'empty' then
         return false
     end
 
@@ -241,9 +242,7 @@ exports('CanUseItem', CanUseItem)
 ---@param source Source
 ---@return boolean
 function IsWhitelisted(source)
-    if not serverConfig.whitelist then return true end
-    if IsPlayerAceAllowed(source --[[@as string]], serverConfig.whitelistPermission) then return true end
-    return false
+    return not serverConfig.whitelist or IsPlayerAceAllowed(source --[[@as string]], serverConfig.whitelistPermission)
 end
 
 exports('IsWhitelisted', IsWhitelisted)
@@ -254,12 +253,12 @@ exports('IsWhitelisted', IsWhitelisted)
 ---@param source Source
 ---@param permission string
 function AddPermission(source, permission)
-    if not IsPlayerAceAllowed(source --[[@as string]], permission) then
-        lib.addPrincipal('player.' .. source, 'group.' .. permission)
-        lib.addAce('player.' .. source, 'group.' .. permission)
-        TriggerClientEvent('QBCore:Client:OnPermissionUpdate', source)
-        TriggerEvent('QBCore:Server:OnPermissionUpdate', source)
-    end
+    if IsPlayerAceAllowed(source --[[@as string]], permission) then return end
+
+    lib.addPrincipal('player.' .. source, 'group.' .. permission)
+    lib.addAce('player.' .. source, 'group.' .. permission)
+    TriggerClientEvent('QBCore:Client:OnPermissionUpdate', source)
+    TriggerEvent('QBCore:Server:OnPermissionUpdate', source)
 end
 
 ---@deprecated use cfg ACEs instead
@@ -332,6 +331,7 @@ function GetPermission(source)
             perms[v] = true
         end
     end
+
     return perms
 end
 
@@ -345,7 +345,9 @@ exports('GetPermission', GetPermission)
 function IsOptin(source)
     local license = GetPlayerIdentifierByType(source --[[@as string]], 'license2') or GetPlayerIdentifierByType(source --[[@as string]], 'license')
     if not license or not IsPlayerAceAllowed(source --[[@as string]], 'admin') then return false end
+
     local player = GetPlayer(source)
+
     return player.PlayerData.optin
 end
 
@@ -356,9 +358,10 @@ exports('IsOptin', IsOptin)
 function ToggleOptin(source)
     local license = GetPlayerIdentifierByType(source --[[@as string]], 'license2') or GetPlayerIdentifierByType(source --[[@as string]], 'license')
     if not license or not IsPlayerAceAllowed(source --[[@as string]], 'admin') then return end
+
     local player = GetPlayer(source)
     player.PlayerData.optin = not player.PlayerData.optin
-    player.Functions.SetPlayerData('optin', player.PlayerData.optin)
+    SetPlayerData(player.PlayerData.source, 'optin', nil, player.PlayerData.optin, nil, true)
 end
 
 exports('ToggleOptin', ToggleOptin)
@@ -456,7 +459,7 @@ local function ExploitBan(playerId, origin)
     DropPlayer(playerId --[[@as string]], locale('info.exploit_banned', serverConfig.discord))
     logger.log({
         source = 'qbx_core',
-        webhook = loggingConfig.webhook['anticheat'],
+        webhook = loggingConfig.webhook.anticheat,
         event = 'Anti-Cheat',
         color = 'red',
         tags = loggingConfig.role,
@@ -551,4 +554,4 @@ function DeleteVehicle(vehicle)
     end
 end
 
-exports('DeleteVehicle', DeleteVehicle)
+exports('DeleteVehicle', DeleteVehicle)
\ No newline at end of file
diff --git a/server/groups.lua b/server/groups.lua
index 8ca4dced5..7dafba21a 100644
--- a/server/groups.lua
+++ b/server/groups.lua
@@ -50,6 +50,7 @@ function RemoveJob(jobName)
     jobs[jobName] = nil
     TriggerEvent('qbx_core:server:onJobUpdate', jobName, nil)
     TriggerClientEvent('qbx_core:client:onJobUpdate', -1, jobName, nil)
+
     return true, 'success'
 end
 
@@ -84,6 +85,7 @@ function RemoveGang(gangName)
 
     TriggerEvent('qbx_core:server:onGangUpdate', gangName, nil)
     TriggerClientEvent('qbx_core:client:onGangUpdate', -1, gangName, nil)
+
     return true, 'success'
 end
 
@@ -136,6 +138,7 @@ local function upsertJobData(name, data)
             grades = {},
         }
     end
+
     TriggerEvent('qbx_core:server:onJobUpdate', name, jobs[name])
     TriggerClientEvent('qbx_core:client:onJobUpdate', -1, name, jobs[name])
 end
@@ -153,6 +156,7 @@ local function upsertGangData(name, data)
             grades = {},
         }
     end
+
     TriggerEvent('qbx_core:server:onGangUpdate', name, gangs[name])
     TriggerClientEvent('qbx_core:client:onGangUpdate', -1, name, gangs[name])
 end
@@ -167,6 +171,7 @@ local function upsertJobGrade(name, grade, data)
         lib.print.error('Job must exist to edit grades. Not found:', name)
         return
     end
+
     jobs[name].grades[grade] = data
     TriggerEvent('qbx_core:server:onJobUpdate', name, jobs[name])
     TriggerClientEvent('qbx_core:client:onJobUpdate', -1, name, jobs[name])
@@ -182,6 +187,7 @@ local function upsertGangGrade(name, grade, data)
         lib.print.error('Gang must exist to edit grades. Not found:', name)
         return
     end
+
     gangs[name].grades[grade] = data
     TriggerEvent('qbx_core:server:onGangUpdate', name, gangs[name])
     TriggerClientEvent('qbx_core:client:onGangUpdate', -1, name, gangs[name])
@@ -196,6 +202,7 @@ local function removeJobGrade(name, grade)
         lib.print.error('Job must exist to edit grades. Not found:', name)
         return
     end
+
     jobs[name].grades[grade] = nil
     TriggerEvent('qbx_core:server:onJobUpdate', name, jobs[name])
     TriggerClientEvent('qbx_core:client:onJobUpdate', -1, name, jobs[name])
@@ -210,6 +217,7 @@ local function removeGangGrade(name, grade)
         lib.print.error('Gang must exist to edit grades. Not found:', name)
         return
     end
+
     gangs[name].grades[grade] = nil
     TriggerEvent('qbx_core:server:onGangUpdate', name, gangs[name])
     TriggerClientEvent('qbx_core:client:onGangUpdate', -1, name, gangs[name])
diff --git a/server/loops.lua b/server/loops.lua
index eb64c5502..8621bdd47 100644
--- a/server/loops.lua
+++ b/server/loops.lua
@@ -1,49 +1,62 @@
 local config = require 'config.server'
+local storage = require 'server.storage.main'
 
 local function removeHungerAndThirst(src, player)
     local playerState = Player(src).state
     if not playerState.isLoggedIn then return end
+
     local newHunger = playerState.hunger - config.player.hungerRate
     local newThirst = playerState.thirst - config.player.thirstRate
 
     player.Functions.SetMetaData('thirst', math.max(0, newThirst))
     player.Functions.SetMetaData('hunger', math.max(0, newHunger))
-
-    player.Functions.Save()
 end
 
-CreateThread(function()
-    local interval = 60000 * config.updateInterval
-    while true do
-        Wait(interval)
-        for src, player in pairs(QBX.Players) do
-            removeHungerAndThirst(src, player)
-        end
-    end
-end)
-
 local function pay(player)
     local job = player.PlayerData.job
     local payment = GetJob(job.name).grades[job.grade.level].payment or job.payment
     if payment <= 0 then return end
+
     if not GetJob(job.name).offDutyPay and not job.onduty then return end
+
     if not config.money.paycheckSociety then
         config.sendPaycheck(player, payment)
         return
     end
+
     local account = config.getSocietyAccount(job.name)
     if not account then -- Checks if player is employed by a society
         config.sendPaycheck(player, payment)
         return
     end
+
     if account < payment then -- Checks if company has enough money to pay society
         Notify(player.PlayerData.source, locale('error.company_too_poor'), 'error')
         return
     end
+
     config.removeSocietyMoney(job.name, payment)
     config.sendPaycheck(player, payment)
 end
 
+CreateThread(function()
+    local interval = 60000 * config.updateInterval
+    while true do
+        Wait(interval)
+        for src, player in pairs(QBX.Players) do
+            removeHungerAndThirst(src, player)
+        end
+    end
+end)
+
+CreateThread(function()
+    local interval = 60 * config.dbUpdateInterval
+    while true do
+        Wait(interval)
+        storage.sendPlayerDataUpdates()
+    end
+end)
+
 CreateThread(function()
     local interval = 60000 * config.money.paycheckTimeout
     while true do
diff --git a/server/main.lua b/server/main.lua
index 283383b4a..68c0baf7d 100644
--- a/server/main.lua
+++ b/server/main.lua
@@ -9,16 +9,19 @@ elseif GetConvar('inventory:framework', '') ~= 'qbx' then
 elseif GetConvarInt('onesync_enableInfinity', 0) ~= 1 then
     startupErrors, errorMessage = true, 'OneSync Infinity is not enabled. You can do so in txAdmin settings or add +set onesync on to your server startup command line'
 end
+
 if startupErrors then
     lib.print.error('Startup errors detected, shutting down server...')
     ExecuteCommand('quit immediately')
+
     for _ = 1, 100 do
         lib.print.error(errorMessage)
     end
+
     error(errorMessage)
 end
 
----@type 'strict'|'relaxed'|'inactive'
+---@type 'strict' | 'relaxed' | 'inactive'
 local bucketLockDownMode = GetConvar('qbx:bucketlockdownmode', 'inactive')
 SetRoutingBucketEntityLockdownMode(0, bucketLockDownMode)
 
@@ -51,6 +54,7 @@ local function createSessionId(entity)
     if existingSessionId then
         return existingSessionId
     end
+
     currentSessionId += 1
     local sessionId = currentSessionId
     Entity(entity).state:set('sessionId', sessionId, true)
@@ -75,6 +79,7 @@ function GetVehicleClass(model)
             repeat
                 local players = GetPlayers()
                 if #players == 0 then break end
+
                 local playerId = players[math.random(#players)]
                 -- this *may* fail, but we still need to resolve our promise
                 pcall(function()
@@ -92,6 +97,7 @@ function GetVehicleClass(model)
             vehicleClassesPromise:resolve()
         end
     end
+
     return vehicleClasses[model]
 end
 
diff --git a/server/motd.lua b/server/motd.lua
index 2bb3f2b50..d46c43146 100644
--- a/server/motd.lua
+++ b/server/motd.lua
@@ -71,10 +71,8 @@ To turn this message off, set the ^3qbx:acknowledge^4 convar to true in your ser
     local serviceMessages = json.decode(Citizen.Await(requestPromise))
     if type(serviceMessages) == 'table' then
         local hasServiceMessage = false
-
         for i = 1, #serviceMessages do
             local message = serviceMessages[i]
-
             if type(message) == 'table' and message.content and isResourceVersion(message.version) then
                 if not hasServiceMessage then
                     hasServiceMessage = true
diff --git a/server/player.lua b/server/player.lua
index c0efeda32..648e2bea9 100644
--- a/server/player.lua
+++ b/server/player.lua
@@ -34,6 +34,7 @@ function Login(source, citizenid, newData)
             tags = config.logging.role,
             message = ('%s [%s] Dropped for attempting to login twice'):format(GetPlayerName(tostring(source)), tostring(source))
         })
+
         return false
     end
 
@@ -43,6 +44,7 @@ function Login(source, citizenid, newData)
         lib.print.error('User does not exist. Licenses checked:', license2, license)
         return false
     end
+
     if citizenid then
         local playerData = storage.fetchPlayerEntity(citizenid)
         if playerData and (playerData.license == license2 or playerData.license == license) then
@@ -76,8 +78,10 @@ exports('Login', Login)
 ---@return Player? player if found in storage
 function GetOfflinePlayer(citizenid)
     if not citizenid then return end
+
     local playerData = storage.fetchPlayerEntity(citizenid)
     if not playerData then return end
+
     return CheckPlayerData(nil, playerData)
 end
 
@@ -94,7 +98,6 @@ function SetJob(identifier, jobName, grade)
     grade = tonumber(grade) or 0
 
     local job = GetJob(jobName)
-
     if not job then
         lib.print.error(('cannot set job. Job %s does not exist'):format(jobName))
 
@@ -108,7 +111,6 @@ function SetJob(identifier, jobName, grade)
     end
 
     local player = type(identifier) == 'string' and (GetPlayerByCitizenId(identifier) or GetOfflinePlayer(identifier)) or GetPlayer(identifier)
-
     if setJobReplaces and player.PlayerData.job.name ~= 'unemployed' then
         local success, errorResult = RemovePlayerFromJob(player.PlayerData.citizenid, player.PlayerData.job.name)
 
@@ -133,18 +135,10 @@ exports('SetJob', SetJob)
 ---@param identifier Source | string
 ---@param onDuty boolean
 function SetJobDuty(identifier, onDuty)
-    local player = type(identifier) == 'string' and (GetPlayerByCitizenId(identifier) or GetOfflinePlayer(identifier)) or GetPlayer(identifier)
-
-    if not player then return end
-
-    player.PlayerData.job.onduty = not not onDuty
-
-    if player.Offline then return end
-
-    TriggerEvent('QBCore:Server:SetDuty', player.PlayerData.source, player.PlayerData.job.onduty)
-    TriggerClientEvent('QBCore:Client:SetDuty', player.PlayerData.source, player.PlayerData.job.onduty)
-
-    UpdatePlayerData(identifier)
+    SetPlayerData(identifier, 'job', {'onduty'}, not not onDuty, function()
+        TriggerEvent('QBCore:Server:SetDuty', player.PlayerData.source, player.PlayerData.job.onduty)
+        TriggerClientEvent('QBCore:Client:SetDuty', player.PlayerData.source, player.PlayerData.job.onduty)
+    end)
 end
 
 exports('SetJobDuty', SetJobDuty)
@@ -202,14 +196,10 @@ function SetPlayerPrimaryJob(citizenid, jobName)
 
     player.PlayerData.job = toPlayerJob(jobName, job, grade)
 
-    if player.Offline then
-        SaveOffline(player.PlayerData)
-    else
-        Save(player.PlayerData.source)
-        UpdatePlayerData(player.PlayerData.source)
+    SetPlayerData(player.PlayerData.source, 'job', nil, player.PlayerData.job, function()
         TriggerEvent('QBCore:Server:OnJobUpdate', player.PlayerData.source, player.PlayerData.job)
         TriggerClientEvent('QBCore:Client:OnJobUpdate', player.PlayerData.source, player.PlayerData.job)
-    end
+    end)
 
     return true
 end
@@ -270,12 +260,11 @@ function AddPlayerToJob(citizenid, jobName, grade)
 
     storage.addPlayerToJob(citizenid, jobName, grade)
 
-    if not player.Offline then
-        player.PlayerData.jobs[jobName] = grade
-        SetPlayerData(player.PlayerData.source, 'jobs', player.PlayerData.jobs)
+    player.PlayerData.jobs[jobName] = grade
+    SetPlayerData(player.PlayerData.source, 'jobs', nil, player.PlayerData.jobs, function()
         TriggerEvent('qbx_core:server:onGroupUpdate', player.PlayerData.source, jobName, grade)
         TriggerClientEvent('qbx_core:client:onGroupUpdate', player.PlayerData.source, jobName, grade)
-    end
+    end, true)
 
     if player.PlayerData.job.name == jobName then
         SetPlayerPrimaryJob(citizenid, jobName)
@@ -317,19 +306,14 @@ function RemovePlayerFromJob(citizenid, jobName)
     if player.PlayerData.job.name == jobName then
         local job = GetJob('unemployed')
         assert(job ~= nil, 'cannot find unemployed job. Does it exist in shared/jobs.lua?')
-        player.PlayerData.job = toPlayerJob('unemployed', job, 0)
-        if player.Offline then
-            SaveOffline(player.PlayerData)
-        else
-            Save(player.PlayerData.source)
-        end
+
+        SetPlayerData(player.PlayerData.source, 'job', nil, toPlayerJob('unemployed', job, 0))
     end
 
-    if not player.Offline then
-        SetPlayerData(player.PlayerData.source, 'jobs', player.PlayerData.jobs)
+    SetPlayerData(player.PlayerData.source, 'jobs', nil, player.PlayerData.jobs, function()
         TriggerEvent('qbx_core:server:onGroupUpdate', player.PlayerData.source, jobName)
         TriggerClientEvent('qbx_core:client:onGroupUpdate', player.PlayerData.source, jobName)
-    end
+    end, true)
 
     return true
 end
@@ -347,7 +331,6 @@ function SetGang(identifier, gangName, grade)
     grade = tonumber(grade) or 0
 
     local gang = GetGang(gangName)
-
     if not gang then
         lib.print.error(('cannot set gang. Gang %s does not exist'):format(gangName))
 
@@ -364,7 +347,6 @@ function SetGang(identifier, gangName, grade)
 
     if setGangReplaces and player.PlayerData.gang.name ~= 'none' then
         local success, errorResult = RemovePlayerFromGang(player.PlayerData.citizenid, player.PlayerData.gang.name)
-
         if not success then
             return false, errorResult
         end
@@ -372,7 +354,6 @@ function SetGang(identifier, gangName, grade)
 
     if gangName ~= 'none' then
         local success, errorResult = AddPlayerToGang(player.PlayerData.citizenid, gangName, grade)
-
         if not success then
             return false, errorResult
         end
@@ -415,7 +396,7 @@ function SetPlayerPrimaryGang(citizenid, gangName)
 
     assert(gang.grades[grade] ~= nil, ('gang %s does not have grade %s'):format(gangName, grade))
 
-    player.PlayerData.gang = {
+    SetPlayerData(player.PlayerData.source, 'gang', nil, {
         name = gangName,
         label = gang.label,
         isboss = gang.grades[grade].isboss,
@@ -423,16 +404,10 @@ function SetPlayerPrimaryGang(citizenid, gangName)
             name = gang.grades[grade].name,
             level = grade
         }
-    }
-
-    if player.Offline then
-        SaveOffline(player.PlayerData)
-    else
-        Save(player.PlayerData.source)
-        UpdatePlayerData(player.PlayerData.source)
+    }, function()
         TriggerEvent('QBCore:Server:OnGangUpdate', player.PlayerData.source, player.PlayerData.gang)
         TriggerClientEvent('QBCore:Client:OnGangUpdate', player.PlayerData.source, player.PlayerData.gang)
-    end
+    end)
 
     return true
 end
@@ -492,12 +467,11 @@ function AddPlayerToGang(citizenid, gangName, grade)
 
     storage.addPlayerToGang(citizenid, gangName, grade)
 
-    if not player.Offline then
-        player.PlayerData.gangs[gangName] = grade
-        SetPlayerData(player.PlayerData.source, 'gangs', player.PlayerData.gangs)
+    player.PlayerData.gangs[gangName] = grade
+    SetPlayerData(player.PlayerData.source, 'gangs', nil, player.PlayerData.gangs, function()
         TriggerEvent('qbx_core:server:onGroupUpdate', player.PlayerData.source, gangName, grade)
         TriggerClientEvent('qbx_core:client:onGroupUpdate', player.PlayerData.source, gangName, grade)
-    end
+    end, true)
 
     if player.PlayerData.gang.name == gangName then
         SetPlayerPrimaryGang(citizenid, gangName)
@@ -539,7 +513,8 @@ function RemovePlayerFromGang(citizenid, gangName)
     if player.PlayerData.gang.name == gangName then
         local gang = GetGang('none')
         assert(gang ~= nil, 'cannot find none gang. Does it exist in shared/gangs.lua?')
-        player.PlayerData.gang = {
+
+        SetPlayerData(player.PlayerData.source, 'gang', nil, {
             name = 'none',
             label = gang.label,
             isboss = false,
@@ -547,19 +522,13 @@ function RemovePlayerFromGang(citizenid, gangName)
                 name = gang.grades[0].name,
                 level = 0
             }
-        }
-        if player.Offline then
-            SaveOffline(player.PlayerData)
-        else
-            Save(player.PlayerData.source)
-        end
+        })
     end
 
-    if not player.Offline then
-        SetPlayerData(player.PlayerData.source, 'gangs', player.PlayerData.gangs)
+    SetPlayerData(player.PlayerData.source, 'gangs', nil, player.PlayerData.gangs, function()
         TriggerEvent('qbx_core:server:onGroupUpdate', player.PlayerData.source, gangName)
         TriggerClientEvent('qbx_core:client:onGroupUpdate', player.PlayerData.source, gangName)
-    end
+    end, true)
 
     return true
 end
@@ -573,12 +542,13 @@ function CheckPlayerData(source, playerData)
     playerData = playerData or {}
     ---@diagnostic disable-next-line: param-type-mismatch
     local playerState = Player(source)?.state
-    local Offline = true
+    local isOffline = true
+
     if source then
         playerData.source = source
         playerData.license = playerData.license or GetPlayerIdentifierByType(source --[[@as string]], 'license2') or GetPlayerIdentifierByType(source --[[@as string]], 'license')
         playerData.name = GetPlayerName(source)
-        Offline = false
+        isOffline = false
     end
 
     playerData.userId = playerData.userId or nil
@@ -586,6 +556,7 @@ function CheckPlayerData(source, playerData)
     playerData.cid = playerData.charinfo?.cid or playerData.cid or 1
     playerData.money = playerData.money or {}
     playerData.optin = playerData.optin or true
+
     for moneytype, startamount in pairs(config.money.moneyTypes) do
         playerData.money[moneytype] = playerData.money[moneytype] or startamount
     end
@@ -601,12 +572,14 @@ function CheckPlayerData(source, playerData)
     playerData.charinfo.phone = playerData.charinfo.phone or GenerateUniqueIdentifier('PhoneNumber')
     playerData.charinfo.account = playerData.charinfo.account or GenerateUniqueIdentifier('AccountNumber')
     playerData.charinfo.cid = playerData.charinfo.cid or playerData.cid
+
     -- Metadata
     playerData.metadata = playerData.metadata or {}
     playerData.metadata.health = playerData.metadata.health or 200
     playerData.metadata.hunger = playerData.metadata.hunger or 100
     playerData.metadata.thirst = playerData.metadata.thirst or 100
     playerData.metadata.stress = playerData.metadata.stress or 0
+
     if playerState then
         playerState:set('hunger', playerData.metadata.hunger, true)
         playerState:set('thirst', playerData.metadata.thirst, true)
@@ -655,10 +628,11 @@ function CheckPlayerData(source, playerData)
         SerialNumber = GenerateUniqueIdentifier('SerialNumber'),
         InstalledApps = {},
     }
-    local jobs, gangs = storage.fetchPlayerGroups(playerData.citizenid)
 
+    local jobs, gangs = storage.fetchPlayerGroups(playerData.citizenid)
     local job = GetJob(playerData.job?.name) or GetJob('unemployed')
     assert(job ~= nil, 'Unemployed job not found. Does it exist in shared/jobs.lua?')
+
     local jobGrade = GetJob(playerData.job?.name) and playerData.job.grade.level or 0
 
     playerData.job = {
@@ -678,9 +652,12 @@ function CheckPlayerData(source, playerData)
     end
 
     playerData.jobs = jobs or {}
+
     local gang = GetGang(playerData.gang?.name) or GetGang('none')
     assert(gang ~= nil, 'none gang not found. Does it exist in shared/gangs.lua?')
+
     local gangGrade = GetGang(playerData.gang?.name) and playerData.gang.grade.level or 0
+
     playerData.gang = {
         name = playerData.gang?.name or 'none',
         label = gang.label,
@@ -690,10 +667,12 @@ function CheckPlayerData(source, playerData)
             level = gangGrade
         }
     }
+
     playerData.gangs = gangs or {}
     playerData.position = playerData.position or defaultSpawn
     playerData.items = {}
-    return CreatePlayer(playerData --[[@as PlayerData]], Offline)
+
+    return CreatePlayer(playerData --[[@as PlayerData]], isOffline)
 end
 
 ---On player logout
@@ -701,6 +680,7 @@ end
 function Logout(source)
     local player = GetPlayer(source)
     if not player then return end
+
     local playerState = Player(source)?.state
     player.PlayerData.metadata.hunger = playerState?.hunger or player.PlayerData.metadata.hunger
     player.PlayerData.metadata.thirst = playerState?.thirst or player.PlayerData.metadata.thirst
@@ -733,17 +713,18 @@ function CreatePlayer(playerData, Offline)
     self.PlayerData = playerData
     self.Offline = Offline
 
-    ---@deprecated use UpdatePlayerData instead
+    ---@deprecated exports.qbx_core:SetPlayerData calls these events automatically
     function self.Functions.UpdatePlayerData()
         if self.Offline then
             lib.print.warn('UpdatePlayerData is unsupported for offline players')
             return
         end
 
-        UpdatePlayerData(self.PlayerData.source)
+        TriggerEvent('QBCore:Player:SetPlayerData', self.PlayerData)
+        TriggerClientEvent('QBCore:Player:SetPlayerData', self.PlayerData.source, self.PlayerData)
     end
 
-    ---@deprecated use SetJob instead
+    ---@deprecated use exports.qbx_core:SetJob instead
     ---Overwrites current primary job with a new job. Removing the player from their current primary job
     ---@param jobName string name
     ---@param grade? integer defaults to 0
@@ -753,7 +734,7 @@ function CreatePlayer(playerData, Offline)
         return SetJob(self.PlayerData.source, jobName, grade)
     end
 
-    ---@deprecated use SetGang instead
+    ---@deprecated use exports.qbx_core:SetGang instead
     ---Removes the player from their current primary gang and adds the player to the new gang
     ---@param gangName string name
     ---@param grade? integer defaults to 0
@@ -763,46 +744,44 @@ function CreatePlayer(playerData, Offline)
         return SetGang(self.PlayerData.source, gangName, grade)
     end
 
-    ---@deprecated use SetJobDuty instead
+    ---@deprecated use exports.qbx_core:SetJobDuty instead
     ---@param onDuty boolean
     function self.Functions.SetJobDuty(onDuty)
         SetJobDuty(self.PlayerData.source, onDuty)
     end
 
-    ---@deprecated use SetPlayerData instead
+    ---@deprecated use exports.qbx_core:SetPlayerData instead
     ---@param key string
     ---@param val any
     function self.Functions.SetPlayerData(key, val)
-        SetPlayerData(self.PlayerData.source, key, val)
+        SetPlayerData(self.PlayerData.source, key, nil, val)
     end
 
-    ---@deprecated use SetMetadata instead
+    ---@deprecated use exports.qbx_core:SetMetadata instead
     ---@param meta string
     ---@param val any
     function self.Functions.SetMetaData(meta, val)
         SetMetadata(self.PlayerData.source, meta, val)
     end
 
-    ---@deprecated use GetMetadata instead
+    ---@deprecated use exports.qbx_core:GetMetadata instead
     ---@param meta string
     ---@return any
     function self.Functions.GetMetaData(meta)
         return GetMetadata(self.PlayerData.source, meta)
     end
 
-    ---@deprecated use SetMetadata instead
+    ---@deprecated use exports.qbx_core:SetMetadata instead
     ---@param amount number
     function self.Functions.AddJobReputation(amount)
         if not amount then return end
 
         amount = tonumber(amount) --[[@as number]]
 
-        self.PlayerData.metadata[self.PlayerData.job.name].reputation += amount
-
-        ---@diagnostic disable-next-line: param-type-mismatch
-        UpdatePlayerData(self.Offline and self.PlayerData.citizenid or self.PlayerData.source)
+        SetPlayerData(self.PlayerData.source, 'metadata', {self.PlayerData.job.name, 'reputation'}, self.PlayerData.metadata[self.PlayerData.job.name].reputation + amount)
     end
 
+    ---@deprecated Use exports.qbx_core:AddMoney or ox_inventory item exports
     ---@param moneytype MoneyType
     ---@param amount number
     ---@param reason? string
@@ -811,6 +790,7 @@ function CreatePlayer(playerData, Offline)
         return AddMoney(self.PlayerData.source, moneytype, amount, reason)
     end
 
+    ---@deprecated Use exports.qbx_core:RemoveMoney or ox_inventory item exports
     ---@param moneytype MoneyType
     ---@param amount number
     ---@param reason? string
@@ -819,6 +799,7 @@ function CreatePlayer(playerData, Offline)
         return RemoveMoney(self.PlayerData.source, moneytype, amount, reason)
     end
 
+    ---@deprecated Use exports.qbx_core:SetMoney or ox_inventory item exports
     ---@param moneytype MoneyType
     ---@param amount number
     ---@param reason? string
@@ -827,6 +808,7 @@ function CreatePlayer(playerData, Offline)
         return SetMoney(self.PlayerData.source, moneytype, amount, reason)
     end
 
+    ---@deprecated Use exports.qbx_core:GetMoney or ox_inventory item checks
     ---@param moneytype MoneyType
     ---@return boolean | number amount or false if moneytype does not exist
     function self.Functions.GetMoney(moneytype)
@@ -904,16 +886,13 @@ function CreatePlayer(playerData, Offline)
         error('Player.Functions.SetInventory is unsupported for ox_inventory. Try ClearInventory, then add the desired items.')
     end
 
-    ---@deprecated use SetCharInfo instead
+    ---@deprecated use exports.qbx_core:SetCharInfo instead
     ---@param cardNumber number
     function self.Functions.SetCreditCard(cardNumber)
-        self.PlayerData.charinfo.card = cardNumber
-
-        ---@diagnostic disable-next-line: param-type-mismatch
-        UpdatePlayerData(self.Offline and self.PlayerData.citizenid or self.PlayerData.source)
+        SetPlayerData(self.PlayerData.source, 'charinfo', {'card'}, cardNumber)
     end
 
-    ---@deprecated use Save or SaveOffline instead
+    ---@deprecated use exports.qbx_core:Save or exports.qbx_core:SaveOffline instead
     function self.Functions.Save()
         if self.Offline then
             SaveOffline(self.PlayerData)
@@ -963,11 +942,10 @@ function CreatePlayer(playerData, Offline)
             end
         end
 
-        if not self.Offline then
-            UpdatePlayerData(self.PlayerData.source)
+        SetPlayerData(self.PlayerData.source, 'job', nil, self.PlayerData.job, function()
             TriggerEvent('QBCore:Server:OnJobUpdate', self.PlayerData.source, self.PlayerData.job)
             TriggerClientEvent('QBCore:Client:OnJobUpdate', self.PlayerData.source, self.PlayerData.job)
-        end
+        end)
     end)
 
     AddEventHandler('qbx_core:server:onGangUpdate', function(gangName, gang)
@@ -999,11 +977,10 @@ function CreatePlayer(playerData, Offline)
             end
         end
 
-        if not self.Offline then
-            UpdatePlayerData(self.PlayerData.source)
+        SetPlayerData(self.PlayerData.source, 'gang', nil, self.PlayerData.gang, function()
             TriggerEvent('QBCore:Server:OnGangUpdate', self.PlayerData.source, self.PlayerData.gang)
             TriggerClientEvent('QBCore:Client:OnGangUpdate', self.PlayerData.source, self.PlayerData.gang)
-        end
+        end)
     end)
 
     if not self.Offline then
@@ -1013,7 +990,6 @@ function CreatePlayer(playerData, Offline)
         SetPedArmour(ped, self.PlayerData.metadata.armor)
         -- At this point we are safe to emit new instance to third party resource for load handling
         GlobalState.PlayerCount += 1
-        UpdatePlayerData(self.PlayerData.source)
         Player(self.PlayerData.source).state:set('loadInventory', true, true)
         TriggerEvent('QBCore:Server:PlayerLoaded', self)
     end
@@ -1029,11 +1005,13 @@ function Save(source)
     local ped = GetPlayerPed(source)
     local playerData = QBX.Players[source].PlayerData
     local playerState = Player(source)?.state
-    local pcoords = playerData.position
+    local playerCoords = playerData.position
+
     if not playerState.inApartment and not playerState.inProperty then
         local coords = GetEntityCoords(ped)
-        pcoords = vec4(coords.x, coords.y, coords.z, GetEntityHeading(ped))
+        playerCoords = vec4(coords.x, coords.y, coords.z, GetEntityHeading(ped))
     end
+
     if not playerData then
         lib.print.error('QBX.PLAYER.SAVE - PLAYERDATA IS EMPTY!')
         return
@@ -1051,9 +1029,10 @@ function Save(source)
     CreateThread(function()
         storage.upsertPlayerEntity({
             playerEntity = playerData,
-            position = pcoords,
+            position = playerCoords,
         })
     end)
+
     assert(GetResourceState('qb-inventory') ~= 'started', 'qb-inventory is not compatible with qbx_core. use ox_inventory instead')
     lib.print.verbose(('%s PLAYER SAVED!'):format(playerData.name))
 end
@@ -1073,6 +1052,7 @@ function SaveOffline(playerData)
             position = playerData.position.xyz
         })
     end)
+
     assert(GetResourceState('qb-inventory') ~= 'started', 'qb-inventory is not compatible with qbx_core. use ox_inventory instead')
     lib.print.verbose(('%s OFFLINE PLAYER SAVED!'):format(playerData.name))
 end
@@ -1081,29 +1061,62 @@ exports('SaveOffline', SaveOffline)
 
 ---@param identifier Source | string
 ---@param key string
+---@param subKeys? string[]
 ---@param value any
-function SetPlayerData(identifier, key, value)
+---@param cb? function A function that's called after the standard SetPlayerData events are triggered if the player is online
+---@param cancelDbUpdate? boolean When true, makes sure the database doesn't get updated as a result of this change
+function SetPlayerData(identifier, key, subKeys, value, cb, cancelDbUpdate)
     if type(key) ~= 'string' then return end
 
     local player = type(identifier) == 'string' and (GetPlayerByCitizenId(identifier) or GetOfflinePlayer(identifier)) or GetPlayer(identifier)
 
-    if not player then return end
+    if not player then
+        error(('SetPlayerData couldn\'t find player with identifier %s'):format(identifier))
+        return
+    end
 
-    player.PlayerData[key] = value
+    local oldValue = player.PlayerData[key]
+
+    if type(subKeys) == "table" then
+        local current = player.PlayerData[key]
+        -- We don't check the last one because otherwise we lose the table reference
+        for i = 1, #subKeys - 1 do
+            local newCurrent = current[subKeys[i]]
+            if newCurrent then
+                current = newCurrent
+            elseif i ~= (#subKeys - 1) then
+                -- if an invalid key is specified and we are not on the last one, stop trying to update
+                -- reason for allowing the last one to not exist is so we can insert new values
+                error(('key %s doesn\'t exist in table player.PlayerData.%s'):format(subKeys[i], key))
+                return
+            end
+        end
 
-    UpdatePlayerData(identifier)
-end
+        local lastIndex = #subKeys
+        oldValue = current[subKeys[lastIndex]]
+        current[subKeys[lastIndex]] = value
+    else
+        player.PlayerData[key] = value
+    end
 
----@param identifier Source | string
-function UpdatePlayerData(identifier)
-    local player = type(identifier) == 'string' and (GetPlayerByCitizenId(identifier) or GetOfflinePlayer(identifier)) or GetPlayer(identifier)
+    if not cancelDbUpdate then
+        storage.addPlayerDataUpdate(player.PlayerData.citizenid, key, subKeys, value)
+    end
 
-    if not player or player.Offline then return end
+    if player.Offline then return end
 
     TriggerEvent('QBCore:Player:SetPlayerData', player.PlayerData)
     TriggerClientEvent('QBCore:Player:SetPlayerData', player.PlayerData.source, player.PlayerData)
+    TriggerEvent('qbx_core:server:setPlayerData', player.PlayerData.source, key, subKeys, value, oldValue)
+    TriggerClientEvent('qbx_core:client:setPlayerData', player.PlayerData.source, key, subKeys, value, oldValue)
+
+    if not cb then return end
+
+    cb()
 end
 
+exports('SetPlayerData', SetPlayerData)
+
 ---@param identifier Source | string
 ---@param metadata string
 ---@param value any
@@ -1114,38 +1127,25 @@ function SetMetadata(identifier, metadata, value)
 
     if not player then return end
 
-    local oldValue = player.PlayerData.metadata[metadata]
-
-    player.PlayerData.metadata[metadata] = value
-
-    UpdatePlayerData(identifier)
+    if metadata == 'hunger' or metadata == 'thirst' or metadata == 'stress' then
+        value = lib.math.clamp(value, 0, 100)
+    end
 
-    if not player.Offline then
+    local oldValue = player.PlayerData.metadata[metadata]
+    SetPlayerData(player.PlayerData.source, 'metadata', {metadata}, value, function()
         local playerState = Player(player.PlayerData.source).state
 
         TriggerClientEvent('qbx_core:client:onSetMetaData', player.PlayerData.source, metadata, oldValue, value)
-        TriggerEvent('qbx_core:server:onSetMetaData', metadata,  oldValue, value, player.PlayerData.source)
-
-        if (metadata == 'hunger' or metadata == 'thirst' or metadata == 'stress') then
-            value = lib.math.clamp(value, 0, 100)
+        TriggerEvent('qbx_core:server:onSetMetaData', metadata, oldValue, value, player.PlayerData.source)
 
-            if playerState[metadata] ~= value then
-                playerState:set(metadata, value, true)
-            end
+        if (metadata == 'hunger' or metadata == 'thirst' or metadata == 'stress') and playerState[metadata] ~= value then
+            playerState:set(metadata, value, true)
         end
 
-        if (metadata == 'dead' or metadata == 'inlaststand') then
+        if metadata == 'dead' or metadata == 'inlaststand' then
             playerState:set('canUseWeapons', not value, true)
         end
-    end
-
-    if metadata == 'inlaststand' or metadata == 'isdead' then
-        if player.Offline then
-            SaveOffline(player.PlayerData)
-        else
-            Save(player.PlayerData.source)
-        end
-    end
+    end)
 end
 
 exports('SetMetadata', SetMetadata)
@@ -1169,17 +1169,7 @@ exports('GetMetadata', GetMetadata)
 ---@param charInfo string
 ---@param value any
 function SetCharInfo(identifier, charInfo, value)
-    if type(charInfo) ~= 'string' then return end
-
-    local player = type(identifier) == 'string' and (GetPlayerByCitizenId(identifier) or GetOfflinePlayer(identifier)) or GetPlayer(identifier)
-
-    if not player then return end
-
-    --local oldCharInfo = player.PlayerData.charinfo[charInfo]
-
-    player.PlayerData.charinfo[charInfo] = value
-
-    UpdatePlayerData(identifier)
+    SetPlayerData(identifier, 'charinfo', {charInfo}, value)
 end
 
 exports('SetCharInfo', SetCharInfo)
@@ -1195,7 +1185,7 @@ local function emitMoneyEvents(source, playerMoney, moneyType, amount, actionTyp
     local isSet = actionType == 'set'
     local isRemove = actionType == 'remove'
 
-    TriggerClientEvent('hud:client:OnMoneyChange', source, moneyType, isSet and math.abs(difference) or amount, isSet and difference < 0 or isRemove, reason)
+    TriggerClientEvent('hud:client:OnMoneyChange', source, moneyType, isSet and difference and math.abs(difference) or amount, isSet and difference and difference < 0 or isRemove, reason)
     TriggerClientEvent('QBCore:Client:OnMoneyChange', source, moneyType, amount, actionType, reason)
     TriggerEvent('QBCore:Server:OnMoneyChange', source, moneyType, amount, actionType, reason)
 
@@ -1231,17 +1221,13 @@ function AddMoney(identifier, moneyType, amount, reason)
         amount = amount
     }) then return false end
 
-    player.PlayerData.money[moneyType] += amount
-
-    if not player.Offline then
-        UpdatePlayerData(identifier)
-
+    SetPlayerData(player.PlayerData.source, 'money', {moneyType}, player.PlayerData.money[moneyType] + amount, function()
         local tags = amount > 100000 and config.logging.role or nil
         local resource = GetInvokingResource() or cache.resource
 
         logger.log({
             source = resource,
-            webhook = config.logging.webhook['playermoney'],
+            webhook = config.logging.webhook.playermoney,
             event = 'AddMoney',
             color = 'lightgreen',
             tags = tags,
@@ -1250,7 +1236,7 @@ function AddMoney(identifier, moneyType, amount, reason)
         })
 
         emitMoneyEvents(player.PlayerData.source, player.PlayerData.money, moneyType, amount, 'add', reason)
-    end
+    end)
 
     return true
 end
@@ -1286,17 +1272,13 @@ function RemoveMoney(identifier, moneyType, amount, reason)
         end
     end
 
-    player.PlayerData.money[moneyType] -= amount
-
-    if not player.Offline then
-        UpdatePlayerData(identifier)
-
+    SetPlayerData(player.PlayerData.source, 'money', {moneyType}, player.PlayerData.money[moneyType] - amount, function()
         local tags = amount > 100000 and config.logging.role or nil
         local resource = GetInvokingResource() or cache.resource
 
         logger.log({
             source = resource,
-            webhook = config.logging.webhook['playermoney'],
+            webhook = config.logging.webhook.playermoney,
             event = 'RemoveMoney',
             color = 'red',
             tags = tags,
@@ -1305,7 +1287,7 @@ function RemoveMoney(identifier, moneyType, amount, reason)
         })
 
         emitMoneyEvents(player.PlayerData.source, player.PlayerData.money, moneyType, amount, 'remove', reason)
-    end
+    end)
 
     return true
 end
@@ -1335,10 +1317,7 @@ function SetMoney(identifier, moneyType, amount, reason)
     }) then return false end
 
     player.PlayerData.money[moneyType] = amount
-
-    if not player.Offline then
-        UpdatePlayerData(identifier)
-
+    SetPlayerData(player.PlayerData.source, 'money', {moneyType}, amount, function()
         local difference = amount - oldAmount
         local dirChange = difference < 0 and 'removed' or 'added'
         local absDifference = math.abs(difference)
@@ -1347,7 +1326,7 @@ function SetMoney(identifier, moneyType, amount, reason)
 
         logger.log({
             source = resource,
-            webhook = config.logging.webhook['playermoney'],
+            webhook = config.logging.webhook.playermoney,
             event = 'SetMoney',
             color = difference < 0 and 'red' or 'green',
             tags = tags,
@@ -1355,7 +1334,7 @@ function SetMoney(identifier, moneyType, amount, reason)
         })
 
         emitMoneyEvents(player.PlayerData.source, player.PlayerData.money, moneyType, amount, 'set', reason, difference)
-    end
+    end)
 
     return true
 end
@@ -1388,7 +1367,7 @@ function DeleteCharacter(source, citizenid)
             if success then
                 logger.log({
                     source = 'qbx_core',
-                    webhook = config.logging.webhook['joinleave'],
+                    webhook = config.logging.webhook.joinleave,
                     event = 'Character Deleted',
                     color = 'red',
                     message = ('**%s** deleted **%s**...'):format(GetPlayerName(source), citizenid, source),
@@ -1399,7 +1378,7 @@ function DeleteCharacter(source, citizenid)
         DropPlayer(tostring(source), locale('info.exploit_dropped'))
         logger.log({
             source = 'qbx_core',
-            webhook = config.logging.webhook['anticheat'],
+            webhook = config.logging.webhook.anticheat,
             event = 'Anti-Cheat',
             color = 'white',
             tags = config.logging.role,
@@ -1422,7 +1401,7 @@ function ForceDeleteCharacter(citizenid)
             if success then
                 logger.log({
                     source = 'qbx_core',
-                    webhook = config.logging.webhook['joinleave'],
+                    webhook = config.logging.webhook.joinleave,
                     event = 'Character Force Deleted',
                     color = 'red',
                     message = ('Character **%s** got deleted'):format(citizenid),
@@ -1444,6 +1423,7 @@ function GenerateUniqueIdentifier(type)
         uniqueId = table.valueFunction()
         isUnique = storage.fetchIsUnique(type, uniqueId)
     until isUnique
+
     return uniqueId
 end
 
diff --git a/server/queue.lua b/server/queue.lua
index 44e73856d..8d99ccada 100644
--- a/server/queue.lua
+++ b/server/queue.lua
@@ -4,10 +4,10 @@ if GetConvar('qbx:enablequeue', 'true') == 'false' then return false end
 
 ---@param resource string
 AddEventHandler('onResourceStarting', function(resource)
-    if resource == 'hardcap' then
-        lib.print.info('Preventing hardcap from starting...')
-        CancelEvent()
-    end
+    if resource ~= 'hardcap' then return end
+
+    lib.print.info('Preventing hardcap from starting...')
+    CancelEvent()
 end)
 
 if GetResourceState('hardcap'):find('start') then
@@ -146,6 +146,7 @@ local function updatePlayerJoining(source, license)
     if not joiningPlayers[license] then
         joiningPlayerCount += 1
     end
+
     joiningPlayers[license] = { source = source, timestamp = os.time() }
 end
 
@@ -197,7 +198,6 @@ local function awaitPlayerQueue(source, license, deferrals)
 
     local playerTimingOut = isPlayerTimingOut(license)
     local data = playerDatas[license]
-
     if data and not playerTimingOut then
         deferrals.done(locale('error.already_in_queue'))
         return
@@ -228,7 +228,6 @@ local function awaitPlayerQueue(source, license, deferrals)
     -- wait until the player disconnected or until there are available slots and the player is first in queue
     while DoesPlayerExist(source --[[@as string]]) and ((GetNumPlayerIndices() + joiningPlayerCount) >= maxPlayers or data.globalPos > 1) do
         local displayTime = createDisplayTime(data.waitingSeconds, waitingEmojiIndex)
-
         if useAdaptiveCard then
             deferrals.presentCard(generateCard({
                 subQueue = subQueue,
@@ -255,6 +254,7 @@ local function awaitPlayerQueue(source, license, deferrals)
         if awaitPlayerTimeout(license) then
             dequeue(license)
         end
+
         return
     end
 
diff --git a/server/storage/players.lua b/server/storage/players.lua
index 3a07f6552..8fb428e94 100644
--- a/server/storage/players.lua
+++ b/server/storage/players.lua
@@ -1,5 +1,32 @@
 local defaultSpawn = require 'config.shared'.defaultSpawn
 local characterDataTables = require 'config.server'.characterDataTables
+local playerDataUpdateQueue = {}
+local collectedPlayerData = {}
+local isUpdating = false
+
+local otherNamedPlayerFields = {
+    ['items'] = 'inventory',
+    ['lastLoggedOut'] = 'last_logged_out'
+}
+
+local jsonPlayerFields = {
+    ['id'] = false,
+    ['userId'] = false,
+    ['citizenid'] = false,
+    ['cid'] = false,
+    ['license'] = false,
+    ['name'] = false,
+    ['money'] = true,
+    ['charinfo'] = true,
+    ['job'] = true,
+    ['gang'] = true,
+    ['position'] = true,
+    ['metadata'] = true,
+    ['inventory'] = true,
+    ['phone_number'] = false,
+    ['last_updated'] = false,
+    ['last_logged_out'] = false
+}
 
 local function createUsersTable()
     MySQL.query([[
@@ -78,7 +105,7 @@ end
 ---@return BanEntity?
 local function fetchBan(request)
     local column, value = getBanId(request)
-    local result = MySQL.single.await('SELECT expire, reason FROM bans WHERE ' ..column.. ' = ?', { value })
+    local result = MySQL.single.await('SELECT expire, reason FROM bans WHERE ' .. column .. ' = ?', { value })
     return result and {
         expire = result.expire,
         reason = result.reason,
@@ -88,7 +115,7 @@ end
 ---@param request GetBanRequest
 local function deleteBan(request)
     local column, value = getBanId(request)
-    MySQL.query.await('DELETE FROM bans WHERE ' ..column.. ' = ?', { value })
+    MySQL.query.await('DELETE FROM bans WHERE ' .. column .. ' = ?', { value })
 end
 
 ---@param request UpsertPlayerRequest
@@ -166,22 +193,28 @@ local function fetchPlayerEntity(citizenId)
 end
 
 ---@param filters table<string, any>
+---@return string, any[]
 local function handleSearchFilters(filters)
-    if not (filters) then return '', {} end
+    if not filters then return '', {} end
+
     local holders = {}
     local clauses = {}
+
     if filters.license then
         clauses[#clauses + 1] = 'license = ?'
         holders[#holders + 1] = filters.license
     end
+
     if filters.job then
         clauses[#clauses + 1] = 'JSON_EXTRACT(job, "$.name") = ?'
         holders[#holders + 1] = filters.job
     end
+
     if filters.gang then
         clauses[#clauses + 1] = 'JSON_EXTRACT(gang, "$.name") = ?'
         holders[#holders + 1] = filters.gang
     end
+
     if filters.metadata then
         local strict = filters.metadata.strict
         for key, value in pairs(filters.metadata) do
@@ -192,6 +225,7 @@ local function handleSearchFilters(filters)
                     else
                         clauses[#clauses + 1] = 'JSON_EXTRACT(metadata, "$.' .. key .. '") >= ?'
                     end
+
                     holders[#holders + 1] = value
                 elseif type(value) == "boolean" then
                     clauses[#clauses + 1] = 'JSON_EXTRACT(metadata, "$.' .. key .. '") = ?'
@@ -203,6 +237,7 @@ local function handleSearchFilters(filters)
             end
         end
     end
+
     return (' WHERE %s'):format(table.concat(clauses, ' AND ')), holders
 end
 
@@ -317,6 +352,7 @@ local function fetchPlayerGroups(citizenid)
             gangs[group.group] = group.grade
         end
     end
+
     return jobs, gangs
 end
 
@@ -382,8 +418,99 @@ local function cleanPlayerGroups()
     lib.print.info('Removed invalid groups from player_groups table')
 end
 
+---@param citizenid string
+---@param key string
+---@param subKeys? string[]
+---@param value any
+local function addPlayerDataUpdate(citizenid, key, subKeys, value)
+    key = otherNamedPlayerFields[key] or key
+
+    if jsonPlayerFields[key] == nil then
+        error(('Tried to update player data field %s when it doesn\'t exist. Value: %s'):format(key, value))
+        return
+    end
+
+    if not jsonPlayerFields[key] and subKeys then
+        error(('Tried to update player data field %s as a json object when it isn\'t one'):format(key))
+        return
+    end
+
+    value = type(value) == 'table' and json.encode(value) or value
+
+    -- In sendPlayerDataUpdates we don't go more than 3 tables deep
+    if subKeys and #subKeys > 3 then
+        error(('Cannot save field %s because data is too big.\nsubKeys: %s\nvalue: %s'):format(key, json.encode(subKeys), value))
+        return
+    end
+
+    local currentTable = isUpdating and playerDataUpdateQueue or collectedPlayerData
+
+    if not currentTable[citizenid] then
+        currentTable[citizenid] = {}
+    end
+
+    currentTable[citizenid][key] = subKeys and {} or value
+
+    if subKeys then
+        local current = currentTable[citizenid][key]
+        -- We don't check the last one because otherwise we lose the table reference
+        for i = 1, #subKeys - 1 do
+            if not current[subKeys[i]] then
+                current[subKeys[i]] = {}
+            end
+        end
+
+        current[subKeys[#subKeys]] = value
+    end
+end
+
+local function sendPlayerDataUpdates()
+    -- We implement this to ensure when updating no values are added to our updating sequence to prevent data loss by accidentally skipping over it
+    isUpdating = true
+
+    for citizenid, playerData in pairs(collectedPlayerData) do
+        for key, data in pairs(playerData) do
+            if type(data) == 'table' then
+                local updateStrings = {}
+                -- We go a maximum of 3 tables deep into the current table to prevent misuse and qbox doesn't have more than 2 actually
+                -- If we were to make this variable to the amount of data there is, then enough tables can crash the server
+                for k, v in pairs(data) do
+                    if type(v) == 'table' then
+                        for k2, v2 in pairs(v) do
+                            if type(v2) == 'table' then
+                                for k3, v3 in pairs(v2) do
+                                    if type(v3) == 'table' then
+                                        v3 = json.encode(v3)
+                                    end
+
+                                    updateStrings[#updateStrings + 1] = { ('$.%s.%s.%s'):format(k, k2, k3), v3 }
+                                end
+                            else
+                                updateStrings = { ('$.%s.%s'):format(k, k2), v2 }
+                            end
+                        end
+                    else
+                        updateStrings[#updateStrings + 1] = { ('$.%s'):format(k), v }
+                    end
+                end
+
+                for i = 1, #updateStrings do
+                    MySQL.prepare.await(('UPDATE players SET %s = JSON_SET(%s, "%s", ?) WHERE citizenid = ?'):format(key, key, updateStrings[i][1]), { updateStrings[i][2], citizenid })
+                end
+            else
+                MySQL.prepare.await(('UPDATE players SET %s = ? WHERE citizenid = ?'):format(key), { data, citizenid })
+            end
+        end
+    end
+
+    collectedPlayerData = playerDataUpdateQueue
+    playerDataUpdateQueue = {}
+    isUpdating = false
+end
+
 RegisterCommand('cleanplayergroups', function(source)
     if source ~= 0 then return warn('This command can only be executed using the server console.') end
+
     cleanPlayerGroups()
 end, true)
 
@@ -394,6 +521,7 @@ CreateThread(function()
             warn(('Table \'%s\' does not exist in database, please remove it from qbx_core/config/server.lua or create the table'):format(tableName))
         end
     end
+
     if GetConvar('qbx:cleanPlayerGroups', 'false') == 'true' then
         cleanPlayerGroups()
     end
@@ -419,4 +547,6 @@ return {
     removePlayerFromJob = removePlayerFromJob,
     removePlayerFromGang = removePlayerFromGang,
     searchPlayerEntities = searchPlayerEntities,
+    sendPlayerDataUpdates = sendPlayerDataUpdates,
+    addPlayerDataUpdate = addPlayerDataUpdate
 }
\ No newline at end of file
diff --git a/server/vehicle-persistence.lua b/server/vehicle-persistence.lua
index d25b4d160..6e2a7eeff 100644
--- a/server/vehicle-persistence.lua
+++ b/server/vehicle-persistence.lua
@@ -83,14 +83,17 @@ local function getPedsInVehicleSeats(vehicle)
                 ped = ped,
                 seat = i,
             }
+
             occupantsI += 1
         end
     end
+
     return occupants
 end
 
 AddEventHandler('entityRemoved', function(entity)
     if not Entity(entity).state.persisted then return end
+
     local sessionId = Entity(entity).state.sessionId
     local coords = GetEntityCoords(entity)
     local heading = GetEntityHeading(entity)
diff --git a/shared/functions.lua b/shared/functions.lua
index 43537e601..7dd231e6e 100644
--- a/shared/functions.lua
+++ b/shared/functions.lua
@@ -29,27 +29,24 @@
 function HasPlayerGotGroup(filter, playerData, primary)
     local groups = not primary and GetPlayerGroups(playerData)
     if not filter then return false end
-    local _type = type(filter)
 
+    local _type = type(filter)
     if _type == 'string' then
         local job = playerData.job.name == filter
         local gang = playerData.gang.name == filter
         local group = groups and groups[filter]
         local citizenId = playerData.citizenid == filter
-
         if job or gang or group or citizenId then
             return true
         end
     elseif _type == 'table' then
         local tabletype = table.type(filter)
-
         if tabletype == 'hash' then
             for name, grade in pairs(filter) do
                 local job = playerData.job.name == name
                 local gang = playerData.gang.name == name
                 local group = groups and groups[name]
                 local citizenId = playerData.citizenid == name
-
                 if job and grade <= playerData.job.grade.level or gang and grade <= playerData.gang.grade.level or group and grade <= group or citizenId then
                     return true
                 end
@@ -61,13 +58,13 @@ function HasPlayerGotGroup(filter, playerData, primary)
                 local gang = playerData.gang.name == name
                 local group = groups and groups[name]
                 local citizenId = playerData.citizenid == name
-
                 if job or gang or group or citizenId then
                     return true
                 end
             end
         end
     end
+
     return false
 end
 
@@ -80,6 +77,7 @@ function GetPlayerGroups(playerData)
             groups[job] = data
         end
     end
+
     for gang, data in pairs(playerData.gangs) do
         if not groups[gang] then
             groups[gang] = data
@@ -87,5 +85,6 @@ function GetPlayerGroups(playerData)
             lib.print.warn(('Duplicate group name %s found in player_groups table, check job and gang shared lua.'):format(gang))
         end
     end
+
     return groups
 end
\ No newline at end of file
diff --git a/types.lua b/types.lua
index 99698e98c..67d85e40a 100644
--- a/types.lua
+++ b/types.lua
@@ -102,6 +102,8 @@
 ---@field fetchPlayerGroups fun(citizenid: string): table<string, integer>, table<string, integer> jobs, gangs
 ---@field removePlayerFromJob fun(citizenid: string, group: string)
 ---@field removePlayerFromGang fun(citizenid: string, group: string)
+---@field sendPlayerDataUpdates fun()
+---@field addPlayerDataUpdate fun(citizenid: string, key: string, subKeys?: string[], value: any)
 
 ---@class InsertBanRequest
 ---@field name string

From 4eb7c764c29d2a38b679db600fa87a3384c0d4e4 Mon Sep 17 00:00:00 2001
From: BerkieBb <82737367+BerkieBb@users.noreply.github.com>
Date: Tue, 14 Jan 2025 14:44:40 +0100
Subject: [PATCH 02/15] fix(server/player): undefined variables

---
 server/player.lua | 31 +++++++++++++++++++------------
 1 file changed, 19 insertions(+), 12 deletions(-)

diff --git a/server/player.lua b/server/player.lua
index 648e2bea9..f5cbb71a6 100644
--- a/server/player.lua
+++ b/server/player.lua
@@ -135,6 +135,13 @@ exports('SetJob', SetJob)
 ---@param identifier Source | string
 ---@param onDuty boolean
 function SetJobDuty(identifier, onDuty)
+    local player = type(identifier) == 'string' and (GetPlayerByCitizenId(identifier) or GetOfflinePlayer(identifier)) or GetPlayer(identifier)
+
+    if not player then
+        error(('SetJobDuty couldn\'t find player with identifier %s'):format(identifier))
+        return
+    end
+
     SetPlayerData(identifier, 'job', {'onduty'}, not not onDuty, function()
         TriggerEvent('QBCore:Server:SetDuty', player.PlayerData.source, player.PlayerData.job.onduty)
         TriggerClientEvent('QBCore:Client:SetDuty', player.PlayerData.source, player.PlayerData.job.onduty)
@@ -196,7 +203,7 @@ function SetPlayerPrimaryJob(citizenid, jobName)
 
     player.PlayerData.job = toPlayerJob(jobName, job, grade)
 
-    SetPlayerData(player.PlayerData.source, 'job', nil, player.PlayerData.job, function()
+    SetPlayerData(citizenid, 'job', nil, player.PlayerData.job, function()
         TriggerEvent('QBCore:Server:OnJobUpdate', player.PlayerData.source, player.PlayerData.job)
         TriggerClientEvent('QBCore:Client:OnJobUpdate', player.PlayerData.source, player.PlayerData.job)
     end)
@@ -261,7 +268,7 @@ function AddPlayerToJob(citizenid, jobName, grade)
     storage.addPlayerToJob(citizenid, jobName, grade)
 
     player.PlayerData.jobs[jobName] = grade
-    SetPlayerData(player.PlayerData.source, 'jobs', nil, player.PlayerData.jobs, function()
+    SetPlayerData(citizenid, 'jobs', nil, player.PlayerData.jobs, function()
         TriggerEvent('qbx_core:server:onGroupUpdate', player.PlayerData.source, jobName, grade)
         TriggerClientEvent('qbx_core:client:onGroupUpdate', player.PlayerData.source, jobName, grade)
     end, true)
@@ -307,10 +314,10 @@ function RemovePlayerFromJob(citizenid, jobName)
         local job = GetJob('unemployed')
         assert(job ~= nil, 'cannot find unemployed job. Does it exist in shared/jobs.lua?')
 
-        SetPlayerData(player.PlayerData.source, 'job', nil, toPlayerJob('unemployed', job, 0))
+        SetPlayerData(citizenid, 'job', nil, toPlayerJob('unemployed', job, 0))
     end
 
-    SetPlayerData(player.PlayerData.source, 'jobs', nil, player.PlayerData.jobs, function()
+    SetPlayerData(citizenid, 'jobs', nil, player.PlayerData.jobs, function()
         TriggerEvent('qbx_core:server:onGroupUpdate', player.PlayerData.source, jobName)
         TriggerClientEvent('qbx_core:client:onGroupUpdate', player.PlayerData.source, jobName)
     end, true)
@@ -396,7 +403,7 @@ function SetPlayerPrimaryGang(citizenid, gangName)
 
     assert(gang.grades[grade] ~= nil, ('gang %s does not have grade %s'):format(gangName, grade))
 
-    SetPlayerData(player.PlayerData.source, 'gang', nil, {
+    SetPlayerData(citizenid, 'gang', nil, {
         name = gangName,
         label = gang.label,
         isboss = gang.grades[grade].isboss,
@@ -468,7 +475,7 @@ function AddPlayerToGang(citizenid, gangName, grade)
     storage.addPlayerToGang(citizenid, gangName, grade)
 
     player.PlayerData.gangs[gangName] = grade
-    SetPlayerData(player.PlayerData.source, 'gangs', nil, player.PlayerData.gangs, function()
+    SetPlayerData(citienid, 'gangs', nil, player.PlayerData.gangs, function()
         TriggerEvent('qbx_core:server:onGroupUpdate', player.PlayerData.source, gangName, grade)
         TriggerClientEvent('qbx_core:client:onGroupUpdate', player.PlayerData.source, gangName, grade)
     end, true)
@@ -514,7 +521,7 @@ function RemovePlayerFromGang(citizenid, gangName)
         local gang = GetGang('none')
         assert(gang ~= nil, 'cannot find none gang. Does it exist in shared/gangs.lua?')
 
-        SetPlayerData(player.PlayerData.source, 'gang', nil, {
+        SetPlayerData(citizenid, 'gang', nil, {
             name = 'none',
             label = gang.label,
             isboss = false,
@@ -525,7 +532,7 @@ function RemovePlayerFromGang(citizenid, gangName)
         })
     end
 
-    SetPlayerData(player.PlayerData.source, 'gangs', nil, player.PlayerData.gangs, function()
+    SetPlayerData(citizenid, 'gangs', nil, player.PlayerData.gangs, function()
         TriggerEvent('qbx_core:server:onGroupUpdate', player.PlayerData.source, gangName)
         TriggerClientEvent('qbx_core:client:onGroupUpdate', player.PlayerData.source, gangName)
     end, true)
@@ -1132,7 +1139,7 @@ function SetMetadata(identifier, metadata, value)
     end
 
     local oldValue = player.PlayerData.metadata[metadata]
-    SetPlayerData(player.PlayerData.source, 'metadata', {metadata}, value, function()
+    SetPlayerData(identifier, 'metadata', {metadata}, value, function()
         local playerState = Player(player.PlayerData.source).state
 
         TriggerClientEvent('qbx_core:client:onSetMetaData', player.PlayerData.source, metadata, oldValue, value)
@@ -1221,7 +1228,7 @@ function AddMoney(identifier, moneyType, amount, reason)
         amount = amount
     }) then return false end
 
-    SetPlayerData(player.PlayerData.source, 'money', {moneyType}, player.PlayerData.money[moneyType] + amount, function()
+    SetPlayerData(identifier, 'money', {moneyType}, player.PlayerData.money[moneyType] + amount, function()
         local tags = amount > 100000 and config.logging.role or nil
         local resource = GetInvokingResource() or cache.resource
 
@@ -1272,7 +1279,7 @@ function RemoveMoney(identifier, moneyType, amount, reason)
         end
     end
 
-    SetPlayerData(player.PlayerData.source, 'money', {moneyType}, player.PlayerData.money[moneyType] - amount, function()
+    SetPlayerData(identifier, 'money', {moneyType}, player.PlayerData.money[moneyType] - amount, function()
         local tags = amount > 100000 and config.logging.role or nil
         local resource = GetInvokingResource() or cache.resource
 
@@ -1317,7 +1324,7 @@ function SetMoney(identifier, moneyType, amount, reason)
     }) then return false end
 
     player.PlayerData.money[moneyType] = amount
-    SetPlayerData(player.PlayerData.source, 'money', {moneyType}, amount, function()
+    SetPlayerData(identifier, 'money', {moneyType}, amount, function()
         local difference = amount - oldAmount
         local dirChange = difference < 0 and 'removed' or 'added'
         local absDifference = math.abs(difference)

From 215c4b29dca7744173fd0d6fda75797b06a1da6d Mon Sep 17 00:00:00 2001
From: BerkieBb <82737367+BerkieBb@users.noreply.github.com>
Date: Tue, 14 Jan 2025 14:53:05 +0100
Subject: [PATCH 03/15] fix(server/player): typo

---
 server/player.lua | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/server/player.lua b/server/player.lua
index f5cbb71a6..353d83436 100644
--- a/server/player.lua
+++ b/server/player.lua
@@ -475,7 +475,7 @@ function AddPlayerToGang(citizenid, gangName, grade)
     storage.addPlayerToGang(citizenid, gangName, grade)
 
     player.PlayerData.gangs[gangName] = grade
-    SetPlayerData(citienid, 'gangs', nil, player.PlayerData.gangs, function()
+    SetPlayerData(citizenid, 'gangs', nil, player.PlayerData.gangs, function()
         TriggerEvent('qbx_core:server:onGroupUpdate', player.PlayerData.source, gangName, grade)
         TriggerClientEvent('qbx_core:client:onGroupUpdate', player.PlayerData.source, gangName, grade)
     end, true)

From be9b9af492b9b546db301ea28f2be06cbb2ef77a Mon Sep 17 00:00:00 2001
From: BerkieBb <82737367+BerkieBb@users.noreply.github.com>
Date: Tue, 14 Jan 2025 16:47:40 +0100
Subject: [PATCH 04/15] revert(client/events): motd guard clause

---
 client/events.lua | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/client/events.lua b/client/events.lua
index f216f84ca..f812c6fa9 100644
--- a/client/events.lua
+++ b/client/events.lua
@@ -15,9 +15,9 @@ RegisterNetEvent('QBCore:Client:OnPlayerLoaded', function()
     end
 
     local motd = GetConvar('qbx:motd', '')
-    if motd == '' then return end
-
-    exports.chat:addMessage({ template = motd })
+    if motd ~= '' then
+        exports.chat:addMessage({ template = motd })
+    end
 end)
 
 ---@param val PlayerData

From 052c3f695a2593ae9ec4c750d44cdf8d0aaf3759 Mon Sep 17 00:00:00 2001
From: BerkieBb <82737367+BerkieBb@users.noreply.github.com>
Date: Tue, 14 Jan 2025 23:43:33 +0100
Subject: [PATCH 05/15] tweak(storage/players): directly trigger queries

---
 server/storage/players.lua | 11 +++--------
 1 file changed, 3 insertions(+), 8 deletions(-)

diff --git a/server/storage/players.lua b/server/storage/players.lua
index 8fb428e94..873228796 100644
--- a/server/storage/players.lua
+++ b/server/storage/players.lua
@@ -471,7 +471,6 @@ local function sendPlayerDataUpdates()
     for citizenid, playerData in pairs(collectedPlayerData) do
         for key, data in pairs(playerData) do
             if type(data) == 'table' then
-                local updateStrings = {}
                 -- We go a maximum of 3 tables deep into the current table to prevent misuse and qbox doesn't have more than 2 actually
                 -- If we were to make this variable to the amount of data there is, then enough tables can crash the server
                 for k, v in pairs(data) do
@@ -483,20 +482,16 @@ local function sendPlayerDataUpdates()
                                         v3 = json.encode(v3)
                                     end
 
-                                    updateStrings[#updateStrings + 1] = { ('$.%s.%s.%s'):format(k, k2, k3), v3 }
+                                    MySQL.prepare.await(('UPDATE players SET %s = JSON_SET(%s, "$.%s.%s.%s", ?) WHERE citizenid = ?'):format(key, key, k, k2, k3), { v3, citizenid })
                                 end
                             else
-                                updateStrings = { ('$.%s.%s'):format(k, k2), v2 }
+                                MySQL.prepare.await(('UPDATE players SET %s = JSON_SET(%s, "$.%s.%s", ?) WHERE citizenid = ?'):format(key, key, k, k2), { v2, citizenid })
                             end
                         end
                     else
-                        updateStrings[#updateStrings + 1] = { ('$.%s'):format(k), v }
+                        MySQL.prepare.await(('UPDATE players SET %s = JSON_SET(%s, "$.%s", ?) WHERE citizenid = ?'):format(key, key, k), { v, citizenid })
                     end
                 end
-
-                for i = 1, #updateStrings do
-                    MySQL.prepare.await(('UPDATE players SET %s = JSON_SET(%s, "%s", ?) WHERE citizenid = ?'):format(key, key, updateStrings[i][1]), { updateStrings[i][2], citizenid })
-                end
             else
                 MySQL.prepare.await(('UPDATE players SET %s = ? WHERE citizenid = ?'):format(key), { data, citizenid })
             end

From a4406819c5c146485e405b1a5945a1ba78012b3b Mon Sep 17 00:00:00 2001
From: BerkieBb <82737367+BerkieBb@users.noreply.github.com>
Date: Wed, 15 Jan 2025 01:58:59 +0100
Subject: [PATCH 06/15] tweak(server): merged subKeys into key for
 SetPlayerData

---
 server/functions.lua       |  2 +-
 server/player.lua          | 66 +++++++++++++++++++-------------------
 server/storage/players.lua | 40 ++++++++++++-----------
 3 files changed, 56 insertions(+), 52 deletions(-)

diff --git a/server/functions.lua b/server/functions.lua
index 8afb637d2..299f1c9e4 100644
--- a/server/functions.lua
+++ b/server/functions.lua
@@ -361,7 +361,7 @@ function ToggleOptin(source)
 
     local player = GetPlayer(source)
     player.PlayerData.optin = not player.PlayerData.optin
-    SetPlayerData(player.PlayerData.source, 'optin', nil, player.PlayerData.optin, nil, true)
+    SetPlayerData(player.PlayerData.source, 'optin', player.PlayerData.optin, nil, true)
 end
 
 exports('ToggleOptin', ToggleOptin)
diff --git a/server/player.lua b/server/player.lua
index 353d83436..845ca6ae7 100644
--- a/server/player.lua
+++ b/server/player.lua
@@ -142,7 +142,7 @@ function SetJobDuty(identifier, onDuty)
         return
     end
 
-    SetPlayerData(identifier, 'job', {'onduty'}, not not onDuty, function()
+    SetPlayerData(identifier, {'job', 'onduty'}, not not onDuty, function()
         TriggerEvent('QBCore:Server:SetDuty', player.PlayerData.source, player.PlayerData.job.onduty)
         TriggerClientEvent('QBCore:Client:SetDuty', player.PlayerData.source, player.PlayerData.job.onduty)
     end)
@@ -203,7 +203,7 @@ function SetPlayerPrimaryJob(citizenid, jobName)
 
     player.PlayerData.job = toPlayerJob(jobName, job, grade)
 
-    SetPlayerData(citizenid, 'job', nil, player.PlayerData.job, function()
+    SetPlayerData(citizenid, 'job', player.PlayerData.job, function()
         TriggerEvent('QBCore:Server:OnJobUpdate', player.PlayerData.source, player.PlayerData.job)
         TriggerClientEvent('QBCore:Client:OnJobUpdate', player.PlayerData.source, player.PlayerData.job)
     end)
@@ -268,7 +268,7 @@ function AddPlayerToJob(citizenid, jobName, grade)
     storage.addPlayerToJob(citizenid, jobName, grade)
 
     player.PlayerData.jobs[jobName] = grade
-    SetPlayerData(citizenid, 'jobs', nil, player.PlayerData.jobs, function()
+    SetPlayerData(citizenid, 'jobs', player.PlayerData.jobs, function()
         TriggerEvent('qbx_core:server:onGroupUpdate', player.PlayerData.source, jobName, grade)
         TriggerClientEvent('qbx_core:client:onGroupUpdate', player.PlayerData.source, jobName, grade)
     end, true)
@@ -314,10 +314,10 @@ function RemovePlayerFromJob(citizenid, jobName)
         local job = GetJob('unemployed')
         assert(job ~= nil, 'cannot find unemployed job. Does it exist in shared/jobs.lua?')
 
-        SetPlayerData(citizenid, 'job', nil, toPlayerJob('unemployed', job, 0))
+        SetPlayerData(citizenid, 'job', toPlayerJob('unemployed', job, 0))
     end
 
-    SetPlayerData(citizenid, 'jobs', nil, player.PlayerData.jobs, function()
+    SetPlayerData(citizenid, 'jobs', player.PlayerData.jobs, function()
         TriggerEvent('qbx_core:server:onGroupUpdate', player.PlayerData.source, jobName)
         TriggerClientEvent('qbx_core:client:onGroupUpdate', player.PlayerData.source, jobName)
     end, true)
@@ -403,7 +403,7 @@ function SetPlayerPrimaryGang(citizenid, gangName)
 
     assert(gang.grades[grade] ~= nil, ('gang %s does not have grade %s'):format(gangName, grade))
 
-    SetPlayerData(citizenid, 'gang', nil, {
+    SetPlayerData(citizenid, 'gang', {
         name = gangName,
         label = gang.label,
         isboss = gang.grades[grade].isboss,
@@ -475,7 +475,7 @@ function AddPlayerToGang(citizenid, gangName, grade)
     storage.addPlayerToGang(citizenid, gangName, grade)
 
     player.PlayerData.gangs[gangName] = grade
-    SetPlayerData(citizenid, 'gangs', nil, player.PlayerData.gangs, function()
+    SetPlayerData(citizenid, 'gangs', player.PlayerData.gangs, function()
         TriggerEvent('qbx_core:server:onGroupUpdate', player.PlayerData.source, gangName, grade)
         TriggerClientEvent('qbx_core:client:onGroupUpdate', player.PlayerData.source, gangName, grade)
     end, true)
@@ -521,7 +521,7 @@ function RemovePlayerFromGang(citizenid, gangName)
         local gang = GetGang('none')
         assert(gang ~= nil, 'cannot find none gang. Does it exist in shared/gangs.lua?')
 
-        SetPlayerData(citizenid, 'gang', nil, {
+        SetPlayerData(citizenid, 'gang', {
             name = 'none',
             label = gang.label,
             isboss = false,
@@ -532,7 +532,7 @@ function RemovePlayerFromGang(citizenid, gangName)
         })
     end
 
-    SetPlayerData(citizenid, 'gangs', nil, player.PlayerData.gangs, function()
+    SetPlayerData(citizenid, 'gangs', player.PlayerData.gangs, function()
         TriggerEvent('qbx_core:server:onGroupUpdate', player.PlayerData.source, gangName)
         TriggerClientEvent('qbx_core:client:onGroupUpdate', player.PlayerData.source, gangName)
     end, true)
@@ -896,7 +896,7 @@ function CreatePlayer(playerData, Offline)
     ---@deprecated use exports.qbx_core:SetCharInfo instead
     ---@param cardNumber number
     function self.Functions.SetCreditCard(cardNumber)
-        SetPlayerData(self.PlayerData.source, 'charinfo', {'card'}, cardNumber)
+        SetPlayerData(self.PlayerData.source, {'charinfo', 'card'}, cardNumber)
     end
 
     ---@deprecated use exports.qbx_core:Save or exports.qbx_core:SaveOffline instead
@@ -949,7 +949,7 @@ function CreatePlayer(playerData, Offline)
             end
         end
 
-        SetPlayerData(self.PlayerData.source, 'job', nil, self.PlayerData.job, function()
+        SetPlayerData(self.PlayerData.source, 'job', self.PlayerData.job, function()
             TriggerEvent('QBCore:Server:OnJobUpdate', self.PlayerData.source, self.PlayerData.job)
             TriggerClientEvent('QBCore:Client:OnJobUpdate', self.PlayerData.source, self.PlayerData.job)
         end)
@@ -984,7 +984,7 @@ function CreatePlayer(playerData, Offline)
             end
         end
 
-        SetPlayerData(self.PlayerData.source, 'gang', nil, self.PlayerData.gang, function()
+        SetPlayerData(self.PlayerData.source, 'gang', self.PlayerData.gang, function()
             TriggerEvent('QBCore:Server:OnGangUpdate', self.PlayerData.source, self.PlayerData.gang)
             TriggerClientEvent('QBCore:Client:OnGangUpdate', self.PlayerData.source, self.PlayerData.gang)
         end)
@@ -1067,13 +1067,13 @@ end
 exports('SaveOffline', SaveOffline)
 
 ---@param identifier Source | string
----@param key string
----@param subKeys? string[]
+---@param key string | string[]
 ---@param value any
 ---@param cb? function A function that's called after the standard SetPlayerData events are triggered if the player is online
 ---@param cancelDbUpdate? boolean When true, makes sure the database doesn't get updated as a result of this change
-function SetPlayerData(identifier, key, subKeys, value, cb, cancelDbUpdate)
-    if type(key) ~= 'string' then return end
+function SetPlayerData(identifier, key, value, cb, cancelDbUpdate)
+    local hasSubKeys = type(key) == 'table'
+    if type(key) ~= 'string' or not hasSubKeys then return end
 
     local player = type(identifier) == 'string' and (GetPlayerByCitizenId(identifier) or GetOfflinePlayer(identifier)) or GetPlayer(identifier)
 
@@ -1082,40 +1082,40 @@ function SetPlayerData(identifier, key, subKeys, value, cb, cancelDbUpdate)
         return
     end
 
-    local oldValue = player.PlayerData[key]
+    local oldValue = player.PlayerData[hasSubKeys and key[1] or key]
 
-    if type(subKeys) == "table" then
-        local current = player.PlayerData[key]
+    if hasSubKeys then
+        local current = player.PlayerData[hasSubKeys and key[1] or key]
         -- We don't check the last one because otherwise we lose the table reference
-        for i = 1, #subKeys - 1 do
-            local newCurrent = current[subKeys[i]]
+        for i = 2, #key - 1 do
+            local newCurrent = current[key[i]]
             if newCurrent then
                 current = newCurrent
-            elseif i ~= (#subKeys - 1) then
+            elseif i ~= (#key - 1) then
                 -- if an invalid key is specified and we are not on the last one, stop trying to update
                 -- reason for allowing the last one to not exist is so we can insert new values
-                error(('key %s doesn\'t exist in table player.PlayerData.%s'):format(subKeys[i], key))
+                error(('key %s doesn\'t exist in table player.PlayerData.%s'):format(key[i], key[1]))
                 return
             end
         end
 
-        local lastIndex = #subKeys
-        oldValue = current[subKeys[lastIndex]]
-        current[subKeys[lastIndex]] = value
+        local lastIndex = #key
+        oldValue = current[key[lastIndex]]
+        current[key[lastIndex]] = value
     else
         player.PlayerData[key] = value
     end
 
     if not cancelDbUpdate then
-        storage.addPlayerDataUpdate(player.PlayerData.citizenid, key, subKeys, value)
+        storage.addPlayerDataUpdate(player.PlayerData.citizenid, key, value)
     end
 
     if player.Offline then return end
 
     TriggerEvent('QBCore:Player:SetPlayerData', player.PlayerData)
     TriggerClientEvent('QBCore:Player:SetPlayerData', player.PlayerData.source, player.PlayerData)
-    TriggerEvent('qbx_core:server:setPlayerData', player.PlayerData.source, key, subKeys, value, oldValue)
-    TriggerClientEvent('qbx_core:client:setPlayerData', player.PlayerData.source, key, subKeys, value, oldValue)
+    TriggerEvent('qbx_core:server:setPlayerData', player.PlayerData.source, key, value, oldValue)
+    TriggerClientEvent('qbx_core:client:setPlayerData', player.PlayerData.source, key, value, oldValue)
 
     if not cb then return end
 
@@ -1139,7 +1139,7 @@ function SetMetadata(identifier, metadata, value)
     end
 
     local oldValue = player.PlayerData.metadata[metadata]
-    SetPlayerData(identifier, 'metadata', {metadata}, value, function()
+    SetPlayerData(identifier, {'metadata', metadata}, value, function()
         local playerState = Player(player.PlayerData.source).state
 
         TriggerClientEvent('qbx_core:client:onSetMetaData', player.PlayerData.source, metadata, oldValue, value)
@@ -1228,7 +1228,7 @@ function AddMoney(identifier, moneyType, amount, reason)
         amount = amount
     }) then return false end
 
-    SetPlayerData(identifier, 'money', {moneyType}, player.PlayerData.money[moneyType] + amount, function()
+    SetPlayerData(identifier, {'money', moneyType}, player.PlayerData.money[moneyType] + amount, function()
         local tags = amount > 100000 and config.logging.role or nil
         local resource = GetInvokingResource() or cache.resource
 
@@ -1279,7 +1279,7 @@ function RemoveMoney(identifier, moneyType, amount, reason)
         end
     end
 
-    SetPlayerData(identifier, 'money', {moneyType}, player.PlayerData.money[moneyType] - amount, function()
+    SetPlayerData(identifier, {'money', moneyType}, player.PlayerData.money[moneyType] - amount, function()
         local tags = amount > 100000 and config.logging.role or nil
         local resource = GetInvokingResource() or cache.resource
 
@@ -1324,7 +1324,7 @@ function SetMoney(identifier, moneyType, amount, reason)
     }) then return false end
 
     player.PlayerData.money[moneyType] = amount
-    SetPlayerData(identifier, 'money', {moneyType}, amount, function()
+    SetPlayerData(identifier, {'money', moneyType}, amount, function()
         local difference = amount - oldAmount
         local dirChange = difference < 0 and 'removed' or 'added'
         local absDifference = math.abs(difference)
diff --git a/server/storage/players.lua b/server/storage/players.lua
index 873228796..7c4550664 100644
--- a/server/storage/players.lua
+++ b/server/storage/players.lua
@@ -419,48 +419,52 @@ local function cleanPlayerGroups()
 end
 
 ---@param citizenid string
----@param key string
----@param subKeys? string[]
+---@param key string | string[]
 ---@param value any
-local function addPlayerDataUpdate(citizenid, key, subKeys, value)
-    key = otherNamedPlayerFields[key] or key
+local function addPlayerDataUpdate(citizenid, key, value)
+    local hasSubKeys = type(key) == 'table'
 
-    if jsonPlayerFields[key] == nil then
-        error(('Tried to update player data field %s when it doesn\'t exist. Value: %s'):format(key, value))
+    if hasSubKeys then
+        key[1] = otherNamedPlayerFields[key[1]] or key[1]
+    else
+        key = otherNamedPlayerFields[key] or key
+    end
+
+    if jsonPlayerFields[hasSubKeys and key[1] or key] == nil then
+        error(('Tried to update player data field %s when it doesn\'t exist. Value: %s'):format(hasSubKeys and key[1] or key, value))
         return
     end
 
-    if not jsonPlayerFields[key] and subKeys then
-        error(('Tried to update player data field %s as a json object when it isn\'t one'):format(key))
+    if hasSubKeys and not jsonPlayerFields[key[1]] then
+        error(('Tried to update player data field %s as a json object when it isn\'t one'):format(key[1]))
         return
     end
 
     value = type(value) == 'table' and json.encode(value) or value
 
     -- In sendPlayerDataUpdates we don't go more than 3 tables deep
-    if subKeys and #subKeys > 3 then
-        error(('Cannot save field %s because data is too big.\nsubKeys: %s\nvalue: %s'):format(key, json.encode(subKeys), value))
+    if hasSubKeys and #key > 4 then
+        error(('Cannot save field %s because data is too big.\nkeys: %s\nvalue: %s'):format(key[1], json.encode(key), value))
         return
     end
 
     local currentTable = isUpdating and playerDataUpdateQueue or collectedPlayerData
-
     if not currentTable[citizenid] then
         currentTable[citizenid] = {}
     end
 
-    currentTable[citizenid][key] = subKeys and {} or value
+    currentTable[citizenid][hasSubKeys and key[1] or key] = hasSubKeys and {} or value
 
-    if subKeys then
-        local current = currentTable[citizenid][key]
+    if hasSubKeys then
+        local current = currentTable[citizenid][key[1]]
         -- We don't check the last one because otherwise we lose the table reference
-        for i = 1, #subKeys - 1 do
-            if not current[subKeys[i]] then
-                current[subKeys[i]] = {}
+        for i = 2, #key - 1 do
+            if not current[key[i]] then
+                current[key[i]] = {}
             end
         end
 
-        current[subKeys[#subKeys]] = value
+        current[key[#key]] = value
     end
 end
 

From d5fecb16d69dac7087390c20af5be9e4f689edc3 Mon Sep 17 00:00:00 2001
From: BerkieBb <82737367+BerkieBb@users.noreply.github.com>
Date: Wed, 15 Jan 2025 02:01:23 +0100
Subject: [PATCH 07/15] fix(server): apply more key merges

---
 server/events.lua | 4 ++--
 server/player.lua | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/server/events.lua b/server/events.lua
index 2de6b30cb..334a71d3f 100644
--- a/server/events.lua
+++ b/server/events.lua
@@ -47,7 +47,7 @@ AddEventHandler('playerDropped', function(reason)
     GlobalState.PlayerCount = GetNumPlayerIndices()
 
     local player = QBX.Players[src]
-    SetPlayerData(player.PlayerData.source, 'lastLoggedOut', nil, os.time())
+    SetPlayerData(player.PlayerData.source, 'lastLoggedOut', os.time())
 
     logger.log({
         source = 'qbx_core',
@@ -268,4 +268,4 @@ end)
 ---@diagnostic disable-next-line: param-type-mismatch
 AddStateBagChangeHandler('stress', nil, function(bagName, _, value)
     playerStateBagCheck(bagName, 'stress', value)
-end)
+end)
\ No newline at end of file
diff --git a/server/player.lua b/server/player.lua
index 845ca6ae7..606f481b0 100644
--- a/server/player.lua
+++ b/server/player.lua
@@ -761,7 +761,7 @@ function CreatePlayer(playerData, Offline)
     ---@param key string
     ---@param val any
     function self.Functions.SetPlayerData(key, val)
-        SetPlayerData(self.PlayerData.source, key, nil, val)
+        SetPlayerData(self.PlayerData.source, key, val)
     end
 
     ---@deprecated use exports.qbx_core:SetMetadata instead

From 78728f7ad8a3c7f2e0a98a4a9ba18f94c3bb5bdb Mon Sep 17 00:00:00 2001
From: BerkieBb <82737367+BerkieBb@users.noreply.github.com>
Date: Wed, 15 Jan 2025 02:02:59 +0100
Subject: [PATCH 08/15] tweak(server): convert errors to lib print errors

---
 server/player.lua          | 6 +++---
 server/storage/players.lua | 6 +++---
 2 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/server/player.lua b/server/player.lua
index 606f481b0..58803fc8a 100644
--- a/server/player.lua
+++ b/server/player.lua
@@ -138,7 +138,7 @@ function SetJobDuty(identifier, onDuty)
     local player = type(identifier) == 'string' and (GetPlayerByCitizenId(identifier) or GetOfflinePlayer(identifier)) or GetPlayer(identifier)
 
     if not player then
-        error(('SetJobDuty couldn\'t find player with identifier %s'):format(identifier))
+        lib.print.error(('SetJobDuty couldn\'t find player with identifier %s'):format(identifier))
         return
     end
 
@@ -1078,7 +1078,7 @@ function SetPlayerData(identifier, key, value, cb, cancelDbUpdate)
     local player = type(identifier) == 'string' and (GetPlayerByCitizenId(identifier) or GetOfflinePlayer(identifier)) or GetPlayer(identifier)
 
     if not player then
-        error(('SetPlayerData couldn\'t find player with identifier %s'):format(identifier))
+        lib.print.error(('SetPlayerData couldn\'t find player with identifier %s'):format(identifier))
         return
     end
 
@@ -1094,7 +1094,7 @@ function SetPlayerData(identifier, key, value, cb, cancelDbUpdate)
             elseif i ~= (#key - 1) then
                 -- if an invalid key is specified and we are not on the last one, stop trying to update
                 -- reason for allowing the last one to not exist is so we can insert new values
-                error(('key %s doesn\'t exist in table player.PlayerData.%s'):format(key[i], key[1]))
+                lib.print.error(('key %s doesn\'t exist in table player.PlayerData.%s'):format(key[i], key[1]))
                 return
             end
         end
diff --git a/server/storage/players.lua b/server/storage/players.lua
index 7c4550664..6a8487a85 100644
--- a/server/storage/players.lua
+++ b/server/storage/players.lua
@@ -431,12 +431,12 @@ local function addPlayerDataUpdate(citizenid, key, value)
     end
 
     if jsonPlayerFields[hasSubKeys and key[1] or key] == nil then
-        error(('Tried to update player data field %s when it doesn\'t exist. Value: %s'):format(hasSubKeys and key[1] or key, value))
+        lib.print.error(('Tried to update player data field %s when it doesn\'t exist. Value: %s'):format(hasSubKeys and key[1] or key, value))
         return
     end
 
     if hasSubKeys and not jsonPlayerFields[key[1]] then
-        error(('Tried to update player data field %s as a json object when it isn\'t one'):format(key[1]))
+        lib.print.error(('Tried to update player data field %s as a json object when it isn\'t one'):format(key[1]))
         return
     end
 
@@ -444,7 +444,7 @@ local function addPlayerDataUpdate(citizenid, key, value)
 
     -- In sendPlayerDataUpdates we don't go more than 3 tables deep
     if hasSubKeys and #key > 4 then
-        error(('Cannot save field %s because data is too big.\nkeys: %s\nvalue: %s'):format(key[1], json.encode(key), value))
+        lib.print.error(('Cannot save field %s because data is too big.\nkeys: %s\nvalue: %s'):format(key[1], json.encode(key), value))
         return
     end
 

From a3d175949eb6c6772c7c2a1420689bb8944c4e13 Mon Sep 17 00:00:00 2001
From: BerkieBb <82737367+BerkieBb@users.noreply.github.com>
Date: Thu, 16 Jan 2025 13:01:29 +0100
Subject: [PATCH 09/15] fix(server/player): type check

---
 server/player.lua | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/server/player.lua b/server/player.lua
index 58803fc8a..4031ddb41 100644
--- a/server/player.lua
+++ b/server/player.lua
@@ -1073,7 +1073,7 @@ exports('SaveOffline', SaveOffline)
 ---@param cancelDbUpdate? boolean When true, makes sure the database doesn't get updated as a result of this change
 function SetPlayerData(identifier, key, value, cb, cancelDbUpdate)
     local hasSubKeys = type(key) == 'table'
-    if type(key) ~= 'string' or not hasSubKeys then return end
+    if type(key) ~= 'string' and not hasSubKeys then return end
 
     local player = type(identifier) == 'string' and (GetPlayerByCitizenId(identifier) or GetOfflinePlayer(identifier)) or GetPlayer(identifier)
 

From 07c7373526a3dc427e1f754ac3d8edfad05c408c Mon Sep 17 00:00:00 2001
From: BerkieBb <82737367+BerkieBb@users.noreply.github.com>
Date: Thu, 16 Jan 2025 13:14:09 +0100
Subject: [PATCH 10/15] fix(server): wrong for loop

---
 server/player.lua          | 21 +++++++++++----------
 server/storage/players.lua | 10 ++++++----
 2 files changed, 17 insertions(+), 14 deletions(-)

diff --git a/server/player.lua b/server/player.lua
index 4031ddb41..5c1e3135c 100644
--- a/server/player.lua
+++ b/server/player.lua
@@ -1086,16 +1086,17 @@ function SetPlayerData(identifier, key, value, cb, cancelDbUpdate)
 
     if hasSubKeys then
         local current = player.PlayerData[hasSubKeys and key[1] or key]
-        -- We don't check the last one because otherwise we lose the table reference
-        for i = 2, #key - 1 do
-            local newCurrent = current[key[i]]
-            if newCurrent then
-                current = newCurrent
-            elseif i ~= (#key - 1) then
-                -- if an invalid key is specified and we are not on the last one, stop trying to update
-                -- reason for allowing the last one to not exist is so we can insert new values
-                lib.print.error(('key %s doesn\'t exist in table player.PlayerData.%s'):format(key[i], key[1]))
-                return
+        if #key > 2 then
+            -- We don't check the last one because otherwise we lose the table reference
+            for i = 2, #key - 1 do
+                local newCurrent = current[key[i]]
+                if newCurrent then
+                    current = newCurrent
+                else
+                    -- if an invalid key is specified , stop trying to update
+                    lib.print.error(('key %s doesn\'t exist in table player.PlayerData.%s'):format(key[i], key[1]))
+                    return
+                end
             end
         end
 
diff --git a/server/storage/players.lua b/server/storage/players.lua
index 6a8487a85..168b9120d 100644
--- a/server/storage/players.lua
+++ b/server/storage/players.lua
@@ -457,10 +457,12 @@ local function addPlayerDataUpdate(citizenid, key, value)
 
     if hasSubKeys then
         local current = currentTable[citizenid][key[1]]
-        -- We don't check the last one because otherwise we lose the table reference
-        for i = 2, #key - 1 do
-            if not current[key[i]] then
-                current[key[i]] = {}
+        if #key > 2 then
+            -- We don't check the last one because otherwise we lose the table reference
+            for i = 2, #key - 1 do
+                if not current[key[i]] then
+                    current[key[i]] = {}
+                end
             end
         end
 

From 23d6ba9550648a164d1f44383bccd41e8d8fa6f1 Mon Sep 17 00:00:00 2001
From: BerkieBb <82737367+BerkieBb@users.noreply.github.com>
Date: Thu, 16 Jan 2025 13:30:24 +0100
Subject: [PATCH 11/15] tweak(storage/players): implement readability

Thanks Frowmza
---
 server/storage/players.lua | 41 ++++++++++++++++++--------------------
 1 file changed, 19 insertions(+), 22 deletions(-)

diff --git a/server/storage/players.lua b/server/storage/players.lua
index 168b9120d..95453e1c1 100644
--- a/server/storage/players.lua
+++ b/server/storage/players.lua
@@ -470,6 +470,22 @@ local function addPlayerDataUpdate(citizenid, key, value)
     end
 end
 
+---@param key string
+---@param nestedTable table<string, any>
+---@param path string?
+---@param citizenid string
+local function updateNestedPlayerData(key, nestedTable, citizenid, path)
+    for k, v in pairs(nestedTable) do
+        local currentPath = path and ('%s.%s'):format(path, k) or k
+        if type(v) == 'table' then
+            updateNestedPlayerData(key, v, citizenid, currentPath)
+        else
+            local query = ('UPDATE players SET %s = JSON_SET(%s, "$.%s", ?) WHERE citizenid = ?'):format(key, key, currentPath)
+            MySQL.prepare.await(query, { v, citizenid })
+        end
+    end
+end
+
 local function sendPlayerDataUpdates()
     -- We implement this to ensure when updating no values are added to our updating sequence to prevent data loss by accidentally skipping over it
     isUpdating = true
@@ -477,29 +493,10 @@ local function sendPlayerDataUpdates()
     for citizenid, playerData in pairs(collectedPlayerData) do
         for key, data in pairs(playerData) do
             if type(data) == 'table' then
-                -- We go a maximum of 3 tables deep into the current table to prevent misuse and qbox doesn't have more than 2 actually
-                -- If we were to make this variable to the amount of data there is, then enough tables can crash the server
-                for k, v in pairs(data) do
-                    if type(v) == 'table' then
-                        for k2, v2 in pairs(v) do
-                            if type(v2) == 'table' then
-                                for k3, v3 in pairs(v2) do
-                                    if type(v3) == 'table' then
-                                        v3 = json.encode(v3)
-                                    end
-
-                                    MySQL.prepare.await(('UPDATE players SET %s = JSON_SET(%s, "$.%s.%s.%s", ?) WHERE citizenid = ?'):format(key, key, k, k2, k3), { v3, citizenid })
-                                end
-                            else
-                                MySQL.prepare.await(('UPDATE players SET %s = JSON_SET(%s, "$.%s.%s", ?) WHERE citizenid = ?'):format(key, key, k, k2), { v2, citizenid })
-                            end
-                        end
-                    else
-                        MySQL.prepare.await(('UPDATE players SET %s = JSON_SET(%s, "$.%s", ?) WHERE citizenid = ?'):format(key, key, k), { v, citizenid })
-                    end
-                end
+                updateNestedPlayerData(key, data, citizenid)
             else
-                MySQL.prepare.await(('UPDATE players SET %s = ? WHERE citizenid = ?'):format(key), { data, citizenid })
+                local query = ('UPDATE players SET %s = ? WHERE citizenid = ?'):format(key)
+                MySQL.prepare.await(query, { data, citizenid })
             end
         end
     end

From a97605bc06579059f827d1cadfca0d780a532daf Mon Sep 17 00:00:00 2001
From: BerkieBb <82737367+BerkieBb@users.noreply.github.com>
Date: Thu, 16 Jan 2025 14:37:15 +0100
Subject: [PATCH 12/15] fix(server/loops): wrong time interval

---
 server/loops.lua | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/server/loops.lua b/server/loops.lua
index 8621bdd47..373cbe10c 100644
--- a/server/loops.lua
+++ b/server/loops.lua
@@ -50,7 +50,7 @@ CreateThread(function()
 end)
 
 CreateThread(function()
-    local interval = 60 * config.dbUpdateInterval
+    local interval = 1000 * config.dbUpdateInterval
     while true do
         Wait(interval)
         storage.sendPlayerDataUpdates()

From 49245c95ef2b81660ecfaa4f1f0b6d60d37e99b5 Mon Sep 17 00:00:00 2001
From: BerkieBb <82737367+BerkieBb@users.noreply.github.com>
Date: Thu, 16 Jan 2025 14:41:02 +0100
Subject: [PATCH 13/15] fix(storage/players): update current too

---
 server/storage/players.lua | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/server/storage/players.lua b/server/storage/players.lua
index 95453e1c1..460bf8aea 100644
--- a/server/storage/players.lua
+++ b/server/storage/players.lua
@@ -463,6 +463,8 @@ local function addPlayerDataUpdate(citizenid, key, value)
                 if not current[key[i]] then
                     current[key[i]] = {}
                 end
+
+                current = current[key[i]]
             end
         end
 

From eb49a62f8c9ccd15e37d05f39d892acddd1e1034 Mon Sep 17 00:00:00 2001
From: BerkieBb <82737367+BerkieBb@users.noreply.github.com>
Date: Thu, 16 Jan 2025 14:55:59 +0100
Subject: [PATCH 14/15] fix(storage/players): let query encode value

Co-authored-by: Frowmza <66181451+Frowmza@users.noreply.github.com>
---
 server/storage/players.lua | 1 -
 1 file changed, 1 deletion(-)

diff --git a/server/storage/players.lua b/server/storage/players.lua
index 460bf8aea..fc2df74cb 100644
--- a/server/storage/players.lua
+++ b/server/storage/players.lua
@@ -440,7 +440,6 @@ local function addPlayerDataUpdate(citizenid, key, value)
         return
     end
 
-    value = type(value) == 'table' and json.encode(value) or value
 
     -- In sendPlayerDataUpdates we don't go more than 3 tables deep
     if hasSubKeys and #key > 4 then

From 44f4f68e30fbe677b98d582140511272e936c95e Mon Sep 17 00:00:00 2001
From: BerkieBb <82737367+BerkieBb@users.noreply.github.com>
Date: Thu, 16 Jan 2025 14:57:14 +0100
Subject: [PATCH 15/15] style(storage/players): spacing

---
 server/storage/players.lua | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/server/storage/players.lua b/server/storage/players.lua
index fc2df74cb..125c4ede0 100644
--- a/server/storage/players.lua
+++ b/server/storage/players.lua
@@ -440,7 +440,6 @@ local function addPlayerDataUpdate(citizenid, key, value)
         return
     end
 
-
     -- In sendPlayerDataUpdates we don't go more than 3 tables deep
     if hasSubKeys and #key > 4 then
         lib.print.error(('Cannot save field %s because data is too big.\nkeys: %s\nvalue: %s'):format(key[1], json.encode(key), value))
@@ -548,4 +547,4 @@ return {
     searchPlayerEntities = searchPlayerEntities,
     sendPlayerDataUpdates = sendPlayerDataUpdates,
     addPlayerDataUpdate = addPlayerDataUpdate
-}
\ No newline at end of file
+}