@rbxts/dataforge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/default.project.json +6 -0
- package/index.d.ts +423 -0
- package/package.json +32 -0
- package/src/hooks/memory.luau +186 -0
- package/src/hooks/roblox.luau +68 -0
- package/src/init.luau +58 -0
- package/src/mutex.luau +107 -0
- package/src/profile/common.luau +102 -0
- package/src/profile/lockless.luau +502 -0
- package/src/profile/peek.luau +79 -0
- package/src/profile/profile.luau +498 -0
- package/src/schedulers/roblox.luau +18 -0
- package/src/schedulers/virtual.luau +128 -0
- package/src/schedulers/zune.luau +18 -0
- package/src/store.luau +409 -0
- package/src/tx/handle.luau +427 -0
- package/src/tx/operation.luau +117 -0
- package/src/types.luau +375 -0
- package/src/utils.luau +104 -0
package/src/store.luau
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
--! strict
|
|
2
|
+
|
|
3
|
+
local types = require "./types"
|
|
4
|
+
local utils = require "./utils"
|
|
5
|
+
local roblox_scheduler = require "./schedulers/roblox"
|
|
6
|
+
local roblox_hook = require "./hooks/roblox"
|
|
7
|
+
local profile = require "./profile/profile"
|
|
8
|
+
local lockless_profile = require "./profile/lockless"
|
|
9
|
+
local peek_profile = require "./profile/peek"
|
|
10
|
+
local tx = require "./tx/handle"
|
|
11
|
+
|
|
12
|
+
type Array<T> = { T }
|
|
13
|
+
|
|
14
|
+
type Config<T> = types.Config<T>
|
|
15
|
+
type ResolvedConfig<T> = types.ResolvedConfig<T>
|
|
16
|
+
type Scheduler = types.Scheduler
|
|
17
|
+
|
|
18
|
+
type ProfileBase<T = any> = types.ProfileBase<T>
|
|
19
|
+
type Profile<T = any> = types.Profile<T>
|
|
20
|
+
type LocklessProfile<T = any> = types.LocklessProfile<T>
|
|
21
|
+
type PeekProfile<T = any> = types.PeekProfile<T>
|
|
22
|
+
type HooksList<T... = ()> = types.HooksList<T...>
|
|
23
|
+
|
|
24
|
+
export type Store<T = any> = {
|
|
25
|
+
_loading: { [string]: boolean },
|
|
26
|
+
_loading_locks: { [string]: { thread } },
|
|
27
|
+
|
|
28
|
+
_hook: types.Hook,
|
|
29
|
+
_scheduler: types.Scheduler,
|
|
30
|
+
|
|
31
|
+
_datastore: types.DatastoreHandle,
|
|
32
|
+
closed: boolean,
|
|
33
|
+
|
|
34
|
+
profiles: { [string]: Profile<T> },
|
|
35
|
+
lockless_profiles: { [string]: LocklessProfile<T> },
|
|
36
|
+
|
|
37
|
+
_hooks: {
|
|
38
|
+
change: HooksList<ProfileBase<T>, T, T?>,
|
|
39
|
+
save: HooksList<ProfileBase<T>>,
|
|
40
|
+
closing: HooksList<ProfileBase<T>>,
|
|
41
|
+
closed: HooksList<ProfileBase<T>>,
|
|
42
|
+
lock_lost: HooksList<ProfileBase<T>>,
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
config: ResolvedConfig<T>,
|
|
46
|
+
load: (self: Store<T>, key: string, user_ids: { number }) -> Profile<T>,
|
|
47
|
+
wait_loaded: (self: Store<T>, key: string, user_ids: { number }) -> Profile<T>,
|
|
48
|
+
get_loaded: (self: Store<T>, key: string) -> Profile<T>?,
|
|
49
|
+
|
|
50
|
+
--- Returns the lockless profile for `key`, creating it if needed. Never
|
|
51
|
+
--- yields: a lockless profile does no storage work until `fetch`.
|
|
52
|
+
get_lockless: (self: Store<T>, key: string, user_ids: { number }) -> LocklessProfile<T>,
|
|
53
|
+
|
|
54
|
+
--- Read-only snapshot of `key` via `GetAsync`: the current data and lock.
|
|
55
|
+
--- Yields. The result is not tracked by the store, takes no lock and cannot
|
|
56
|
+
--- join a transaction.
|
|
57
|
+
peek: (self: Store<T>, key: string) -> PeekProfile<T>,
|
|
58
|
+
|
|
59
|
+
--- Atomically updates profiles of this store (locked or lockless). `transform`
|
|
60
|
+
--- gets their data in order and returns the new data in the same order, or
|
|
61
|
+
--- false to cancel. Returns false when cancelled, true when committed; throws
|
|
62
|
+
--- when aborted. `config` fills in what the store does not know (ttls, retry);
|
|
63
|
+
--- hook and scheduler default to the store's.
|
|
64
|
+
transaction: (
|
|
65
|
+
self: Store<T>,
|
|
66
|
+
profiles: Array<ProfileBase<T>>,
|
|
67
|
+
transform: (data: Array<T>) -> Array<T> | false,
|
|
68
|
+
config: types.TransactionConfig?
|
|
69
|
+
) -> boolean,
|
|
70
|
+
|
|
71
|
+
on_change: (self: Store<T>, callback: (profile: Profile<T>, new: T, old: T?) -> ()) -> (),
|
|
72
|
+
on_save: (self: Store<T>, callback: (profile: Profile<T>) -> ()) -> (),
|
|
73
|
+
on_closing: (self: Store<T>, callback: (profile: Profile<T>) -> ()) -> (),
|
|
74
|
+
on_closed: (self: Store<T>, callback: (profile: Profile<T>) -> ()) -> (),
|
|
75
|
+
on_lock_lost: (self: Store<T>, callback: (profile: Profile<T>) -> ()) -> (),
|
|
76
|
+
|
|
77
|
+
close: (self: Store<T>) -> (),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
local function resume_load_locks(self: Store, key: string, ok: boolean, result: any)
|
|
81
|
+
local loading_locks = self._loading_locks[key]
|
|
82
|
+
if not loading_locks then
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
self._loading_locks[key] = nil
|
|
86
|
+
|
|
87
|
+
for _, thread in loading_locks do
|
|
88
|
+
self.config._scheduler.spawn(thread, ok, result)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
local function insert_loaded_lock(self: Store, key: string)
|
|
93
|
+
local thread = coroutine.running()
|
|
94
|
+
|
|
95
|
+
local waiters = self._loading_locks[key]
|
|
96
|
+
if waiters == nil then
|
|
97
|
+
waiters = {}
|
|
98
|
+
self._loading_locks[key] = waiters
|
|
99
|
+
end
|
|
100
|
+
table.insert(waiters, thread)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
local function unregister_profile(self: Store, profile: Profile)
|
|
104
|
+
local key = profile.key
|
|
105
|
+
if self.profiles[key] == profile then
|
|
106
|
+
self.profiles[key] = nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
local function fire_hooks<T...>(self: Store, hooks: HooksList<T...>, ...: T...)
|
|
111
|
+
for _, hook in hooks.hooks do
|
|
112
|
+
local ok, err = utils.try(hook, ...)
|
|
113
|
+
if not ok then
|
|
114
|
+
self._hook.warn(`datastore: store hook {hooks.type} failed: {tostring(err)}`)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
--- Subscribes to every hook of `profile` and re-fires the matching store hook
|
|
120
|
+
--- with the profile as first argument, followed by the profile hook's arguments.
|
|
121
|
+
local function forward_hooks(self: Store, profile: ProfileBase<any>)
|
|
122
|
+
local hooks = self._hooks
|
|
123
|
+
|
|
124
|
+
profile:on_change(function(new, old)
|
|
125
|
+
fire_hooks(self, hooks.change, profile, new, old)
|
|
126
|
+
end)
|
|
127
|
+
profile:on_save(function()
|
|
128
|
+
fire_hooks(self, hooks.save, profile)
|
|
129
|
+
end)
|
|
130
|
+
profile:on_closing(function()
|
|
131
|
+
fire_hooks(self, hooks.closing, profile)
|
|
132
|
+
end)
|
|
133
|
+
profile:on_closed(function()
|
|
134
|
+
fire_hooks(self, hooks.closed, profile)
|
|
135
|
+
end)
|
|
136
|
+
profile:on_lock_lost(function()
|
|
137
|
+
fire_hooks(self, hooks.lock_lost, profile)
|
|
138
|
+
end)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
local store = {}
|
|
142
|
+
store.__index = store
|
|
143
|
+
|
|
144
|
+
function store.create<T>(config: Config<T>): Store<T>
|
|
145
|
+
assert(type(config) == "table", "datastore: config must be a table")
|
|
146
|
+
assert(type(config.name) == "string" and #config.name > 0, "datastore: config.name is required")
|
|
147
|
+
assert(config.template ~= nil, "datastore: config.template is required")
|
|
148
|
+
|
|
149
|
+
local lock_ttl = config.lock_ttl or 60
|
|
150
|
+
local hook = config._hook
|
|
151
|
+
local scheduler = config._scheduler
|
|
152
|
+
|
|
153
|
+
if not hook and game then
|
|
154
|
+
hook = roblox_hook.create()
|
|
155
|
+
end
|
|
156
|
+
if not scheduler and game and task then
|
|
157
|
+
scheduler = roblox_scheduler.create()
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
assert(hook, "for non roblox environments, you need to provide a datastore hook")
|
|
161
|
+
assert(scheduler, "for non roblox environments, you need to provide a task scheduler")
|
|
162
|
+
|
|
163
|
+
local resolved: ResolvedConfig<T> = {
|
|
164
|
+
name = config.name,
|
|
165
|
+
template = config.template,
|
|
166
|
+
|
|
167
|
+
_hook = hook,
|
|
168
|
+
_scheduler = scheduler,
|
|
169
|
+
|
|
170
|
+
migrations = config.migrations or {},
|
|
171
|
+
|
|
172
|
+
autosave_interval = config.autosave_interval or 30,
|
|
173
|
+
flush_interval = config.flush_interval or config.autosave_interval or 30,
|
|
174
|
+
lock_ttl = lock_ttl,
|
|
175
|
+
load_timeout = config.load_timeout or (lock_ttl + 10),
|
|
176
|
+
load_poll = config.load_poll or 1,
|
|
177
|
+
retry_attempts = config.retry_attempts or 5,
|
|
178
|
+
retry_base = config.retry_base or 1,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
local self = {} :: Store<T>
|
|
182
|
+
self.config = resolved
|
|
183
|
+
self._hook = resolved._hook
|
|
184
|
+
self._scheduler = resolved._scheduler
|
|
185
|
+
self._datastore = resolved._hook.get_datastore(resolved.name)
|
|
186
|
+
|
|
187
|
+
self.profiles = {}
|
|
188
|
+
self.lockless_profiles = {}
|
|
189
|
+
self.closed = false
|
|
190
|
+
|
|
191
|
+
self._loading = {}
|
|
192
|
+
self._loading_locks = {}
|
|
193
|
+
|
|
194
|
+
self._hooks = {
|
|
195
|
+
change = { type = "change", hooks = {} },
|
|
196
|
+
save = { type = "save", hooks = {} },
|
|
197
|
+
closing = { type = "closing", hooks = {} },
|
|
198
|
+
closed = { type = "closed", hooks = {} },
|
|
199
|
+
lock_lost = { type = "lock_lost", hooks = {} },
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return setmetatable(self, store) :: any
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
function store.load(self: Store, key: string, user_ids: { number }): Profile
|
|
206
|
+
if self.closed then
|
|
207
|
+
error "datastore: store is closed"
|
|
208
|
+
end
|
|
209
|
+
if self.profiles[key] ~= nil or self._loading[key] then
|
|
210
|
+
error(`datastore: "{key}" is already loaded in this store`)
|
|
211
|
+
end
|
|
212
|
+
self._loading[key] = true
|
|
213
|
+
|
|
214
|
+
local ok, profile = pcall(profile.load, {
|
|
215
|
+
key = key,
|
|
216
|
+
config = self.config,
|
|
217
|
+
user_ids = user_ids,
|
|
218
|
+
_datastore = self._datastore,
|
|
219
|
+
})
|
|
220
|
+
self._loading[key] = nil
|
|
221
|
+
|
|
222
|
+
if not ok then
|
|
223
|
+
resume_load_locks(self, key, false, profile)
|
|
224
|
+
error(profile, 0)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
profile:on_closed(function()
|
|
228
|
+
unregister_profile(self, profile)
|
|
229
|
+
end)
|
|
230
|
+
forward_hooks(self, profile)
|
|
231
|
+
|
|
232
|
+
self.profiles[key] = profile
|
|
233
|
+
resume_load_locks(self, key, ok, profile)
|
|
234
|
+
return profile
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
--- Returns the profile for `key`, waiting for it to load or loading it if nobody has yet.
|
|
238
|
+
function store.wait_loaded(self: Store, key: string, user_ids: { number }): Profile
|
|
239
|
+
if self.closed then
|
|
240
|
+
error "datastore: store is closed"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
local existing = self.profiles[key]
|
|
244
|
+
if existing ~= nil then
|
|
245
|
+
return existing
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
if not self._loading[key] then
|
|
249
|
+
return self:load(key, user_ids)
|
|
250
|
+
end
|
|
251
|
+
insert_loaded_lock(self, key)
|
|
252
|
+
|
|
253
|
+
local ok, result = coroutine.yield()
|
|
254
|
+
if not ok then
|
|
255
|
+
error(result, 0)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
return result
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
function store.get_loaded(self: Store, key: string): Profile?
|
|
262
|
+
return self.profiles[key]
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
function store.get_lockless(self: Store, key: string, user_ids: { number }): LocklessProfile
|
|
266
|
+
if self.closed then
|
|
267
|
+
error "datastore: store is closed"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
local existing = self.lockless_profiles[key]
|
|
271
|
+
if existing ~= nil then
|
|
272
|
+
return existing
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
local profile = lockless_profile.create {
|
|
276
|
+
key = key,
|
|
277
|
+
config = self.config,
|
|
278
|
+
user_ids = user_ids,
|
|
279
|
+
_datastore = self._datastore,
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
profile:on_closed(function()
|
|
283
|
+
if self.lockless_profiles[key] == profile then
|
|
284
|
+
self.lockless_profiles[key] = nil
|
|
285
|
+
end
|
|
286
|
+
end)
|
|
287
|
+
forward_hooks(self, profile)
|
|
288
|
+
|
|
289
|
+
self.lockless_profiles[key] = profile
|
|
290
|
+
return profile
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
--- One `get_async` on `key`, no write. Never registered in the store: `close`
|
|
294
|
+
--- does not know about it and hooks do not fire for it.
|
|
295
|
+
function store.peek(self: Store, key: string): PeekProfile
|
|
296
|
+
if self.closed then
|
|
297
|
+
error "datastore: store is closed"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
return peek_profile.create {
|
|
301
|
+
key = key,
|
|
302
|
+
_config = self.config,
|
|
303
|
+
_datastore = self._datastore,
|
|
304
|
+
}
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
function store.on_change(self: Store, callback: (profile: ProfileBase, new: any, old: any?) -> ())
|
|
308
|
+
table.insert(self._hooks.change.hooks, callback)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
function store.on_save(self: Store, callback: (profile: ProfileBase) -> ())
|
|
312
|
+
table.insert(self._hooks.save.hooks, callback)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
function store.transaction(
|
|
316
|
+
self: Store,
|
|
317
|
+
profiles: Array<ProfileBase>,
|
|
318
|
+
transform: (data: Array<any>) -> Array<any> | false,
|
|
319
|
+
config: types.TransactionConfig?
|
|
320
|
+
): boolean
|
|
321
|
+
if self.closed then
|
|
322
|
+
error "datastore: store is closed"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
local resolved = tx.resolve_config(config, {
|
|
326
|
+
_hook = self._hook,
|
|
327
|
+
_scheduler = self._scheduler,
|
|
328
|
+
retry_attempts = self.config.retry_attempts,
|
|
329
|
+
retry_base = self.config.retry_base,
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
return tx.run(resolved, profiles, function(ctx)
|
|
333
|
+
local datas = table.create(#profiles)
|
|
334
|
+
for index, participant in profiles do
|
|
335
|
+
datas[index] = ctx:get(participant)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
local result = transform(datas)
|
|
339
|
+
if result == false then
|
|
340
|
+
return false
|
|
341
|
+
end
|
|
342
|
+
local new_datas = result :: Array<any>
|
|
343
|
+
|
|
344
|
+
for index, participant in profiles do
|
|
345
|
+
local new = new_datas[index]
|
|
346
|
+
if new == nil then
|
|
347
|
+
error(`datastore: transaction transform returned no data for "{participant.key}" (index {index})`)
|
|
348
|
+
end
|
|
349
|
+
ctx:set(participant, new)
|
|
350
|
+
end
|
|
351
|
+
return true
|
|
352
|
+
end)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
--- Unloads every profile (locked and lockless) concurrently and waits for all
|
|
356
|
+
--- of them. Loads and transactions are rejected from the first line on.
|
|
357
|
+
function store.close(self: Store)
|
|
358
|
+
if self.closed then
|
|
359
|
+
return
|
|
360
|
+
end
|
|
361
|
+
self.closed = true
|
|
362
|
+
|
|
363
|
+
local scheduler = self._scheduler
|
|
364
|
+
local thread = coroutine.running()
|
|
365
|
+
local remaining = 0
|
|
366
|
+
local waiting = false
|
|
367
|
+
|
|
368
|
+
local function unload(profile: ProfileBase)
|
|
369
|
+
remaining += 1
|
|
370
|
+
scheduler.spawn(function()
|
|
371
|
+
local ok, err = utils.try(profile.unload, profile)
|
|
372
|
+
if not ok then
|
|
373
|
+
self._hook.warn(`datastore: unload failed for "{profile.key}": {tostring(err)}`)
|
|
374
|
+
end
|
|
375
|
+
remaining -= 1
|
|
376
|
+
if remaining == 0 and waiting then
|
|
377
|
+
scheduler.spawn(thread)
|
|
378
|
+
end
|
|
379
|
+
end)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
for _, profile in self.profiles do
|
|
383
|
+
unload(profile)
|
|
384
|
+
end
|
|
385
|
+
for _, profile in self.lockless_profiles do
|
|
386
|
+
unload(profile)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
if remaining > 0 then
|
|
390
|
+
waiting = true
|
|
391
|
+
coroutine.yield()
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
function store.on_closing(self: Store, callback: (profile: ProfileBase) -> ())
|
|
396
|
+
table.insert(self._hooks.closing.hooks, callback)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
function store.on_closed(self: Store, callback: (profile: ProfileBase) -> ())
|
|
400
|
+
table.insert(self._hooks.closed.hooks, callback)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
function store.on_lock_lost(self: Store, callback: (profile: ProfileBase) -> ())
|
|
404
|
+
table.insert(self._hooks.lock_lost.hooks, callback)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
create = store.create,
|
|
409
|
+
}
|