@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
|
@@ -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
|