@rbxts/dataforge 0.1.0 → 0.2.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/index.d.ts CHANGED
@@ -197,6 +197,172 @@ declare namespace dataforge {
197
197
 
198
198
  type TxOutcome = "committed" | "aborted";
199
199
 
200
+ /*
201
+ Errors and results
202
+ */
203
+
204
+ interface ErrorBase {
205
+ readonly type: string;
206
+ /** Human readable; `tostring(err)` returns it. */
207
+ readonly message: string;
208
+ }
209
+
210
+ /** Storage kept failing after `retry_attempts`. */
211
+ interface RobloxError extends ErrorBase {
212
+ readonly type: "roblox";
213
+ readonly op: "get_async" | "update_async";
214
+ readonly store: string;
215
+ readonly key: string;
216
+ /** The last raw error the storage hook threw. */
217
+ readonly cause: unknown;
218
+ }
219
+
220
+ /** `load` / `readquire` gave up after `load_timeout`. */
221
+ interface TimeoutError extends ErrorBase {
222
+ readonly type: "timeout";
223
+ readonly key: string;
224
+ /** The last lock seen on the record (its holder). */
225
+ readonly lock: Lock | undefined;
226
+ }
227
+
228
+ /** A write found the session lock is not ours; the profile is closed. */
229
+ interface LockLostError extends ErrorBase {
230
+ readonly type: "lock_lost";
231
+ readonly profile: Profile<any>;
232
+ /** The lock now on the record, `undefined` when it was wiped. */
233
+ readonly lock: Lock | undefined;
234
+ }
235
+
236
+ /** The profile was released and not readquired. */
237
+ interface ReleasedError extends ErrorBase {
238
+ readonly type: "released";
239
+ readonly profile: Profile<any>;
240
+ }
241
+
242
+ /** Another server holds a live session lock on a lockless key; the profile is closed. */
243
+ interface LocklessLockedError extends ErrorBase {
244
+ readonly type: "lockless_locked";
245
+ readonly profile: LocklessProfile<any>;
246
+ readonly lock: Lock;
247
+ }
248
+
249
+ /** The transaction lock a lockless participant held is gone or replaced; the profile is closed. */
250
+ interface TxLockLostError extends ErrorBase {
251
+ readonly type: "tx_lock_lost";
252
+ readonly profile: LocklessProfile<any>;
253
+ /** The transaction lock this profile held. */
254
+ readonly expected: Lock;
255
+ /** The lock now on the record, `undefined` when none. */
256
+ readonly lock: Lock | undefined;
257
+ }
258
+
259
+ /**
260
+ * The record was written by a newer server (it lists more migrations than
261
+ * this store declares). Nothing is written and no lock is taken.
262
+ */
263
+ interface OutdatedError extends ErrorBase {
264
+ readonly type: "outdated";
265
+ readonly key: string;
266
+ /** The profile the call ran on; `undefined` for `store.load` and `store.peek`. */
267
+ readonly profile: AnyProfile<any> | undefined;
268
+ readonly record_migrations: string[];
269
+ readonly declared_migrations: string[];
270
+ }
271
+
272
+ /** Lockless `get_data` before any fetch / flush. Thrown, never returned. */
273
+ interface NotFetchedError extends ErrorBase {
274
+ readonly type: "not_fetched";
275
+ readonly profile: LocklessProfile<any>;
276
+ }
277
+
278
+ /** The profile is closed (or closed while the call waited for it). */
279
+ interface ProfileClosedError extends ErrorBase {
280
+ readonly type: "profile_closed";
281
+ readonly profile: AnyProfile<any>;
282
+ readonly reason: "unloaded" | "lock_lost";
283
+ }
284
+
285
+ /**
286
+ * A name in the record's applied migrations differs from the declared one
287
+ * at `index`. A record with more migrations than declared is `outdated`.
288
+ */
289
+ interface MigrationMismatchError extends ErrorBase {
290
+ readonly type: "migration_mismatch";
291
+ readonly key: string;
292
+ readonly index: number;
293
+ readonly expected: string;
294
+ readonly actual: string;
295
+ /** Names the record has applied / this store declares. */
296
+ readonly applied: string[];
297
+ readonly declared: string[];
298
+ }
299
+
300
+ /** The transaction aborted. */
301
+ interface TxAbortedError extends ErrorBase {
302
+ readonly type: "tx_aborted";
303
+ readonly id: string;
304
+ /**
305
+ * The error of the participant that failed phase 1; `undefined` when
306
+ * the marker was already resolved as aborted by another server.
307
+ */
308
+ readonly cause: DataforgeError | undefined;
309
+ }
310
+
311
+ /** The marker datastore holds something other than "committed" / "aborted". */
312
+ interface TxMarkerInvalidError extends ErrorBase {
313
+ readonly type: "tx_marker_invalid";
314
+ readonly id: string;
315
+ readonly value: unknown;
316
+ }
317
+
318
+ interface StoreClosedError extends ErrorBase {
319
+ readonly type: "store_closed";
320
+ readonly store: Store<any>;
321
+ }
322
+
323
+ interface AlreadyLoadedError extends ErrorBase {
324
+ readonly type: "already_loaded";
325
+ readonly store: Store<any>;
326
+ readonly key: string;
327
+ }
328
+
329
+ /** Every runtime failure the library reports. Narrow on `type`. */
330
+ type DataforgeError =
331
+ | RobloxError
332
+ | TimeoutError
333
+ | LockLostError
334
+ | ReleasedError
335
+ | LocklessLockedError
336
+ | TxLockLostError
337
+ | OutdatedError
338
+ | NotFetchedError
339
+ | ProfileClosedError
340
+ | MigrationMismatchError
341
+ | TxAbortedError
342
+ | TxMarkerInvalidError
343
+ | StoreClosedError
344
+ | AlreadyLoadedError;
345
+
346
+ interface Ok<T> {
347
+ readonly success: true;
348
+ readonly value: T;
349
+ /** Returns `value`. */
350
+ unwrap(): T;
351
+ }
352
+
353
+ interface Err {
354
+ readonly success: false;
355
+ readonly error: DataforgeError;
356
+ /** Throws `error` (the table itself). */
357
+ unwrap(): never;
358
+ }
359
+
360
+ /**
361
+ * What every fallible call returns. Programmer errors (bad config, misuse
362
+ * of a transaction) and errors thrown by your own callbacks still throw.
363
+ */
364
+ type Result<T> = Ok<T> | Err;
365
+
200
366
  /*
201
367
  Profiles
202
368
  */
@@ -216,16 +382,22 @@ declare namespace dataforge {
216
382
  readonly open: boolean;
217
383
  readonly migrations: string[];
218
384
 
219
- /** Current data. Throws if the profile is closed or (lockless) not fetched. */
385
+ /**
386
+ * Current data. A plain accessor: throws a `DataforgeError`
387
+ * (`profile_closed`, or `not_fetched` for a lockless profile).
388
+ */
220
389
  get_data(): T;
221
- save(): void;
390
+ /** Fails with `profile_closed`, `released`, `roblox`, `lock_lost` (locked) or `lockless_locked`, `outdated`, ... (lockless). */
391
+ save(): Result<void>;
222
392
  unload(): void;
223
393
  /**
224
394
  * Applies `dispatcher` to the current data. Returning `undefined` or
225
- * `false` leaves the data untouched (returns `false`); anything else
226
- * becomes the new data and is persisted on the next save.
395
+ * `false` leaves the data untouched (`Ok(false)`); anything else
396
+ * becomes the new data and is persisted on the next save (`Ok(true)`).
397
+ * Fails with `profile_closed` / `released`; an error thrown by the
398
+ * dispatcher propagates as is.
227
399
  */
228
- update(dispatcher: DataDispatcher<T>): boolean;
400
+ update(dispatcher: DataDispatcher<T>): Result<boolean>;
229
401
 
230
402
  on_change(callback: (newData: T, oldData: T | undefined) => void): void;
231
403
  on_save(callback: () => void): void;
@@ -255,8 +427,8 @@ declare namespace dataforge {
255
427
  /**
256
428
  * Whether the profile currently holds its session lock. True after
257
429
  * `load`; `release` clears it, `readquire` takes it again. While
258
- * released, `update` / `save` throw and the profile cannot join a
259
- * transaction.
430
+ * released, `update` / `save` fail with `released` and the profile
431
+ * cannot join a transaction.
260
432
  */
261
433
  readonly is_locked: boolean;
262
434
 
@@ -266,13 +438,15 @@ declare namespace dataforge {
266
438
  /**
267
439
  * Writes the data and gives the session lock back, keeping the profile
268
440
  * open so another server may take the key. No-op when already released.
441
+ * Fails with `profile_closed`, `roblox` or `lock_lost`.
269
442
  */
270
- release(): void;
443
+ release(): Result<void>;
271
444
  /**
272
445
  * Takes the session lock again (waits like `load` does) and adopts the
273
- * stored record as the current data. No-op when already locked.
446
+ * stored record as the current data. No-op when already locked. Fails
447
+ * with `timeout`, `profile_closed`, `roblox`, `outdated`, `migration_mismatch`.
274
448
  */
275
- readquire(): void;
449
+ readquire(): Result<void>;
276
450
  }
277
451
 
278
452
  /** Profile without a session lock; updates are queued and folded into storage on save. */
@@ -286,9 +460,10 @@ declare namespace dataforge {
286
460
  /**
287
461
  * Reads the stored record into the local data: migrations are applied,
288
462
  * then the queued updates replayed on top. Marks the profile fetched.
289
- * Yields. Throws (keeping the queue) if a session lock is on the record.
463
+ * Yields. Fails (keeping the queue) with `lockless_locked` if a session
464
+ * lock is on the record, `outdated`, `roblox`, `migration_mismatch`, `profile_closed`.
290
465
  */
291
- fetch(): T;
466
+ fetch(): Result<T>;
292
467
  }
293
468
 
294
469
  /** Either profile kind; narrow on `kind`. */
@@ -310,8 +485,12 @@ declare namespace dataforge {
310
485
  readonly migrations: string[];
311
486
 
312
487
  get_data(): T;
313
- /** Calls `GetAsync` again and replaces `lock`, `pending`, `migrations` and the data. Yields. */
314
- refresh(): T;
488
+ /**
489
+ * Calls `GetAsync` again and replaces `lock`, `pending`, `migrations`
490
+ * and the data. Yields. Fails with `roblox`, `outdated` or `migration_mismatch`,
491
+ * keeping the previous snapshot.
492
+ */
493
+ refresh(): Result<T>;
315
494
  }
316
495
 
317
496
  /*
@@ -334,36 +513,46 @@ declare namespace dataforge {
334
513
  readonly profiles: ReadonlyMap<string, Profile<T>>;
335
514
  readonly lockless_profiles: ReadonlyMap<string, LocklessProfile<T>>;
336
515
 
337
- /** Takes the session lock for `key` and loads it. Yields. Throws when already loaded. */
338
- load(key: string, user_ids: number[]): Profile<T>;
339
- /** Returns the profile for `key`, waiting for it to load or loading it if nobody has yet. */
340
- wait_loaded(key: string, user_ids: number[]): Profile<T>;
516
+ /**
517
+ * Takes the session lock for `key` and loads it. Yields. Fails with
518
+ * `store_closed`, `already_loaded`, `timeout`, `roblox`,
519
+ * `outdated`, `migration_mismatch` or `tx_marker_invalid`.
520
+ */
521
+ load(key: string, user_ids: number[]): Result<Profile<T>>;
522
+ /**
523
+ * Returns the profile for `key`, waiting for it to load or loading it
524
+ * if nobody has yet. Waiters get the same result as the load.
525
+ */
526
+ wait_loaded(key: string, user_ids: number[]): Result<Profile<T>>;
341
527
  get_loaded(key: string): Profile<T> | undefined;
342
528
 
343
529
  /**
344
530
  * Returns the lockless profile for `key`, creating it if needed. Never
345
531
  * yields: a lockless profile does no storage work until `fetch`.
532
+ * Throws a `store_closed` error on a closed store.
346
533
  */
347
534
  get_lockless(key: string, user_ids: number[]): LocklessProfile<T>;
348
535
 
349
536
  /**
350
537
  * Read-only snapshot of `key` via `GetAsync`: the current data and lock.
351
538
  * Yields. The result is not tracked by the store, takes no lock and
352
- * cannot join a transaction.
539
+ * cannot join a transaction. Fails with `store_closed`, `roblox`, `outdated`
540
+ * or `migration_mismatch`.
353
541
  */
354
- peek(key: string): PeekProfile<T>;
542
+ peek(key: string): Result<PeekProfile<T>>;
355
543
 
356
544
  /**
357
545
  * Atomically updates profiles of this store (locked or lockless).
358
546
  * `transform` gets their data in order and returns the new data in the
359
- * same order, or `false` to cancel. Returns `false` when cancelled,
360
- * `true` when committed; throws when aborted.
547
+ * same order, or `false` to cancel. `Ok(false)` when cancelled,
548
+ * `Ok(true)` when committed; fails like `dataforge.transaction`, plus
549
+ * `store_closed`.
361
550
  */
362
551
  transaction(
363
552
  profiles: ProfileBase<T>[],
364
553
  transform: (data: T[]) => T[] | false,
365
554
  config?: TransactionConfig,
366
- ): boolean;
555
+ ): Result<boolean>;
367
556
 
368
557
  on_change(callback: (profile: AnyProfile<T>, newData: T, oldData: T | undefined) => void): void;
369
558
  on_save(callback: (profile: AnyProfile<T>) => void): void;
@@ -390,14 +579,17 @@ declare namespace dataforge {
390
579
  * and lockless ones. `process` reads with `ctx.get(profile)` and stages
391
580
  * writes with `ctx.set(profile, newData)`; return `false` to cancel.
392
581
  *
393
- * Returns `false` when cancelled, `true` when committed. Throws when aborted.
394
- * `config` defaults its retry settings to the first profile's.
582
+ * `Ok(false)` when cancelled, `Ok(true)` when committed. Fails with
583
+ * `tx_aborted` (see its `cause`), `profile_closed`, `released`, or what
584
+ * flushing a lockless participant raised. Misuse and errors thrown by
585
+ * `process` throw. `config` defaults its retry settings to the first
586
+ * profile's.
395
587
  */
396
588
  function transaction(
397
589
  profiles: ProfileBase<any>[],
398
590
  process: (ctx: TxContext) => boolean | undefined,
399
591
  config?: TransactionConfig,
400
- ): boolean;
592
+ ): Result<boolean>;
401
593
 
402
594
  namespace hooks {
403
595
  namespace memory {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rbxts/dataforge",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Roblox DataStore library with cross-key transactions, session-locked and lockless profiles, migrations and an immutable data model.",
5
5
  "license": "MIT",
6
6
  "author": "PepeElToro41",
@@ -0,0 +1,195 @@
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.not_fetched(profile: any): types.NotFetchedError
106
+ return make {
107
+ type = "not_fetched" :: "not_fetched",
108
+ message = `datastore: lockless profile "{profile.key}" has not been fetched`,
109
+ profile = profile,
110
+ }
111
+ end
112
+
113
+ function errors.profile_closed(profile: any, reason: "unloaded" | "lock_lost"): types.ProfileClosedError
114
+ local message = if reason == "lock_lost"
115
+ then `datastore: profile "{profile.key}" is closed (lock lost)`
116
+ else `datastore: profile "{profile.key}" is closed`
117
+ return make {
118
+ type = "profile_closed" :: "profile_closed",
119
+ message = message,
120
+ profile = profile,
121
+ reason = reason,
122
+ }
123
+ end
124
+
125
+ --- The error a closed profile rejects calls with: the one its mutex was closed
126
+ --- with (carries the right reason), or a plain "unloaded" while `unload` is
127
+ --- still waiting for the mutex.
128
+ function errors.closed(profile: any): types.ProfileClosedError
129
+ local closed = profile._mutex._closed
130
+ if closed ~= nil then
131
+ return closed
132
+ end
133
+ return errors.profile_closed(profile, "unloaded")
134
+ end
135
+
136
+ export type MigrationMismatchDetails = {
137
+ index: number,
138
+ expected: string,
139
+ actual: string,
140
+ applied: Array<string>,
141
+ declared: Array<string>,
142
+ }
143
+
144
+ function errors.migration_mismatch(key: string, details: MigrationMismatchDetails): types.MigrationMismatchError
145
+ local reason =
146
+ `migration mismatch at index {details.index}: record has "{details.actual}", declared is "{details.expected}"`
147
+ return make {
148
+ type = "migration_mismatch" :: "migration_mismatch",
149
+ message = `datastore: failed to apply migrations for "{key}": {reason}`,
150
+ key = key,
151
+ index = details.index,
152
+ expected = details.expected,
153
+ actual = details.actual,
154
+ applied = details.applied,
155
+ declared = details.declared,
156
+ }
157
+ end
158
+
159
+ function errors.tx_aborted(id: string, cause: types.Error?): types.TxAbortedError
160
+ local reason = if cause then (cause :: any).message else "marker resolved as aborted by another server"
161
+ return make {
162
+ type = "tx_aborted" :: "tx_aborted",
163
+ message = `datastore: transaction {id} aborted: {reason}`,
164
+ id = id,
165
+ cause = cause,
166
+ }
167
+ end
168
+
169
+ function errors.tx_marker_invalid(id: string, value: any): types.TxMarkerInvalidError
170
+ return make {
171
+ type = "tx_marker_invalid" :: "tx_marker_invalid",
172
+ message = `datastore: unexpected tx marker value for {id}: {tostring(value)}`,
173
+ id = id,
174
+ value = value,
175
+ }
176
+ end
177
+
178
+ function errors.store_closed(store: any): types.StoreClosedError
179
+ return make {
180
+ type = "store_closed" :: "store_closed",
181
+ message = `datastore: store "{store.config.name}" is closed`,
182
+ store = store,
183
+ }
184
+ end
185
+
186
+ function errors.already_loaded(store: any, key: string): types.AlreadyLoadedError
187
+ return make {
188
+ type = "already_loaded" :: "already_loaded",
189
+ message = `datastore: "{key}" is already loaded in this store`,
190
+ store = store,
191
+ key = key,
192
+ }
193
+ end
194
+
195
+ 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
@@ -18,6 +18,8 @@ export type Profile<T = any> = types.Profile<T>
18
18
  export type LocklessProfile<T = any> = types.LocklessProfile<T>
19
19
  export type ProfileBase<T = any> = types.ProfileBase<T>
20
20
  export type PeekProfile<T = any> = types.PeekProfile<T>
21
+ export type Result<T> = types.Result<T>
22
+ export type Error = types.Error
21
23
 
22
24
  local dataforge = {}
23
25
 
@@ -26,12 +28,14 @@ function dataforge.create_store<T>(config: Config<T>): Store<T>
26
28
  end
27
29
 
28
30
  --- Atomically updates any set of profiles, across stores and mixing locked and
29
- --- lockless ones.
31
+ --- lockless ones. Ok(false) when `process` returns false, Ok(true) when
32
+ --- committed. Fails with `tx_aborted` (see its `cause`), `profile_closed`,
33
+ --- `released`, or what flushing a lockless participant raised.
30
34
  function dataforge.transaction(
31
35
  profiles: Array<ProfileBase>,
32
36
  process: (ctx: TxContext) -> boolean?,
33
37
  config: TransactionConfig?
34
- ): boolean
38
+ ): Result<boolean>
35
39
  local first = profiles[1]
36
40
  local defaults: TransactionConfig? = if first
37
41
  then {
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