@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/src/types.luau CHANGED
@@ -61,7 +61,7 @@ export type Config<T = any> = {
61
61
  retry_base: number?,
62
62
  }
63
63
 
64
- --- Options shared by the retry helper. Both `ResolvedConfig` and
64
+ --- Options shared by the storage retry helpers. Both `ResolvedConfig` and
65
65
  --- `ResolvedTransactionConfig` satisfy it.
66
66
  export type RetryConfig = {
67
67
  _scheduler: Scheduler,
@@ -139,10 +139,175 @@ export type DataRecord<T = any> = {
139
139
  migrations: { string },
140
140
  }
141
141
 
142
+ --[[
143
+ Errors and results
144
+ ]]
145
+
146
+ --- Every runtime failure is one of these tables (see `errors.luau`): `type`
147
+ --- tells them apart, `message` is human readable (`tostring(err)` returns
148
+ --- it), the rest is typed extra data. `profile` / `store` fields are typed
149
+ --- `any` on purpose: naming `ProfileBase` or `Store` here would put them in a
150
+ --- recursive generic group with `Result`, which Luau rejects.
151
+ export type RobloxError = {
152
+ type: "roblox",
153
+ message: string,
154
+ op: "get_async" | "update_async",
155
+ store: string,
156
+ key: string,
157
+ --- the last raw error the storage hook threw
158
+ cause: any,
159
+ }
160
+
161
+ export type TimeoutError = {
162
+ type: "timeout",
163
+ message: string,
164
+ key: string,
165
+ --- the last lock seen on the record while waiting
166
+ lock: Lock?,
167
+ }
168
+
169
+ export type LockLostError = {
170
+ type: "lock_lost",
171
+ message: string,
172
+ profile: any,
173
+ --- the lock now on the record, nil when it has none
174
+ lock: Lock?,
175
+ }
176
+
177
+ export type ReleasedError = {
178
+ type: "released",
179
+ message: string,
180
+ profile: any,
181
+ }
182
+
183
+ export type LocklessLockedError = {
184
+ type: "lockless_locked",
185
+ message: string,
186
+ profile: any,
187
+ --- the live session lock another server holds on the record
188
+ lock: Lock,
189
+ }
190
+
191
+ export type TxLockLostError = {
192
+ type: "tx_lock_lost",
193
+ message: string,
194
+ profile: any,
195
+ --- the transaction lock this profile held
196
+ expected: Lock,
197
+ --- the lock now on the record, nil when it has none
198
+ lock: Lock?,
199
+ }
200
+
201
+ --- The record lists more migrations than this server declares: a newer server
202
+ --- wrote it, so this one must not touch it (nothing is written, no lock taken).
203
+ export type OutdatedError = {
204
+ type: "outdated",
205
+ message: string,
206
+ key: string,
207
+ --- the profile the call ran on; nil for `store:load` and `store:peek`
208
+ profile: any?,
209
+ record_migrations: Array<string>,
210
+ declared_migrations: Array<string>,
211
+ }
212
+
213
+ export type NotFetchedError = {
214
+ type: "not_fetched",
215
+ message: string,
216
+ profile: any,
217
+ }
218
+
219
+ export type ProfileClosedError = {
220
+ type: "profile_closed",
221
+ message: string,
222
+ profile: any,
223
+ reason: "unloaded" | "lock_lost",
224
+ }
225
+
226
+ --- A name in the record's applied migrations differs from the declared one
227
+ --- at `index`. A record with more migrations than declared is `outdated`.
228
+ export type MigrationMismatchError = {
229
+ type: "migration_mismatch",
230
+ message: string,
231
+ key: string,
232
+ --- the position that differs, and the declared / record's name there
233
+ index: number,
234
+ expected: string,
235
+ actual: string,
236
+ --- names the record has applied / this server declares
237
+ applied: Array<string>,
238
+ declared: Array<string>,
239
+ }
240
+
241
+ export type TxAbortedError = {
242
+ type: "tx_aborted",
243
+ message: string,
244
+ id: string,
245
+ --- the failure that made phase 1 give up; nil when the marker was already
246
+ --- resolved as aborted by another server
247
+ cause: Error?,
248
+ }
249
+
250
+ export type TxMarkerInvalidError = {
251
+ type: "tx_marker_invalid",
252
+ message: string,
253
+ id: string,
254
+ value: any,
255
+ }
256
+
257
+ export type StoreClosedError = {
258
+ type: "store_closed",
259
+ message: string,
260
+ store: any,
261
+ }
262
+
263
+ export type AlreadyLoadedError = {
264
+ type: "already_loaded",
265
+ message: string,
266
+ store: any,
267
+ key: string,
268
+ }
269
+
270
+ export type Error =
271
+ | RobloxError
272
+ | TimeoutError
273
+ | LockLostError
274
+ | ReleasedError
275
+ | LocklessLockedError
276
+ | TxLockLostError
277
+ | OutdatedError
278
+ | NotFetchedError
279
+ | ProfileClosedError
280
+ | MigrationMismatchError
281
+ | TxAbortedError
282
+ | TxMarkerInvalidError
283
+ | StoreClosedError
284
+ | AlreadyLoadedError
285
+
286
+ export type Ok<T> = {
287
+ success: true,
288
+ value: T,
289
+ --- Returns `value`.
290
+ unwrap: (self: any) -> T,
291
+ }
292
+
293
+ export type Err = {
294
+ success: false,
295
+ error: Error,
296
+ --- Throws `error` (the table itself; `tostring` gives its message).
297
+ --- Typed `any`: Luau cannot call a union of two function types, and this
298
+ --- keeps `result:unwrap()` callable on `Result<T>` while `success` still
299
+ --- narrows the union.
300
+ unwrap: any,
301
+ }
302
+
303
+ --- What every fallible library call returns. See `result.luau`.
304
+ export type Result<T> = Ok<T> | Err
305
+
142
306
  --- FIFO mutex for coroutines. See `mutex.luau`.
143
307
  export type Mutex = {
144
308
  _locked: boolean,
145
- _closed: string?,
309
+ --- The `Error` (a `profile_closed`) every lock is rejected with once closed.
310
+ _closed: any?,
146
311
  _waiters: { thread },
147
312
  _settlers: { thread },
148
313
 
@@ -151,7 +316,7 @@ export type Mutex = {
151
316
  wait_settled: (self: Mutex) -> (),
152
317
  lock: (self: Mutex) -> (),
153
318
  unlock: (self: Mutex) -> (),
154
- close: (self: Mutex, reason: string) -> (),
319
+ close: (self: Mutex, reason: any) -> (),
155
320
  is_locked: (self: Mutex) -> boolean,
156
321
  is_closed: (self: Mutex) -> boolean,
157
322
  with: (self: Mutex, fn: () -> ...any) -> any,
@@ -170,9 +335,9 @@ export type TxPatch = {
170
335
  --- Set once `prepare` succeeded (the record carries `pending`).
171
336
  prepared: boolean,
172
337
 
173
- --- Phase 1: persist `pending` next to the current data. Returns false when
174
- --- the profile no longer owns its lock. May throw.
175
- prepare: (pending: Pending) -> boolean,
338
+ --- Phase 1: persist `pending` next to the current data. Throws an `Error`
339
+ --- (`lock_lost`, `tx_lock_lost`, `lockless_locked`, `roblox`, ...) on failure.
340
+ prepare: (pending: Pending) -> (),
176
341
  --- Applies the outcome in memory. Called exactly once by the operation.
177
342
  commit: (operation: TxOperation) -> (),
178
343
  abort: (operation: TxOperation) -> (),
@@ -227,11 +392,14 @@ export type ProfileBase<T = any> = {
227
392
  --- Operations waiting for this profile to write a record without `pending`.
228
393
  _cleanup_txs: Array<TxOperation>,
229
394
 
230
- --- Current data. Throws if the profile is closed or (lockless) not fetched.
395
+ --- Current data. Throws an `Error` (`profile_closed`, or `not_fetched` for
396
+ --- a lockless profile) instead of returning a Result: it is a plain accessor.
231
397
  get_data: (self: ProfileBase<T>) -> T,
232
- save: (self: ProfileBase<T>) -> (),
398
+ save: (self: ProfileBase<T>) -> Result<nil>,
233
399
  unload: (self: ProfileBase<T>) -> (),
234
- update: (self: ProfileBase<T>, (T) -> T | false) -> boolean,
400
+ --- Ok(true) when the data changed, Ok(false) when the dispatcher returned
401
+ --- nil / false. An error thrown by the dispatcher propagates as is.
402
+ update: (self: ProfileBase<T>, (T) -> T | false) -> Result<boolean>,
235
403
 
236
404
  on_change: (self: ProfileBase<T>, callback: (new: T, old: T?) -> ()) -> (),
237
405
  on_save: (self: ProfileBase<T>, callback: () -> ()) -> (),
@@ -274,13 +442,14 @@ export type Profile<T = any> = ProfileBase<T> & {
274
442
 
275
443
  --- Writes the data and gives the session lock back, keeping the profile
276
444
  --- open so another server may take the key. No-op when already released.
277
- release: (self: Profile<T>) -> (),
445
+ release: (self: Profile<T>) -> Result<nil>,
278
446
  --- Takes the session lock again (waits like `load` does) and adopts the
279
447
  --- stored record as the current data. No-op when already locked.
280
- readquire: (self: Profile<T>) -> (),
448
+ readquire: (self: Profile<T>) -> Result<nil>,
281
449
 
282
- -- internal, used by transactions. Returns false when the lock is gone.
283
- _write_record: (self: Profile<T>, options: ProfileWriteOptions) -> boolean,
450
+ -- internal, used by transactions. Returns false (and the lock now on the
451
+ -- record) when the lock is gone.
452
+ _write_record: (self: Profile<T>, options: ProfileWriteOptions) -> (boolean, Lock?),
284
453
  }
285
454
 
286
455
  export type ProfileWriteOptions = {
@@ -312,7 +481,7 @@ export type LocklessProfile<T = any> = ProfileBase<T> & {
312
481
  lock_lost: Array<() -> ()>,
313
482
  },
314
483
 
315
- fetch: (self: LocklessProfile<T>) -> T,
484
+ fetch: (self: LocklessProfile<T>) -> Result<T>,
316
485
 
317
486
  -- internal, used by transactions
318
487
  _write_queued: (self: LocklessProfile<T>, options: LocklessWriteOptions) -> (),
@@ -340,7 +509,7 @@ export type PeekProfile<T = any> = {
340
509
  get_data: (self: PeekProfile<T>) -> T,
341
510
  --- Calls `GetAsync` again and replaces `lock`, `pending`, `migrations` and
342
511
  --- the data. Yields.
343
- refresh: (self: PeekProfile<T>) -> T,
512
+ refresh: (self: PeekProfile<T>) -> Result<T>,
344
513
  }
345
514
 
346
515
  export type LocklessWriteOptions = {
@@ -367,7 +536,9 @@ export type LoadOptions = {
367
536
  export type MigrationsResult = {
368
537
  applied: boolean,
369
538
  success: boolean,
370
- err: any,
539
+ --- `outdated` when the record lists more migrations than declared,
540
+ --- `migration_mismatch` when a name differs
541
+ err: (MigrationMismatchError | OutdatedError)?,
371
542
  result: any,
372
543
  new_migrations: { string },
373
544
  }
package/src/utils.luau CHANGED
@@ -1,3 +1,4 @@
1
+ local errors = require "./errors"
1
2
  local types = require "./types"
2
3
 
3
4
  export type Counter = types.Counter
@@ -74,7 +75,9 @@ function utils.uuid(): string
74
75
  return table.concat(chars)
75
76
  end
76
77
 
77
- function utils.retry<T>(config: types.RetryConfig, callback: () -> T): T
78
+ --- Calls `callback` up to `retry_attempts` times with exponential backoff.
79
+ --- Returns the result, or false and the last error once every attempt failed.
80
+ local function retry<T>(config: types.RetryConfig, callback: () -> T): (boolean, T | any)
78
81
  local last_error: any = nil
79
82
  local attempts = config.retry_attempts
80
83
  local base = config.retry_base
@@ -82,14 +85,45 @@ function utils.retry<T>(config: types.RetryConfig, callback: () -> T): T
82
85
  for attempt = 1, attempts do
83
86
  local ok, result = pcall(callback)
84
87
  if ok then
85
- return result
88
+ return true, result
86
89
  end
87
90
  last_error = result
88
91
  if attempt < attempts then
89
92
  config._scheduler.wait(base * 2 ^ (attempt - 1))
90
93
  end
91
94
  end
92
- error(last_error, 0)
95
+ return false, last_error
96
+ end
97
+
98
+ --- `datastore.get_async(key)` under the retry policy. Throws a `roblox`
99
+ --- error once every attempt failed.
100
+ function utils.get_async(config: types.RetryConfig, datastore: types.DatastoreHandle, key: string): any?
101
+ local ok, result = retry(config, function()
102
+ return datastore.get_async(key)
103
+ end)
104
+ if not ok then
105
+ error(errors.roblox("get_async", datastore.name, key, result), 0)
106
+ end
107
+ return result
108
+ end
109
+
110
+ --- `datastore.update_async(key, transform, user_ids)` under the retry policy.
111
+ --- Throws a `roblox` error once every attempt failed. `transform` must not
112
+ --- throw: whatever it throws counts as a storage failure and is retried.
113
+ function utils.update_async(
114
+ config: types.RetryConfig,
115
+ datastore: types.DatastoreHandle,
116
+ key: string,
117
+ transform: (old: any?) -> any?,
118
+ user_ids: { number }?
119
+ ): any?
120
+ local ok, result = retry(config, function()
121
+ return datastore.update_async(key, transform, user_ids)
122
+ end)
123
+ if not ok then
124
+ error(errors.roblox("update_async", datastore.name, key, result), 0)
125
+ end
126
+ return result
93
127
  end
94
128
 
95
129
  function utils.counter(): Counter