@rbxts/dataforge 0.1.0 → 0.4.1

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,211 @@
1
+ --!strict
2
+
3
+ local types = require "./types"
4
+
5
+ --[[
6
+ Error tables. Every runtime failure the library reports is one of these:
7
+ a plain table with a `type`, a `message` and typed extra data, sharing one
8
+ metatable so `tostring(err)` yields the message and `errors.is` can tell
9
+ them apart from raw (string) errors thrown by user callbacks.
10
+ ]]
11
+
12
+ type Array<T> = { T }
13
+ type Lock = types.Lock
14
+
15
+ local error_mt = {
16
+ __tostring = function(self: any): string
17
+ return self.message
18
+ end,
19
+ }
20
+
21
+ local function make<T>(fields: T): T
22
+ return setmetatable(fields :: any, error_mt) :: any
23
+ end
24
+
25
+ local errors = {}
26
+
27
+ function errors.is(value: any): boolean
28
+ return type(value) == "table" and getmetatable(value) == error_mt
29
+ end
30
+
31
+ function errors.roblox(op: "get_async" | "update_async", store: string, key: string, cause: any): types.RobloxError
32
+ return make {
33
+ type = "roblox" :: "roblox",
34
+ message = `datastore: {op} on "{store}/{key}" failed after retries: {tostring(cause)}`,
35
+ op = op,
36
+ store = store,
37
+ key = key,
38
+ cause = cause,
39
+ }
40
+ end
41
+
42
+ function errors.timeout(key: string, lock: Lock?): types.TimeoutError
43
+ return make {
44
+ type = "timeout" :: "timeout",
45
+ message = `datastore: timed out loading "{key}" (session locked by another server)`,
46
+ key = key,
47
+ lock = lock,
48
+ }
49
+ end
50
+
51
+ function errors.lock_lost(profile: any, lock: Lock?): types.LockLostError
52
+ return make {
53
+ type = "lock_lost" :: "lock_lost",
54
+ message = `datastore: profile "{profile.key}" lost its session lock`,
55
+ profile = profile,
56
+ lock = lock,
57
+ }
58
+ end
59
+
60
+ function errors.released(profile: any): types.ReleasedError
61
+ return make {
62
+ type = "released" :: "released",
63
+ message = `datastore: profile "{profile.key}" is released (does not hold its session lock)`,
64
+ profile = profile,
65
+ }
66
+ end
67
+
68
+ function errors.lockless_locked(profile: any, lock: Lock): types.LocklessLockedError
69
+ return make {
70
+ type = "lockless_locked" :: "lockless_locked",
71
+ message = `datastore: lockless profile "{profile.key}" is session locked by another server`,
72
+ profile = profile,
73
+ lock = lock,
74
+ }
75
+ end
76
+
77
+ function errors.tx_lock_lost(profile: any, expected: Lock, lock: Lock?): types.TxLockLostError
78
+ return make {
79
+ type = "tx_lock_lost" :: "tx_lock_lost",
80
+ message = `datastore: transaction lock on "{profile.key}" was lost`,
81
+ profile = profile,
82
+ expected = expected,
83
+ lock = lock,
84
+ }
85
+ end
86
+
87
+ --- `profile` is the lockless / locked profile the call ran on, when there is
88
+ --- one (nil for `load` and `peek`).
89
+ function errors.outdated(
90
+ key: string,
91
+ record_migrations: Array<string>,
92
+ declared_migrations: Array<string>,
93
+ profile: any?
94
+ ): types.OutdatedError
95
+ return make {
96
+ type = "outdated" :: "outdated",
97
+ message = `datastore: "{key}" was written by a newer server (record has {#record_migrations} applied migrations, this server declares {#declared_migrations})`,
98
+ key = key,
99
+ profile = profile,
100
+ record_migrations = record_migrations,
101
+ declared_migrations = declared_migrations,
102
+ }
103
+ end
104
+
105
+ function errors.predicate_failed(
106
+ key: string,
107
+ op: "load" | "save" | "fetch" | "peek",
108
+ data: any,
109
+ profile: any?
110
+ ): types.PredicateFailedError
111
+ return make {
112
+ type = "predicate_failed" :: "predicate_failed",
113
+ message = `datastore: the store predicate rejected the stored data of "{key}" (on {op})`,
114
+ key = key,
115
+ op = op,
116
+ data = data,
117
+ profile = profile,
118
+ }
119
+ end
120
+
121
+ function errors.not_fetched(profile: any): types.NotFetchedError
122
+ return make {
123
+ type = "not_fetched" :: "not_fetched",
124
+ message = `datastore: lockless profile "{profile.key}" has not been fetched`,
125
+ profile = profile,
126
+ }
127
+ end
128
+
129
+ function errors.profile_closed(profile: any, reason: "unloaded" | "lock_lost"): types.ProfileClosedError
130
+ local message = if reason == "lock_lost"
131
+ then `datastore: profile "{profile.key}" is closed (lock lost)`
132
+ else `datastore: profile "{profile.key}" is closed`
133
+ return make {
134
+ type = "profile_closed" :: "profile_closed",
135
+ message = message,
136
+ profile = profile,
137
+ reason = reason,
138
+ }
139
+ end
140
+
141
+ --- The error a closed profile rejects calls with: the one its mutex was closed
142
+ --- with (carries the right reason), or a plain "unloaded" while `unload` is
143
+ --- still waiting for the mutex.
144
+ function errors.closed(profile: any): types.ProfileClosedError
145
+ local closed = profile._mutex._closed
146
+ if closed ~= nil then
147
+ return closed
148
+ end
149
+ return errors.profile_closed(profile, "unloaded")
150
+ end
151
+
152
+ export type MigrationMismatchDetails = {
153
+ index: number,
154
+ expected: string,
155
+ actual: string,
156
+ applied: Array<string>,
157
+ declared: Array<string>,
158
+ }
159
+
160
+ function errors.migration_mismatch(key: string, details: MigrationMismatchDetails): types.MigrationMismatchError
161
+ local reason =
162
+ `migration mismatch at index {details.index}: record has "{details.actual}", declared is "{details.expected}"`
163
+ return make {
164
+ type = "migration_mismatch" :: "migration_mismatch",
165
+ message = `datastore: failed to apply migrations for "{key}": {reason}`,
166
+ key = key,
167
+ index = details.index,
168
+ expected = details.expected,
169
+ actual = details.actual,
170
+ applied = details.applied,
171
+ declared = details.declared,
172
+ }
173
+ end
174
+
175
+ function errors.tx_aborted(id: string, cause: types.Error?): types.TxAbortedError
176
+ local reason = if cause then (cause :: any).message else "marker resolved as aborted by another server"
177
+ return make {
178
+ type = "tx_aborted" :: "tx_aborted",
179
+ message = `datastore: transaction {id} aborted: {reason}`,
180
+ id = id,
181
+ cause = cause,
182
+ }
183
+ end
184
+
185
+ function errors.tx_marker_invalid(id: string, value: any): types.TxMarkerInvalidError
186
+ return make {
187
+ type = "tx_marker_invalid" :: "tx_marker_invalid",
188
+ message = `datastore: unexpected tx marker value for {id}: {tostring(value)}`,
189
+ id = id,
190
+ value = value,
191
+ }
192
+ end
193
+
194
+ function errors.store_closed(store: any): types.StoreClosedError
195
+ return make {
196
+ type = "store_closed" :: "store_closed",
197
+ message = `datastore: store "{store.config.name}" is closed`,
198
+ store = store,
199
+ }
200
+ end
201
+
202
+ function errors.already_loaded(store: any, key: string): types.AlreadyLoadedError
203
+ return make {
204
+ type = "already_loaded" :: "already_loaded",
205
+ message = `datastore: "{key}" is already loaded in this store`,
206
+ store = store,
207
+ key = key,
208
+ }
209
+ end
210
+
211
+ return errors
@@ -42,17 +42,6 @@ type Failure = {
42
42
  message: string,
43
43
  }
