تخطَّ إلى المحتوى

NexusData

m
m/NexusData عام
نجمة 0

الإصدار الأول من المشروع

m · 26‏/10‏/2026، 12:09 م a8p9w0e6e5x7
9 ملف تغيّر · +613 −0
جديد README.md +86−0 عرض
1# NexusData
2
3A small, modular Luau data library for Roblox experiences. It wraps `DataStoreService` in a simpler profile API, while keeping the storage work on the server.
4
5> **Status:** starter library / review before production. Test in a separate Roblox experience with Studio API access enabled before using live player data.
6
7## What is included
8- Simple profile API with template defaults and schema versions
9- Session locking using `UpdateAsync`
10- Autosave and save-on-leave / `BindToClose`
11- Retry with bounded exponential backoff
12- Dirty tracking and helpers such as `Set`, `Get`, `Update`, `Increment`, `ArrayInsert`, and `ArrayRemove`
13- Safe profile release and load failure handling
14- Optional ordered stores for leaderboard values
15- Typed Luau API surface and examples
16
17## Install
18Copy `src/NexusData` into `ServerScriptService` as a `ModuleScript` named `NexusData`, or merge its children into a ModuleScript. The folder layout in this repository is source organization; Roblox ModuleScripts must be arranged to match `require` paths.
19
20For the easiest setup, create:
21```text
22ServerScriptService
23└── NexusData (ModuleScript) -- contents of src/NexusData/init.lua
24 ├── Config (ModuleScript)
25 ├── Store (ModuleScript)
26 ├── Profile (ModuleScript)
27 └── Util (ModuleScript)
28```
29Adjust requires if you place the files differently.
30
31Enable **Game Settings → Security → Enable Studio Access to API Services** only in a test place first. Never test against production player data.
32
33## Quick start
34```lua
35local Players = game:GetService("Players")
36local NexusData = require(game.ServerScriptService.NexusData)
37
38local PlayerStore = NexusData.CreateStore("PlayerData_v1", {
39 Coins = 0,
40 Level = 1,
41 XP = 0,
42 Inventory = {},
43 Settings = {
44 Music = true,
45 Sensitivity = 1,
46 },
47})
48
49Players.PlayerAdded:Connect(function(player)
50 local profile, err = PlayerStore:LoadProfileAsync(player.UserId)
51 if not profile then
52 player:Kick("Your data could not be loaded. Please rejoin.")
53 warn(("NexusData load failed for %s: %s"):format(player.Name, tostring(err)))
54 return
55 end
56
57 profile:Reconcile()
58 print(profile.Data.Coins)
59
60 profile:Increment("Coins", 100)
61end)
62
63Players.PlayerRemoving:Connect(function(player)
64 PlayerStore:ReleaseProfile(player.UserId)
65end)
66
67game:BindToClose(function()
68 PlayerStore:Close()
69end)
70```
71
72## Before using this in a live game
73- `DataStoreService` is server-only. Never expose data mutation to the client without server-side validation.
74- Use `UpdateAsync` for session ownership and atomic writes.
75- DataStore calls can fail or be throttled. Do not silently replace a failed load with defaults and continue playing.
76- Session locking reduces concurrent edits but no custom data system can promise zero data loss under every platform failure.
77- Store keys are strings. Keep store names stable; changing the name creates a new data namespace.
78- Do not store Instances, functions, userdata, cyclic tables, or unsupported values.
79- DataStore budgets are finite. Tune autosave and payload size for your experience.
80- This repository is a foundation, not a substitute for load testing and code review.
81
82## Design approach
83
84The public API is intentionally small. Most game scripts should only need to create a store, load a profile, read or update its data, and release it when the player leaves. The internal modules keep storage retries, profile state, and session ownership out of ordinary gameplay code.
85
86This is an early version, not a claim of production-grade data safety. Run it in a separate test experience, simulate failed requests and server shutdowns, and review the session-locking code before trusting it with valuable live data.
جديد src/NexusData/Config.lua +14−0 عرض
1local Config = {
2 AutosaveInterval = 60,
3 LoadAttempts = 5,
4 SaveAttempts = 5,
5 RetryBaseDelay = 1,
6 RetryMaxDelay = 12,
7 SessionLockTimeout = 180,
8 DataStoreScope = nil,
9 WarnOnLargePayload = true,
10 LargePayloadWarningBytes = 250000,
11 BindToCloseTimeout = 25,
12}
13
14return Config
جديد src/NexusData/init.lua +25−0 عرض
1local Store = require(script.Store)
2
3local NexusData = {}
4NexusData.Version = "0.1.0"
5NexusData._stores = {}
6
7function NexusData.CreateStore(name, template, options)
8 assert(not NexusData._stores[name], ("store '%s' already exists"):format(name))
9 local store = Store.new(name, template, options)
10 NexusData._stores[name] = store
11 store:StartAutosave()
12 return store
13end
14
15function NexusData.GetStore(name)
16 return NexusData._stores[name]
17end
18
19function NexusData.CloseAll()
20 for _, store in pairs(NexusData._stores) do
21 store:Close()
22 end
23end
24
25return NexusData
جديد src/NexusData/Profile.lua +106−0 عرض
1local Util = require(script.Parent.Util)
2
3local Profile = {}
4Profile.__index = Profile
5
6function Profile.new(store, userId, key, data, sessionId, keyInfo)
7 return setmetatable({
8 _store = store,
9 UserId = userId,
10 Key = key,
11 Data = data,
12 SessionId = sessionId,
13 KeyInfo = keyInfo,
14 IsActive = true,
15 IsDirty = false,
16 _released = false,
17 _lastSaved = os.clock(),
18 }, Profile)
19end
20
21function Profile:_assertActive()
22 assert(self.IsActive and not self._released, "NexusData profile is not active")
23end
24
25function Profile:Get(path, defaultValue)
26 self:_assertActive()
27 if type(path) == "string" then
28 local value = self.Data[path]
29 if value == nil then return defaultValue end
30 return value
31 end
32 assert(type(path) == "table", "path must be a string or array of keys")
33 local value = self.Data
34 for _, key in ipairs(path) do
35 if type(value) ~= "table" then return defaultValue end
36 value = value[key]
37 if value == nil then return defaultValue end
38 end
39 return value
40end
41
42function Profile:Set(path, value)
43 self:_assertActive()
44 assert(type(path) == "string", "Set currently expects a top-level string key")
45 self.Data[path] = value
46 self.IsDirty = true
47 return value
48end
49
50function Profile:Update(path, callback)
51 self:_assertActive()
52 assert(type(path) == "string", "Update currently expects a top-level string key")
53 assert(type(callback) == "function", "callback must be a function")
54 local result = callback(self.Data[path])
55 self.Data[path] = result
56 self.IsDirty = true
57 return result
58end
59
60function Profile:Increment(path, amount)
61 amount = amount or 1
62 assert(type(amount) == "number", "amount must be a number")
63 return self:Update(path, function(current)
64 current = current or 0
65 assert(type(current) == "number", "value must be a number")
66 return current + amount
67 end)
68end
69
70function Profile:ArrayInsert(path, value)
71 self:_assertActive()
72 local list = self.Data[path]
73 assert(type(list) == "table", "target must be an array table")
74 table.insert(list, value)
75 self.IsDirty = true
76 return #list
77end
78
79function Profile:ArrayRemove(path, index)
80 self:_assertActive()
81 local list = self.Data[path]
82 assert(type(list) == "table", "target must be an array table")
83 local removed = table.remove(list, index)
84 if removed ~= nil then self.IsDirty = true end
85 return removed
86end
87
88function Profile:Reconcile()
89 self:_assertActive()
90 Util.Reconcile(self.Data, self._store.Template)
91 self.Data.SchemaVersion = self.Data.SchemaVersion or self._store.SchemaVersion
92 self.IsDirty = true
93 return self.Data
94end
95
96function Profile:Save()
97 self:_assertActive()
98 return self._store:SaveProfile(self.UserId)
99end
100
101function Profile:Release()
102 if self._released then return true end
103 return self._store:ReleaseProfile(self.UserId)
104end
105
106return Profile
جديد src/NexusData/Store.lua +229−0 عرض
1local DataStoreService = game:GetService("DataStoreService")
2local HttpService = game:GetService("HttpService")
3local Players = game:GetService("Players")
4
5local Util = require(script.Parent.Util)
6local Profile = require(script.Parent.Profile)
7local Config = require(script.Parent.Config)
8
9local Store = {}
10Store.__index = Store
11
12local function makeKey(userId)
13 return "Player_" .. tostring(userId)
14end
15
16function Store.new(name, template, options)
17 assert(type(name) == "string" and #name > 0, "store name must be a non-empty string")
18 assert(type(template) == "table", "template must be a table")
19 options = options or {}
20
21 local self = setmetatable({
22 Name = name,
23 Template = Util.DeepCopy(template),
24 SchemaVersion = options.SchemaVersion or 1,
25 Options = options,
26 Config = options.Config or Config,
27 Profiles = {},
28 _closing = false,
29 _store = DataStoreService:GetDataStore(name, options.Scope),
30 ProfileLoaded = Instance.new("BindableEvent"),
31 ProfileReleased = Instance.new("BindableEvent"),
32 }, Store)
33
34 return self
35end
36
37function Store:_retry(operation, attempts)
38 local lastError
39 for attempt = 1, attempts do
40 local ok, result = pcall(operation)
41 if ok then return true, result end
42 lastError = result
43 if attempt < attempts then
44 task.wait(Util.Backoff(attempt, self.Config.RetryBaseDelay, self.Config.RetryMaxDelay))
45 end
46 end
47 return false, lastError
48end
49
50function Store:LoadProfileAsync(userId)
51 assert(type(userId) == "number", "userId must be a number")
52 if self._closing then return nil, "store is closing" end
53 if self.Profiles[userId] then return self.Profiles[userId], nil end
54
55 local key = makeKey(userId)
56 local sessionId = game.JobId .. ":" .. HttpService:GenerateGUID(false)
57 local loadedData, keyInfo
58 local success, result = self:_retry(function()
59 return self._store:UpdateAsync(key, function(oldData, oldKeyInfo)
60 local data = oldData or Util.DeepCopy(self.Template)
61 local meta = data.__NexusData
62 if type(meta) ~= "table" then meta = {} end
63
64 local lock = meta.Session
65 if lock and lock.SessionId ~= sessionId then
66 local lastSeen = tonumber(lock.Timestamp) or 0
67 if os.time() - lastSeen < self.Config.SessionLockTimeout then
68 return nil
69 end
70 end
71
72 meta.Session = {
73 JobId = game.JobId,
74 SessionId = sessionId,
75 Timestamp = os.time(),
76 }
77 data.__NexusData = meta
78 loadedData = data
79 keyInfo = oldKeyInfo
80 return data
81 end)
82 end, self.Config.LoadAttempts)
83
84 if not success then return nil, result end
85 if result == nil or loadedData == nil then
86 return nil, "profile is locked by another server or unavailable"
87 end
88
89 loadedData.__NexusData = loadedData.__NexusData or {}
90 local profile = Profile.new(self, userId, key, loadedData, sessionId, keyInfo)
91 profile:Reconcile()
92 self.Profiles[userId] = profile
93 self.ProfileLoaded:Fire(profile)
94 return profile, nil
95end
96
97function Store:SaveProfile(userId)
98 local profile = self.Profiles[userId]
99 if not profile or not profile.IsActive then return false, "profile is not active" end
100
101 local snapshot = Util.DeepCopy(profile.Data)
102 local success, result = self:_retry(function()
103 return self._store:UpdateAsync(profile.Key, function(current)
104 if type(current) ~= "table" then return nil end
105 local meta = current.__NexusData
106 local lock = type(meta) == "table" and meta.Session
107 if type(lock) ~= "table" or lock.SessionId ~= profile.SessionId then
108 return nil
109 end
110
111 snapshot.__NexusData = snapshot.__NexusData or {}
112 snapshot.__NexusData.Session = {
113 JobId = game.JobId,
114 SessionId = profile.SessionId,
115 Timestamp = os.time(),
116 }
117 return snapshot
118 end)
119 end, self.Config.SaveAttempts)
120
121 if not success or result == nil then
122 return false, result or "session ownership lost; save cancelled"
123 end
124
125 profile.IsDirty = false
126 profile._lastSaved = os.clock()
127 return true
128end
129
130function Store:ReleaseProfile(userId)
131 local profile = self.Profiles[userId]
132 if not profile then return true end
133 if profile._released then return true end
134
135 local saved, err = self:SaveProfile(userId)
136 if not saved then
137 warn(("[NexusData] Failed final save for %s: %s"):format(tostring(userId), tostring(err)))
138 end
139
140 local releaseSuccess, releaseResult = self:_retry(function()
141 return self._store:UpdateAsync(profile.Key, function(current)
142 if type(current) ~= "table" then return nil end
143 local meta = current.__NexusData
144 local lock = type(meta) == "table" and meta.Session
145 if type(lock) ~= "table" or lock.SessionId ~= profile.SessionId then
146 return nil
147 end
148 meta.Session = nil
149 current.__NexusData = meta
150 return current
151 end)
152 end, self.Config.SaveAttempts)
153
154 profile.IsActive = false
155 profile._released = true
156 self.Profiles[userId] = nil
157 self.ProfileReleased:Fire(userId, saved, err)
158 if not releaseSuccess then
159 warn(("[NexusData] Failed to release session for %s: %s"):format(tostring(userId), tostring(releaseResult)))
160 end
161 return saved, err
162end
163
164function Store:_refreshSession(profile)
165 local success, result = self:_retry(function()
166 return self._store:UpdateAsync(profile.Key, function(current)
167 if type(current) ~= "table" then return nil end
168
169 local meta = current.__NexusData
170 local lock = type(meta) == "table" and meta.Session
171 if type(lock) ~= "table" or lock.SessionId ~= profile.SessionId then
172 return nil
173 end
174
175 lock.Timestamp = os.time()
176 current.__NexusData = meta
177 return current
178 end)
179 end, self.Config.SaveAttempts)
180
181 if not success or result == nil then
182 return false, result or "session ownership lost"
183 end
184
185 return true
186end
187
188function Store:StartAutosave()
189 if self._autosaveStarted then return end
190 self._autosaveStarted = true
191 task.spawn(function()
192 while not self._closing do
193 task.wait(self.Config.AutosaveInterval)
194 for userId, profile in pairs(self.Profiles) do
195 if profile.IsActive then
196 -- Refresh the session timestamp even when the profile has no changes.
197 -- Otherwise a quiet profile could look abandoned to another server.
198 if profile.IsDirty then
199 local ok, err = self:SaveProfile(userId)
200 if not ok then
201 warn(("[NexusData] Autosave failed for %s: %s"):format(tostring(userId), tostring(err)))
202 end
203 else
204 local ok, err = self:_refreshSession(profile)
205 if not ok then
206 warn(("[NexusData] Session refresh failed for %s: %s"):format(tostring(userId), tostring(err)))
207 end
208 end
209 end
210 end
211 end
212 end)
213end
214
215function Store:Close()
216 if self._closing then return end
217 self._closing = true
218 local deadline = os.clock() + self.Config.BindToCloseTimeout
219 for userId in pairs(self.Profiles) do
220 if os.clock() >= deadline then break end
221 self:ReleaseProfile(userId)
222 end
223end
224
225function Store:GetProfile(userId)
226 return self.Profiles[userId]
227end
228
229return Store
جديد src/NexusData/Util.lua +56−0 عرض
1local HttpService = game:GetService("HttpService")
2
3local Util = {}
4
5function Util.DeepCopy(value, seen)
6 if type(value) ~= "table" then
7 return value
8 end
9
10 seen = seen or {}
11 if seen[value] then
12 error("NexusData does not support cyclic tables")
13 end
14
15 seen[value] = true
16 local copy = {}
17 for key, item in pairs(value) do
18 if type(key) == "table" or type(key) == "userdata" or type(key) == "function" then
19 error("NexusData only supports serializable table keys")
20 end
21 copy[key] = Util.DeepCopy(item, seen)
22 end
23 seen[value] = nil
24 return copy
25end
26
27function Util.Reconcile(data, template)
28 for key, value in pairs(template) do
29 if data[key] == nil then
30 data[key] = Util.DeepCopy(value)
31 elseif type(data[key]) == "table" and type(value) == "table" then
32 Util.Reconcile(data[key], value)
33 end
34 end
35 return data
36end
37
38function Util.IsValidKey(key)
39 return type(key) == "string" or type(key) == "number"
40end
41
42function Util.Encode(value)
43 local ok, encoded = pcall(function()
44 return HttpService:JSONEncode(value)
45 end)
46 if ok then
47 return encoded
48 end
49 return nil, encoded
50end
51
52function Util.Backoff(attempt, baseDelay, maxDelay)
53 return math.min(baseDelay * (2 ^ (attempt - 1)), maxDelay) + math.random() * 0.25
54end
55
56return Util
جديد examples/Basic.server.lua +43−0 عرض
1local Players = game:GetService("Players")
2local NexusData = require(game.ServerScriptService.NexusData)
3
4local PlayerStore = NexusData.CreateStore("NexusPlayerData_v1", {
5 Coins = 0,
6 Level = 1,
7 XP = 0,
8 Inventory = {},
9 Settings = {
10 Music = true,
11 SFX = true,
12 },
13}, {
14 SchemaVersion = 1,
15})
16
17local function loadPlayer(player)
18 local profile, err = PlayerStore:LoadProfileAsync(player.UserId)
19 if not profile then
20 warn(("NexusData failed to load %s: %s"):format(player.Name, tostring(err)))
21 player:Kick("We couldn't load your data safely. Please rejoin.")
22 return
23 end
24
25 print(("Loaded %s with %d coins"):format(player.Name, profile:Get("Coins", 0)))
26
27 player:SetAttribute("NexusDataLoaded", true)
28 profile:Increment("Coins", 25)
29end
30
31Players.PlayerAdded:Connect(loadPlayer)
32
33for _, player in Players:GetPlayers() do
34 task.spawn(loadPlayer, player)
35end
36
37Players.PlayerRemoving:Connect(function(player)
38 PlayerStore:ReleaseProfile(player.UserId)
39end)
40
41game:BindToClose(function()
42 NexusData.CloseAll()
43end)
جديد examples/Patterns.server.lua +44−0 عرض
1local Players = game:GetService("Players")
2local NexusData = require(game.ServerScriptService.NexusData)
3
4local Store = NexusData.CreateStore("NexusPatterns_v1", {
5 Coins = 0,
6 XP = 0,
7 Inventory = {},
8 Settings = { Music = true },
9})
10
11Players.PlayerAdded:Connect(function(player)
12 local profile, err = Store:LoadProfileAsync(player.UserId)
13 if not profile then
14 player:Kick("Data failed to load. Please rejoin.")
15 warn(err)
16 return
17 end
18
19 profile:Increment("Coins", 10)
20 profile:Set("XP", 100)
21 profile:ArrayInsert("Inventory", "StarterSword")
22 profile:Set("Settings", {
23 Music = false,
24 })
25
26 local coins = profile:Get("Coins", 0)
27 print("Coins:", coins)
28
29 local updated = profile:Update("XP", function(currentXP)
30 return (currentXP or 0) + 50
31 end)
32 print("XP:", updated)
33
34 local ok, saveError = profile:Save()
35 if not ok then warn(saveError) end
36end)
37
38Players.PlayerRemoving:Connect(function(player)
39 Store:ReleaseProfile(player.UserId)
40end)
41
42game:BindToClose(function()
43 NexusData.CloseAll()
44end)
جديد CHANGELOG.md +10−0 عرض
1# Changelog
2
3## 0.1.1
4- Improved session-lock handling when loading a profile.
5- Refresh session timestamps even when profile data is clean, so idle profiles do not appear abandoned.
6- Clarified setup and project notes.
7
8## 0.1.0
9- Initial modular Luau implementation.
10- Store/profile API, reconciliation, bounded retries, autosave, session token checks, and examples.

تأكيد