@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/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
+ }