44
44
 
45
- local HEX = "0123456789abcdef"
46
-
47
- function random_jobid(): string
48
- local chars = table.create(16)
49
- for index = 1, 16 do
50
- local nibble = math.random(1, 16)
51
- chars[index] = string.sub(HEX, nibble, nibble)
52
- end
53
- return table.concat(chars)
54
- end
55
-
56
45
  local memory = {}
57
46
 
58
47
  function memory.create(scheduler: types.Scheduler): MemoryHook
@@ -113,7 +102,7 @@ function memory.create(scheduler: types.Scheduler): MemoryHook
113
102
  end
114
103
 
115
104
  -- one id per hook instance: locks are owned by job id, so it must be stable
116
- local job_id = random_jobid()
105
+ local job_id = utils.uuid()
117
106
  function self.job_id()
118
107
  return job_id
119
108
  end
@@ -2,6 +2,7 @@
2
2
  --!nolint UnknownGlobal
3
3
 
4
4
  local types = require "../types"
5
+ local utils = require "../utils"
5
6
 
6
7
  --[[
7
8
  DataStoreService hook. Only usable inside Roblox.
@@ -17,8 +18,16 @@ function roblox_hook.create(): types.Hook
17
18
 
18
19
  self.warn = warn
19
20
 
21
+ -- `game.JobId` is an empty string in Studio, so every Studio session would
22
+ -- share the same lock owner and transaction id prefix. Generate a random
23
+ -- one once per hook instead; locks are owned by job id, so it must be stable.
24
+ local job_id: string = game.JobId
25
+ if job_id == "" then
26
+ job_id = `studio-{utils.uuid()}`
27
+ end
28
+
20
29
  function self.job_id()
21
- return game.JobId :: string
30
+ return job_id
22
31
  end
23
32
 
24
33
  function self.get_datastore(name: string): types.DatastoreHandle
package/src/init.luau CHANGED
@@ -1,3 +1,5 @@
1
+ local errors = require "@self/errors"
2
+ local result = require "@self/result"
1
3
  local store = require "@self/store"
2
4
  local tx = require "@self/tx/handle"
3
5
  local types = require "@self/types"
@@ -18,6 +20,10 @@ export type Profile<T = any> = types.Profile<T>
18
20
  export type LocklessProfile<T = any> = types.LocklessProfile<T>
19
21
  export type ProfileBase<T = any> = types.ProfileBase<T>
20
22
  export type PeekProfile<T = any> = types.PeekProfile<T>
23
+ export type Result<T> = types.Result<T>
24
+ export type Error = types.Error
25
+ export type Migration = types.Migration
26
+ export type DataDispatcher<T = any> = types.DataDispatcher<T>
21
27
 
22
28
  local dataforge = {}
23
29
 
@@ -26,12 +32,14 @@ function dataforge.create_store<T>(config: Config<T>): Store<T>
26
32
  end
27
33
 
28
34
  --- Atomically updates any set of profiles, across stores and mixing locked and
29
- --- lockless ones.
35
+ --- lockless ones. Ok(false) when `process` returns false, Ok(true) when
36
+ --- committed. Fails with `tx_aborted` (see its `cause`), `profile_closed`,
37
+ --- `released`, or what flushing a lockless participant raised.
30
38
  function dataforge.transaction(
31
39
  profiles: Array<ProfileBase>,
32
40
  process: (ctx: TxContext) -> boolean?,
33
41
  config: TransactionConfig?
34
- ): boolean
42
+ ): Result<boolean>
35
43
  local first = profiles[1]
36
44
  local defaults: TransactionConfig? = if first
37
45
  then {
@@ -45,6 +53,16 @@ function dataforge.transaction(
45
53
  return tx.run(tx.resolve_config(config, defaults), profiles, process)
46
54
  end
47
55
 
56
+ --- Whether `value` is a dataforge `Error` table (what a failed Result carries
57
+ --- and what the library throws internally). Lets a wrapper around library calls
58
+ --- tell a library error from any other thrown value.
59
+ dataforge.is_error = errors.is
60
+
61
+ --- The `Result` helpers (`ok`, `err`, `is`, `of`, `capture`), so a game can
62
+ --- build library-shaped results in its own wrappers. `result.err` only accepts
63
+ --- an `Error`, so a malformed result cannot be fabricated with it.
64
+ dataforge.result = result
65
+
48
66
  dataforge.hooks = {
49
67
  memory = memory_hook,
50
68
  roblox = roblox_hook,
package/src/mutex.luau CHANGED
@@ -24,7 +24,7 @@ end
24
24
 
25
25
  function mutex.lock(self: Mutex)
26
26
  if self._closed then
27
- error(self._closed, 2)
27
+ error(self._closed, 0)
28
28
  end
29
29
  if not self._locked then
30
30
  self._locked = true
@@ -34,7 +34,7 @@ function mutex.lock(self: Mutex)
34
34
  -- resumed with a reason only when the mutex was closed while waiting
35
35
  local rejection = coroutine.yield()
36
36
  if rejection ~= nil then
37
- error(rejection, 2)
37
+ error(rejection, 0)
38
38
  end
39
39
  end
40
40
 
@@ -59,9 +59,9 @@ function mutex.unlock(self: Mutex)
59
59
  self.scheduler.spawn(next_waiter)
60
60
  end
61
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)
62
+ --- Rejects every current and future lock with `reason` (an `Error` table,
63
+ --- thrown as is). Idempotent: a second close keeps the first reason.
64
+ function mutex.close(self: Mutex, reason: any)
65
65
  if self._closed then
66
66
  return
67
67
  end
@@ -1,3 +1,4 @@
1
+ local errors = require "../errors"
1
2
  local types = require "../types"
2
3
 
3
4
  type Array<T> = { T }
@@ -8,25 +9,33 @@ export type MigrationsResult = types.MigrationsResult
8
9
 
9
10
  local common = {}
10
11
 
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.
12
+ --- Throws a `profile_closed` error right away when the profile is closed. Not
13
+ --- strictly needed (the closed mutex rejects the call anyway) but it fails
14
+ --- before queueing on the mutex.
13
15
  function common.assert_open(self: ProfileBase)
14
16
  if not self.open then
15
- error(`datastore: profile "{self.key}" is closed`, 3)
17
+ error(errors.closed(self), 0)
16
18
  end
17
19
  end
18
20
 
19
21
  --- 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
22
+ --- is safe inside an update transform. Fails with `outdated` in `err` when the
23
+ --- record lists more migrations than declared (a newer server wrote it), or
24
+ --- `migration_mismatch` when a name in the applied prefix differs. An error
25
+ --- thrown by a migration's `apply` propagates as is.
26
+ function common.apply_migrations(
27
+ key: string,
28
+ data: any,
29
+ applied: { string },
30
+ migrations: Array<types.Migration>
31
+ ): MigrationsResult
23
32
  local applied_count = #applied
24
33
 
25
34
  if applied_count > #migrations then
26
35
  return {
27
36
  applied = false,
28
37
  success = false,
29
- err = `record has {applied_count} applied migrations but only {#migrations} are declared`,
38
+ err = errors.outdated(key, applied, common.all_migration_names(migrations)),
30
39
  new_migrations = applied,
31
40
  }
32
41
  end
@@ -38,7 +47,13 @@ function common.apply_migrations(data: any, applied: { string }, migrations: Arr
38
47
  return {
39
48
  applied = false,
40
49
  success = false,
41
- err = `migration mismatch at index {index}: record has "{actual}", declared is "{expected}"`,
50
+ err = errors.migration_mismatch(key, {
51
+ index = index,
52
+ expected = expected,
53
+ actual = actual,
54
+ applied = applied,
55
+ declared = common.all_migration_names(migrations),
56
+ }),
42
57
  new_migrations = applied,
43
58
  }
44
59
  end
@@ -67,7 +82,39 @@ function common.all_migration_names(migrations: Array<types.Migration>): { strin
67
82
  return names
68
83
  end
69
84
 
70
- function common.is_lock_owned(record: types.DataRecord?, job_id: string)
85
+ --- Whether the record lists more migrations than this server declares. Pure.
86
+ function common.is_outdated(record: types.DataRecord?, config: types.ResolvedConfig): boolean
87
+ if not record or not record.migrations then
88
+ return false
89
+ end
90
+ return #record.migrations > #config.migrations
91
+ end
92
+
93
+ function common.run_predicate(config: types.ResolvedConfig, key: string, data: any): (boolean, any?)
94
+ local predicate = config.predicate
95
+ if predicate == nil then
96
+ return true, nil
97
+ end
98
+ local ok, passed = pcall(predicate, data, key)
99
+ if not ok then
100
+ return false, passed
101
+ end
102
+ return not not passed, nil
103
+ end
104
+
105
+ --- Rethrows a failed `apply_migrations` with the profile attached to an
106
+ --- `outdated` error (a `migration_mismatch` has no profile field).
107
+ function common.raise_migrations(profile: ProfileBase, migrated: MigrationsResult)
108
+ local err: any = migrated.err
109
+ if err.type == "outdated" then
110
+ err.profile = profile
111
+ end
112
+ error(err, 0)
113
+ end
114
+
115
+ --- Whether `lock_id` (the id of one acquisition) is the lock currently on
116
+ --- the record, so this acquisition may write it.
117
+ function common.is_lock_owned(record: types.DataRecord?, lock_id: string)
71
118
  if not record then
72
119
  return false
73
120
  end
@@ -77,10 +124,12 @@ function common.is_lock_owned(record: types.DataRecord?, job_id: string)
77
124
  return false
78
125
  end
79
126
 
80
- return lock.id == job_id
127
+ return lock.id == lock_id
81
128
  end
82
129
 
83
- function common.is_locked_for(record: types.DataRecord?, job_id: string, now: number)
130
+ --- Whether a live lock of another acquisition holds the record, in which case
131
+ --- `lock_id` must wait it out or steal it once it expires.
132
+ function common.is_locked_for(record: types.DataRecord?, lock_id: string, now: number)
84
133
  if not record then
85
134
  return false
86
135
  end
@@ -89,7 +138,7 @@ function common.is_locked_for(record: types.DataRecord?, job_id: string, now: nu
89
138
  return false
90
139
  end
91
140
 
92
- return lock.id ~= job_id and lock.expires > now
141
+ return lock.id ~= lock_id and lock.expires > now
93
142
  end
94
143
 
95
144
  function common.has_lock(record: types.DataRecord?)