@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.
@@ -0,0 +1,68 @@
1
+ --!nocheck
2
+ --!nolint UnknownGlobal
3
+
4
+ local types = require "../types"
5
+
6
+ --[[
7
+ DataStoreService hook. Only usable inside Roblox.
8
+ ]]
9
+
10
+ local roblox_hook = {}
11
+
12
+ function roblox_hook.create(): types.Hook
13
+ local DataStoreService = game:GetService "DataStoreService"
14
+ local handles: { [string]: types.DatastoreHandle } = {}
15
+
16
+ local self = {} :: types.Hook
17
+
18
+ self.warn = warn
19
+
20
+ function self.job_id()
21
+ return game.JobId :: string
22
+ end
23
+
24
+ function self.get_datastore(name: string): types.DatastoreHandle
25
+ local existing = handles[name]
26
+ if existing then
27
+ return existing
28
+ end
29
+ local datastore = DataStoreService:GetDataStore(name)
30
+ local handle = {} :: types.DatastoreHandle
31
+ handle.name = name
32
+
33
+ function handle.get_async(key: string): any?
34
+ return (datastore:GetAsync(key))
35
+ end
36
+
37
+ function handle.set_async(key: string, value: any, user_ids: { number }?)
38
+ datastore:SetAsync(key, value, user_ids)
39
+ end
40
+
41
+ function handle.update_async(key: string, transform: (old: any?) -> any?, user_ids: { number }?): any?
42
+ -- Capture the result inside the callback so semantics do not depend on
43
+ -- what UpdateAsync returns when the callback cancels.
44
+ local result: any = nil
45
+ datastore:UpdateAsync(key, function(old: any?)
46
+ local new = transform(old)
47
+ if new == nil then
48
+ result = old
49
+ return nil
50
+ end
51
+ result = new
52
+ return new, user_ids
53
+ end)
54
+ return result
55
+ end
56
+
57
+ function handle.remove_async(key: string)
58
+ datastore:RemoveAsync(key)
59
+ end
60
+
61
+ handles[name] = handle
62
+ return handle
63
+ end
64
+
65
+ return self
66
+ end
67
+
68
+ return roblox_hook
package/src/init.luau ADDED
@@ -0,0 +1,58 @@
1
+ local store = require "@self/store"
2
+ local tx = require "@self/tx/handle"
3
+ local types = require "@self/types"
4
+
5
+ local memory_hook = require "@self/hooks/memory"
6
+ local roblox_hook = require "@self/hooks/roblox"
7
+
8
+ local virtual_scheduler = require "@self/schedulers/virtual"
9
+ local roblox_scheduler = require "@self/schedulers/roblox"
10
+
11
+ type Array<T> = { T }
12
+
13
+ export type Store<T = any> = store.Store<T>
14
+ export type Config<T = any> = types.Config<T>
15
+ export type TransactionConfig = types.TransactionConfig
16
+ export type TxContext = types.TxContext
17
+ export type Profile<T = any> = types.Profile<T>
18
+ export type LocklessProfile<T = any> = types.LocklessProfile<T>
19
+ export type ProfileBase<T = any> = types.ProfileBase<T>
20
+ export type PeekProfile<T = any> = types.PeekProfile<T>
21
+
22
+ local dataforge = {}
23
+
24
+ function dataforge.create_store<T>(config: Config<T>): Store<T>
25
+ return store.create(config)
26
+ end
27
+
28
+ --- Atomically updates any set of profiles, across stores and mixing locked and
29
+ --- lockless ones.
30
+ function dataforge.transaction(
31
+ profiles: Array<ProfileBase>,
32
+ process: (ctx: TxContext) -> boolean?,
33
+ config: TransactionConfig?
34
+ ): boolean
35
+ local first = profiles[1]
36
+ local defaults: TransactionConfig? = if first
37
+ then {
38
+ _hook = first.config._hook,
39
+ _scheduler = first.config._scheduler,
40
+ retry_attempts = first.config.retry_attempts,
41
+ retry_base = first.config.retry_base,
42
+ }
43
+ else nil
44
+
45
+ return tx.run(tx.resolve_config(config, defaults), profiles, process)
46
+ end
47
+
48
+ dataforge.hooks = {
49
+ memory = memory_hook,
50
+ roblox = roblox_hook,
51
+ }
52
+
53
+ dataforge.schedulers = {
54
+ virtual = virtual_scheduler,
55
+ roblox = roblox_scheduler,
56
+ }
57
+
58
+ return dataforge
package/src/mutex.luau ADDED
@@ -0,0 +1,107 @@
1
+ --!strict
2
+
3
+ local types = require "./types"
4
+
5
+ --[[
6
+ FIFO mutex for coroutines.
7
+ ]]
8
+
9
+ export type Mutex = types.Mutex
10
+
11
+ local mutex = {}
12
+ mutex.__index = mutex
13
+
14
+ function mutex.create(scheduler: types.Scheduler): Mutex
15
+ local self = {} :: Mutex
16
+ self._locked = false
17
+ self._closed = nil
18
+ self._waiters = {}
19
+ self._settlers = {}
20
+ self.scheduler = scheduler
21
+
22
+ return setmetatable(self, mutex) :: any
23
+ end
24
+
25
+ function mutex.lock(self: Mutex)
26
+ if self._closed then
27
+ error(self._closed, 2)
28
+ end
29
+ if not self._locked then
30
+ self._locked = true
31
+ return
32
+ end
33
+ table.insert(self._waiters, coroutine.running())
34
+ -- resumed with a reason only when the mutex was closed while waiting
35
+ local rejection = coroutine.yield()
36
+ if rejection ~= nil then
37
+ error(rejection, 2)
38
+ end
39
+ end
40
+
41
+ local function resume_settlers(self: Mutex)
42
+ local settlers = self._settlers
43
+ self._settlers = {}
44
+ for _, thread in settlers do
45
+ self.scheduler.spawn(thread)
46
+ end
47
+ end
48
+
49
+ function mutex.unlock(self: Mutex)
50
+ if not self._locked then
51
+ error "mutex: unlock called while not locked"
52
+ end
53
+ local next_waiter = table.remove(self._waiters, 1)
54
+ if next_waiter == nil then
55
+ self._locked = false
56
+ resume_settlers(self)
57
+ return
58
+ end
59
+ self.scheduler.spawn(next_waiter)
60
+ end
61
+
62
+ --- Rejects every current and future lock with `reason`. Idempotent: a second
63
+ --- close keeps the first reason.
64
+ function mutex.close(self: Mutex, reason: string)
65
+ if self._closed then
66
+ return
67
+ end
68
+ self._closed = reason
69
+
70
+ local waiters = self._waiters
71
+ self._waiters = {}
72
+ for _, thread in waiters do
73
+ self.scheduler.spawn(thread, reason)
74
+ end
75
+ end
76
+
77
+ function mutex.is_locked(self: Mutex): boolean
78
+ return self._locked
79
+ end
80
+
81
+ function mutex.is_closed(self: Mutex): boolean
82
+ return self._closed ~= nil
83
+ end
84
+
85
+ function mutex.with(self: Mutex, fn: () -> ...any): any
86
+ self:lock()
87
+ local ok, result = pcall(fn)
88
+ self:unlock()
89
+ if not ok then
90
+ error(result, 0)
91
+ end
92
+ return result
93
+ end
94
+
95
+ --- Yields until the mutex is unlocked with no one waiting. Works after `close`.
96
+ function mutex.wait_settled(self: Mutex)
97
+ if not self._locked then
98
+ return
99
+ end
100
+
101
+ table.insert(self._settlers, coroutine.running())
102
+ coroutine.yield()
103
+ end
104
+
105
+ return {
106
+ create = mutex.create,
107
+ }
@@ -0,0 +1,102 @@
1
+ local types = require "../types"
2
+
3
+ type Array<T> = { T }
4
+
5
+ export type ProfileBase<T = any> = types.ProfileBase<T>
6
+ export type LoadOptions = types.LoadOptions
7
+ export type MigrationsResult = types.MigrationsResult
8
+
9
+ local common = {}
10
+
11
+ --- Throws right away when the profile is closed, Its not necessary to add this, as it will get rejected either way,
12
+ --- by the mutex, but its used to throw instantly.
13
+ function common.assert_open(self: ProfileBase)
14
+ if not self.open then
15
+ error(`datastore: profile "{self.key}" is closed`, 3)
16
+ end
17
+ end
18
+
19
+ --- Applies the declared migrations not yet in `applied` (in order). Pure, so it
20
+ --- is safe inside an update transform. Fails when the record's applied list does
21
+ --- not prefix-match the declared list.
22
+ function common.apply_migrations(data: any, applied: { string }, migrations: Array<types.Migration>): MigrationsResult
23
+ local applied_count = #applied
24
+
25
+ if applied_count > #migrations then
26
+ return {
27
+ applied = false,
28
+ success = false,
29
+ err = `record has {applied_count} applied migrations but only {#migrations} are declared`,
30
+ new_migrations = applied,
31
+ }
32
+ end
33
+
34
+ for index = 1, applied_count do
35
+ local expected = migrations[index].name
36
+ local actual = applied[index]
37
+ if actual ~= expected then
38
+ return {
39
+ applied = false,
40
+ success = false,
41
+ err = `migration mismatch at index {index}: record has "{actual}", declared is "{expected}"`,
42
+ new_migrations = applied,
43
+ }
44
+ end
45
+ end
46
+
47
+ if applied_count == #migrations then
48
+ return { applied = false, success = true, result = data, new_migrations = applied }
49
+ end
50
+
51
+ local new_migrations = table.clone(applied)
52
+ for index = applied_count + 1, #migrations do
53
+ local migration = migrations[index]
54
+ data = migration.apply(data)
55
+ table.insert(new_migrations, migration.name)
56
+ end
57
+
58
+ return { applied = true, success = true, result = data, new_migrations = new_migrations }
59
+ end
60
+
61
+ --- Names of every declared migration; what a fresh template counts as having applied.
62
+ function common.all_migration_names(migrations: Array<types.Migration>): { string }
63
+ local names = table.create(#migrations) :: { string }
64
+ for index, migration in migrations do
65
+ names[index] = migration.name
66
+ end
67
+ return names
68
+ end
69
+
70
+ function common.is_lock_owned(record: types.DataRecord?, job_id: string)
71
+ if not record then
72
+ return false
73
+ end
74
+
75
+ local lock = record.lock
76
+ if not lock then
77
+ return false
78
+ end
79
+
80
+ return lock.id == job_id
81
+ end
82
+
83
+ function common.is_locked_for(record: types.DataRecord?, job_id: string, now: number)
84
+ if not record then
85
+ return false
86
+ end
87
+ local lock = record.lock
88
+ if not lock then
89
+ return false
90
+ end
91
+
92
+ return lock.id ~= job_id and lock.expires > now
93
+ end
94
+
95
+ function common.has_lock(record: types.DataRecord?)
96
+ if not record then
97
+ return false
98
+ end
99
+ return not not record.lock
100
+ end
101
+
102
+ return common