@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.
@@ -1,4 +1,6 @@
1
+ local errors = require "../errors"
1
2
  local mutex = require "../mutex"
3
+ local result = require "../result"
2
4
  local types = require "../types"
3
5
  local utils = require "../utils"
4
6
  local common = require "./common"
@@ -13,22 +15,57 @@ export type LoadOptions = types.LoadOptions
13
15
  local profile = {}
14
16
  profile.__index = profile
15
17
 
16
- local function adquire_lock(load_options: LoadOptions): types.DataRecord
18
+ --- One counter per process: a lock id must be unique per acquisition, not per
19
+ --- server, so two sessions of the same key on this server can tell their locks
20
+ --- apart. `#` keeps them out of the transaction id namespace (`{job_id}-{n}`),
21
+ --- which lockless writes compare by id.
22
+ local lock_counter = utils.counter()
23
+
24
+ --- Takes the session lock on `key`, waiting out (or stealing by expiry) any
25
+ --- holder. Returns the locked record and the id this acquisition stamped on it.
26
+ local function adquire_lock(load_options: LoadOptions): (types.DataRecord, string)
17
27
  local config = load_options.config
18
28
  local user_ids = load_options.user_ids
19
29
  local datastore = load_options._datastore
20
- local job_id = config._hook.job_id()
21
30
  local key = load_options.key
31
+ -- minted once per acquisition, before the loop: waiting may write the lock
32
+ -- several times and every attempt must stamp the same id
33
+ local lock_id = `{config._hook.job_id()}#{lock_counter()}`
22
34
 
23
35
  local scheduler = config._scheduler
24
36
 
25
37
  local deadline = scheduler.now() + config.load_timeout
38
+ -- the last lock seen on the record, reported when the load times out
39
+ local last_lock: types.Lock? = nil
40
+ -- set by the transform: the record lists more migrations than this server
41
+ -- declares, so the lock must not be taken (the write is cancelled)
42
+ local outdated_migrations: { string }? = nil
43
+ -- set by the transform: the error the store's predicate rejected the record
44
+ -- with (or the one the predicate itself threw); the lock is not taken
45
+ local predicate_error: any = nil
26
46
 
27
47
  while true do
28
48
  local now = scheduler.now()
29
- local lock_applied = utils.retry(config, function()
30
- return datastore.update_async(key, function(old: types.DataRecord?): types.DataRecord?
31
- if common.is_locked_for(old :: types.DataRecord, job_id, now) then
49
+ local lock_applied = utils.update_async(
50
+ config,
51
+ datastore,
52
+ key,
53
+ function(old: types.DataRecord?): types.DataRecord?
54
+ outdated_migrations = nil
55
+ predicate_error = nil
56
+ if common.is_outdated(old, config) then
57
+ outdated_migrations = (old :: types.DataRecord).migrations
58
+ return nil
59
+ end
60
+ -- before the lock check on purpose: a key this server must not
61
+ -- own is rejected right away instead of waiting out its holder
62
+ local stored = if old then old.data else nil
63
+ local passed, failure = common.run_predicate(config, key, stored)
64
+ if not passed then
65
+ predicate_error = failure or errors.predicate_failed(key, "load", stored)
66
+ return nil
67
+ end
68
+ if common.is_locked_for(old :: types.DataRecord, lock_id, now) then
32
69
  return nil
33
70
  end
34
71
  local record: types.DataRecord = old or {} :: types.DataRecord
@@ -38,28 +75,35 @@ local function adquire_lock(load_options: LoadOptions): types.DataRecord
38
75
  pending = record.pending,
39
76
  migrations = record.migrations or {},
40
77
  lock = {
41
- id = job_id,
78
+ id = lock_id,
42
79
  expires = now + config.lock_ttl,
43
80
  },
44
81
  }
45
- end, user_ids)
46
- end) :: types.DataRecord
82
+ end,
83
+ user_ids
84
+ ) :: types.DataRecord
47
85
 
48
- if common.is_lock_owned(lock_applied, job_id) then
49
- return lock_applied
86
+ if outdated_migrations ~= nil then
87
+ error(errors.outdated(key, outdated_migrations, common.all_migration_names(config.migrations)), 0)
88
+ end
89
+ if predicate_error ~= nil then
90
+ error(predicate_error, 0)
50
91
  end
92
+ if common.is_lock_owned(lock_applied, lock_id) then
93
+ return lock_applied, lock_id
94
+ end
95
+ last_lock = if lock_applied then lock_applied.lock else nil
51
96
 
52
97
  while true do
53
98
  if scheduler.now() >= deadline then
54
- error(`datastore: timed out loading "{key}" (session locked by another server)`)
99
+ error(errors.timeout(key, last_lock), 0)
55
100
  end
56
101
  scheduler.wait(config.load_poll)
57
102
 
58
- local current = utils.retry(config, function()
59
- return datastore.get_async(key)
60
- end) :: types.DataRecord?
103
+ local current = utils.get_async(config, datastore, key) :: types.DataRecord?
104
+ last_lock = if current then current.lock else nil
61
105
 
62
- if not common.is_locked_for(current, job_id, scheduler.now()) then
106
+ if not common.is_locked_for(current, lock_id, scheduler.now()) then
63
107
  break
64
108
  end
65
109
  end
@@ -85,9 +129,11 @@ local function run_autosave_loop(self: Profile)
85
129
 
86
130
  -- a failed write must not kill the loop: the next tick retries, and
87
131
  -- `write_data_record` gives the lock up after a full ttl of failures
88
- local ok, err = utils.try(self.save, self)
132
+ local ok, res = utils.try(self.save, self)
89
133
  if not ok then
90
- config._hook.warn(`datastore: autosave failed for "{self.key}": {tostring(err)}`)
134
+ config._hook.warn(`datastore: autosave failed for "{self.key}": {tostring(res)}`)
135
+ elseif not res.success then
136
+ config._hook.warn(`datastore: autosave failed for "{self.key}": {tostring(res.error)}`)
91
137
  end
92
138
  end
93
139
  end)
@@ -109,6 +155,18 @@ local function set_data(self: Profile, data: any)
109
155
  fire_hooks(self, self._hooks.change, new, old)
110
156
  end
111
157
 
158
+ --- Lets the store drop the key as soon as the profile stops being usable,
159
+ --- without waiting for the final write to land. Idempotent: a lost lock
160
+ --- followed by an unload must only detach once.
161
+ local function detach(self: Profile)
162
+ local on_detach = self._on_detach
163
+ if on_detach == nil or self._detached then
164
+ return
165
+ end
166
+ self._detached = true
167
+ on_detach()
168
+ end
169
+
112
170
  --- Another server took the key: the profile closes without a release write.
113
171
  --- Runs inside a write (mutex held by the caller), so it only closes the mutex:
114
172
  --- the holder finishes, everything waiting on it is rejected.
@@ -117,7 +175,8 @@ local function process_lock_lost(self: Profile)
117
175
  return
118
176
  end
119
177
  self.open = false
120
- self._mutex:close(`datastore: profile "{self.key}" lost its session lock`)
178
+ detach(self)
179
+ self._mutex:close(errors.profile_closed(self, "lock_lost"))
121
180
  fire_hooks(self, self._hooks.lock_lost)
122
181
  fire_hooks(self, self._hooks.closed)
123
182
  end
@@ -127,20 +186,40 @@ type WriteOptions = {
127
186
  pending: types.Pending?,
128
187
  }
129
188
 
130
- local function write_data_record(self: Profile, options: WriteOptions): boolean
189
+ --- Writes the data (and `pending`, if any) under our session lock, refreshing
190
+ --- or releasing it. Returns true when written; false and the lock now on the
191
+ --- record (nil when none) when the lock is not ours any more, in which case
192
+ --- the profile is closed. Throws a `roblox` error when storage fails, or
193
+ --- `predicate_failed` when the store's predicate rejected the stored data
194
+ --- (nothing is written and the lock is kept).
195
+ local function write_data_record(self: Profile, options: WriteOptions): (boolean, types.Lock?)
131
196
  local config = self.config
132
197
  local scheduler = config._scheduler
133
198
  local datastore = self._datastore
134
199
  local now = scheduler.now()
135
- local job_id = config._hook.job_id()
200
+ -- this acquisition's id, not the job id: another session of this key on this
201
+ -- server holds a different lock and must not be written over
202
+ local lock_id = self.lock_id
136
203
 
137
204
  -- set by the transform: whether the write went through (the transform may
138
- -- run more than once when the storage retries it)
205
+ -- run more than once when the storage retries it), and the lock it found
139
206
  local applied = false
207
+ local seen_lock: types.Lock? = nil
208
+ -- set by the transform: the store's predicate rejected the stored data (or
209
+ -- threw); the write is cancelled without the lock being lost
210
+ local predicate_error: any = nil
140
211
 
141
212
  local function transform(old: any?): DataRecord?
142
213
  applied = false
143
- if not common.is_lock_owned(old, job_id) then
214
+ predicate_error = nil
215
+ seen_lock = if old then old.lock else nil
216
+ if not common.is_lock_owned(old, lock_id) then
217
+ return nil
218
+ end
219
+ local stored = if old then old.data else nil
220
+ local passed, failure = common.run_predicate(config, self.key, stored)
221
+ if not passed then
222
+ predicate_error = failure or errors.predicate_failed(self.key, "save", stored, self)
144
223
  return nil
145
224
  end
146
225
  applied = true
@@ -148,7 +227,9 @@ local function write_data_record(self: Profile, options: WriteOptions): boolean
148
227
  local new_lock: types.Lock? = nil
149
228
  if not options.release then
150
229
  new_lock = {
151
- id = job_id,
230
+ -- the same id every time: a fresh one would look like a new lock
231
+ -- to everyone else
232
+ id = lock_id,
152
233
  expires = now + config.lock_ttl,
153
234
  tx = nil,
154
235
  } :: types.Lock
@@ -163,9 +244,7 @@ local function write_data_record(self: Profile, options: WriteOptions): boolean
163
244
  end
164
245
 
165
246
  local success, err = pcall(function()
166
- utils.retry(config, function()
167
- return datastore.update_async(self.key, transform, self.user_ids)
168
- end)
247
+ utils.update_async(config, datastore, self.key, transform, self.user_ids)
169
248
  end)
170
249
 
171
250
  if not success then
@@ -177,10 +256,16 @@ local function write_data_record(self: Profile, options: WriteOptions): boolean
177
256
  error(err, 0)
178
257
  end
179
258
 
259
+ if predicate_error ~= nil then
260
+ -- the predicate cancelled the write, not a lost lock: the profile keeps
261
+ -- its lock and stays open, so the next save may go through again
262
+ error(predicate_error, 0)
263
+ end
264
+
180
265
  if not applied then
181
266
  -- the transform bailed out: the lock is not ours any more
182
267
  process_lock_lost(self)
183
- return false
268
+ return false, seen_lock
184
269
  end
185
270
 
186
271
  self.last_write = now
@@ -197,31 +282,33 @@ local function write_data_record(self: Profile, options: WriteOptions): boolean
197
282
  return true
198
283
  end
199
284
 
200
- --- Gives the lock taken by `adquire_lock` back, leaving the record otherwise
201
- --- untouched. Used when the load fails after locking; a failure here is only
202
- --- warned about (the lock expires on its own).
203
- local function release_lock(load_options: LoadOptions)
285
+ --- Gives the lock `lock_id` (taken by `adquire_lock`) back, leaving the record
286
+ --- otherwise untouched. Used when the load fails after locking; a failure here
287
+ --- is only warned about (the lock expires on its own). Takes the acquisition id
288
+ --- so a failed load cannot clear a lock another local acquisition just took.
289
+ local function release_lock(load_options: LoadOptions, lock_id: string)
204
290
  local config = load_options.config
205
- local job_id = config._hook.job_id()
206
291
 
207
292
  local ok, err = utils.try(function()
208
- utils.retry(config, function()
209
- return load_options._datastore.update_async(load_options.key, function(old: DataRecord?): DataRecord?
210
- if not common.is_lock_owned(old, job_id) then
211
- return nil
212
- end
213
- local record = old :: DataRecord
214
- return {
215
- data = record.data,
216
- pending = record.pending,
217
- migrations = record.migrations,
218
- lock = nil,
219
- }
220
- end, load_options.user_ids)
221
- end)
293
+ utils.update_async(config, load_options._datastore, load_options.key, function(old: DataRecord?): DataRecord?
294
+ if not common.is_lock_owned(old, lock_id) then
295
+ return nil
296
+ end
297
+ local record = old :: DataRecord
298
+ return {
299
+ data = record.data,
300
+ pending = record.pending,
301
+ migrations = record.migrations,
302
+ lock = nil,
303
+ }
304
+ end, load_options.user_ids)
222
305
  end)
223
306
  if not ok then
224
- config._hook.warn(`datastore: could not release the lock of "{load_options.key}" after a failed load (it will expire): {tostring(err)}`)
307
+ config._hook.warn(
308
+ `datastore: could not release the lock of "{load_options.key}" after a failed load (it will expire): {tostring(
309
+ err
310
+ )}`
311
+ )
225
312
  end
226
313
  end
227
314
 
@@ -253,15 +340,15 @@ local function resolve_loaded(load_options: LoadOptions, record: DataRecord): (a
253
340
  end
254
341
  dirty = true
255
342
  else
256
- local result = apply_migrations(data, record.migrations or {}, migrations)
257
- if not result.success then
258
- error(`datastore: failed to apply migrations for "{load_options.key}": {result.err}`)
343
+ local migrated = apply_migrations(load_options.key, data, record.migrations or {}, migrations)
344
+ if not migrated.success then
345
+ error(migrated.err, 0)
259
346
  end
260
347
 
261
- data = result.result
262
- new_migrations = result.new_migrations
348
+ data = migrated.result
349
+ new_migrations = migrated.new_migrations
263
350
 
264
- if result.applied then
351
+ if migrated.applied then
265
352
  dirty = true
266
353
  end
267
354
  end
@@ -271,19 +358,21 @@ end
271
358
 
272
359
  --- Takes the session lock and resolves the locked record into data. If the
273
360
  --- resolution fails the lock is given back before rethrowing.
274
- local function adquire_and_resolve(load_options: LoadOptions): (any, { string }, boolean)
275
- local record = adquire_lock(load_options)
361
+ local function adquire_and_resolve(load_options: LoadOptions): (any, { string }, boolean, string)
362
+ local record, lock_id = adquire_lock(load_options)
276
363
  local config = load_options.config
277
364
 
278
365
  -- from here on the key is locked: anything failing must give it back
279
366
  local ok, data, new_migrations, dirty = pcall(resolve_loaded, load_options, record)
280
367
  if not ok then
281
368
  local err = data
282
- config._hook.warn(`datastore: load of "{load_options.key}" failed after taking its lock, releasing it: {tostring(err)}`)
283
- release_lock(load_options)
369
+ config._hook.warn(
370
+ `datastore: load of "{load_options.key}" failed after taking its lock, releasing it: {tostring(err)}`
371
+ )
372
+ release_lock(load_options, lock_id)
284
373
  error(err, 0)
285
374
  end
286
- return data, new_migrations, dirty
375
+ return data, new_migrations, dirty, lock_id
287
376
  end
288
377
 
289
378
  local function load_options_of(self: Profile): LoadOptions
@@ -297,12 +386,12 @@ end
297
386
 
298
387
  local function assert_locked(self: Profile)
299
388
  if not self.is_locked then
300
- error(`datastore: profile "{self.key}" does not hold its session lock`, 3)
389
+ error(errors.released(self), 0)
301
390
  end
302
391
  end
303
392
 
304
393
  function profile.load(load_options: LoadOptions): Profile
305
- local data, new_migrations, dirty = adquire_and_resolve(load_options)
394
+ local data, new_migrations, dirty, lock_id = adquire_and_resolve(load_options)
306
395
  local config = load_options.config
307
396
 
308
397
  local self: Profile = {} :: any
@@ -312,6 +401,8 @@ function profile.load(load_options: LoadOptions): Profile
312
401
  self._datastore = load_options._datastore
313
402
  self.open = true
314
403
  self.is_locked = true
404
+ self.lock_id = lock_id
405
+ self._detached = false
315
406
 
316
407
  self.config = load_options.config
317
408
  self.migrations = new_migrations
@@ -343,22 +434,26 @@ function profile.get_data(self: Profile)
343
434
  end
344
435
 
345
436
  --- Applies `dispatcher` to the current data. Returning nil or false leaves the
346
- --- data untouched (returns false); anything else becomes the new data and is
347
- --- persisted on the next save. Waits for any in-flight save on the mutex.
348
- function profile.update(self: Profile, dispatcher: (any) -> any): boolean
349
- common.assert_open(self)
350
- assert_locked(self)
351
-
352
- return self._mutex:with(function()
353
- -- a release may have run while this call waited on the mutex
437
+ --- data untouched (Ok(false)); anything else becomes the new data and is
438
+ --- persisted on the next save (Ok(true)). Waits for any in-flight save on the
439
+ --- mutex. Fails with `profile_closed` / `released`; an error thrown by the
440
+ --- dispatcher propagates as is.
441
+ function profile.update(self: Profile, dispatcher: (any) -> any): types.Result<boolean>
442
+ return result.capture(function()
443
+ common.assert_open(self)
354
444
  assert_locked(self)
355
- local result = dispatcher(self._data)
356
- if result == nil or result == false then
357
- return false
358
- end
359
- set_data(self, result)
360
- self._dirty = true
361
- return true
445
+
446
+ return self._mutex:with(function()
447
+ -- a release may have run while this call waited on the mutex
448
+ assert_locked(self)
449
+ local new = dispatcher(self._data)
450
+ if new == nil or new == false then
451
+ return false
452
+ end
453
+ set_data(self, new)
454
+ self._dirty = true
455
+ return true
456
+ end)
362
457
  end)
363
458
  end
364
459
 
@@ -382,61 +477,92 @@ function profile.on_lock_lost(self: Profile, callback: () -> ())
382
477
  table.insert(self._hooks.lock_lost.hooks, callback)
383
478
  end
384
479
 
385
- function profile.save(self: Profile)
386
- common.assert_open(self)
387
- assert_locked(self)
388
- -- before the mutex so hooks can still run updates that land in this save
389
- fire_hooks(self, self._hooks.save)
390
- common.assert_open(self)
391
- assert_locked(self)
392
- self._mutex:with(function()
393
- -- without the lock the write would be cancelled and wrongly read as lost
480
+ --- Writes the data, refreshing the session lock. Fails with `profile_closed`,
481
+ --- `released`, `roblox`, `predicate_failed` (the store's predicate rejected
482
+ --- the stored data; nothing is written and the lock is kept), or `lock_lost`
483
+ --- (another server took the key; the profile is closed).
484
+ function profile.save(self: Profile): types.Result<nil>
485
+ return result.capture(function()
486
+ common.assert_open(self)
487
+ assert_locked(self)
488
+ -- before the mutex so hooks can still run updates that land in this save
489
+ fire_hooks(self, self._hooks.save)
490
+ common.assert_open(self)
394
491
  assert_locked(self)
395
- write_data_record(self, { release = false })
492
+ self._mutex:with(function()
493
+ -- without the lock the write would be cancelled and wrongly read as lost
494
+ assert_locked(self)
495
+ local written, lock = write_data_record(self, { release = false })
496
+ if not written then
497
+ error(errors.lock_lost(self, lock), 0)
498
+ end
499
+ end)
500
+ return nil
396
501
  end)
397
502
  end
398
503
 
399
504
  --- Writes the data and gives the session lock back. The profile stays open
400
- --- (another server may load the key meanwhile); `update` and `save` throw
401
- --- until `readquire`. No-op when already released.
402
- function profile.release(self: Profile)
403
- common.assert_open(self)
404
- if not self.is_locked then
405
- return
406
- end
407
- self._mutex:with(function()
408
- if not self.open or not self.is_locked then
409
- return
505
+ --- (another server may load the key meanwhile); `update` and `save` fail with
506
+ --- `released` until `readquire`. No-op when already released. Fails with
507
+ --- `profile_closed`, `roblox` or `predicate_failed` (the lock stays on the
508
+ --- record, `is_locked` stays true), or `lock_lost` (the profile is closed).
509
+ function profile.release(self: Profile): types.Result<nil>
510
+ return result.capture(function()
511
+ common.assert_open(self)
512
+ if not self.is_locked then
513
+ return nil
410
514
  end
411
- -- false: the lock was not ours any more, `process_lock_lost` closed the profile.
412
- -- A storage error propagates; the lock is still on the record, so `is_locked` stays.
413
- if write_data_record(self, { release = true }) then
515
+ self._mutex:with(function()
516
+ -- an unload queued behind this call has flipped `open`: its own write
517
+ -- gives the lock back
518
+ if not self.open or not self.is_locked then
519
+ return
520
+ end
521
+ local written, lock = write_data_record(self, { release = true })
522
+ if not written then
523
+ error(errors.lock_lost(self, lock), 0)
524
+ end
414
525
  self.is_locked = false
415
- end
526
+ end)
527
+ return nil
416
528
  end)
417
529
  end
418
530
 
419
531
  --- Takes the session lock again, waiting for a holder like `load` does, and
420
532
  --- adopts the stored record (resolved pending, migrations) as the current
421
- --- data. Throws on timeout, leaving the profile open and released. No-op when
422
- --- already locked.
423
- function profile.readquire(self: Profile)
424
- common.assert_open(self)
425
- if self.is_locked then
426
- return
427
- end
428
- self._mutex:with(function()
533
+ --- data. Fails with `timeout` (the profile stays open and released),
534
+ --- `profile_closed`, `roblox`, `outdated`, `predicate_failed`,
535
+ --- `migration_mismatch` or `tx_marker_invalid`. No-op when already locked.
536
+ function profile.readquire(self: Profile): types.Result<nil>
537
+ return result.capture(function()
429
538
  common.assert_open(self)
430
539
  if self.is_locked then
431
- return
540
+ return nil
432
541
  end
433
- local data, new_migrations, dirty = adquire_and_resolve(load_options_of(self))
542
+ self._mutex:with(function()
543
+ common.assert_open(self)
544
+ if self.is_locked then
545
+ return
546
+ end
547
+ local ok, data, new_migrations, dirty, lock_id = pcall(adquire_and_resolve, load_options_of(self))
548
+ if not ok then
549
+ local err: any = data
550
+ if errors.is(err) and err.type == "outdated" then
551
+ err.profile = self
552
+ end
553
+ error(err, 0)
554
+ end
434
555
 
435
- self.migrations = new_migrations
436
- set_data(self, data)
437
- self._dirty = dirty
438
- self.last_write = self.config._scheduler.now()
439
- self.is_locked = true
556
+ self.migrations = new_migrations
557
+ -- a fresh acquisition, so a fresh id (the released profile kept the
558
+ -- old one; nothing could be written with it while released)
559
+ self.lock_id = lock_id
560
+ set_data(self, data)
561
+ self._dirty = dirty
562
+ self.last_write = self.config._scheduler.now()
563
+ self.is_locked = true
564
+ end)
565
+ return nil
440
566
  end)
441
567
  end
442
568
 
@@ -451,10 +577,14 @@ function profile.unload(self: Profile)
451
577
  end
452
578
 
453
579
  self.open = false
580
+ -- before the release write (which may take seconds under throttling): the
581
+ -- key is free to be loaded again, the new load waits on the record lock
582
+ detach(self)
454
583
  self._mutex:lock()
455
- self._mutex:close(`datastore: profile "{self.key}" is closed`)
584
+ self._mutex:close(errors.profile_closed(self, "unloaded"))
456
585
  local ok, err = utils.try(function()
457
- -- an already released profile has nothing on the record to give back
586
+ -- an already released profile has nothing on the record to give back;
587
+ -- a lost lock (false) has nothing to give back either
458
588
  if self.is_locked then
459
589
  write_data_record(self, { release = true })
460
590
  end
@@ -0,0 +1,61 @@
1
+ --!strict
2
+
3
+ local errors = require "./errors"
4
+ local types = require "./types"
5
+ local utils = require "./utils"
6
+
7
+ --[[
8
+ `Result<T>`: what every fallible library call returns instead of throwing.
9
+
10
+ Internally the library keeps throwing, but it throws `Error` tables (see
11
+ `errors.luau`). `result.capture` at each public entry point turns an
12
+ `Error` into `Err`; anything else (a string thrown by a user callback, an
13
+ internal invariant) is rethrown untouched.
14
+ ]]
15
+
16
+ export type Result<T> = types.Result<T>
17
+ export type Error = types.Error
18
+
19
+ local result_mt = {}
20
+ result_mt.__index = result_mt
21
+
22
+ function result_mt.unwrap(self: any): any
23
+ if self.success then
24
+ return self.value
25
+ end
26
+ error(self.error, 0)
27
+ end
28
+
29
+ local result = {}
30
+
31
+ function result.ok<T>(value: T): Result<T>
32
+ return setmetatable({ success = true, value = value }, result_mt) :: any
33
+ end
34
+
35
+ function result.err(err: Error): Result<any>
36
+ assert(errors.is(err), "result.err expects an Error table")
37
+ return setmetatable({ success = false, error = err }, result_mt) :: any
38
+ end
39
+
40
+ function result.is(value: any): boolean
41
+ return type(value) == "table" and getmetatable(value) == result_mt
42
+ end
43
+
44
+ --- Turns a `pcall` outcome into a Result: `Ok(value)` on success, `Err` when
45
+ --- the thrown value is an `Error` table, rethrown raw otherwise.
46
+ function result.of<T>(ok: boolean, value: any): Result<T>
47
+ if ok then
48
+ return result.ok(value)
49
+ end
50
+ if errors.is(value) then
51
+ return result.err(value)
52
+ end
53
+ error(value, 0)
54
+ end
55
+
56
+ --- `pcall(fn, ...)` folded through `result.of`.
57
+ function result.capture<T>(fn: (...any) -> T, ...: any): Result<T>
58
+ return result.of(utils.try(fn, ...))
59
+ end
60
+
61
+ return result