@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.
package/src/types.luau CHANGED
@@ -5,6 +5,17 @@ export type Counter = () -> number
5
5
  --- Returns the new data, or nil / false to leave it untouched.
6
6
  export type DataDispatcher<T> = (T) -> (T | false)?
7
7
 
8
+ --- Decides whether this server may work with a key at all. `data` is the raw
9
+ --- stored data as it is in storage: nil when the key does not exist yet, and
10
+ --- with the declared migrations *not* applied. Returning false rejects the
11
+ --- operation with a `predicate_failed` error: no lock is taken and nothing is
12
+ --- written.
13
+ ---
14
+ --- It runs inside the `update_async` transform, so it must be pure: no
15
+ --- yielding, no storage calls, and no mutation of `data` (it may be retried,
16
+ --- and the data it gets is the record this server is about to write back).
17
+ export type Predicate<T = any> = (data: T?, key: string) -> boolean
18
+
8
19
  --- A storage handle for one named store. Deliberately not shaped like a
9
20
  --- Roblox DataStore. All methods throw on failure.
10
21
  export type DatastoreHandle = {
@@ -45,6 +56,10 @@ export type Config<T = any> = {
45
56
 
46
57
  migrations: Array<Migration>?,
47
58
 
59
+ --- Gate on a key's stored data: every load, save, fetch and peek is
60
+ --- rejected with `predicate_failed` when it returns false. See `Predicate`.
61
+ predicate: Predicate<T>?,
62
+
48
63
  --- seconds, default 30 (locked profiles)
49
64
  autosave_interval: number?,
50
65
  -- seconds, default autosave_interval (lockless profiles)
@@ -61,7 +76,7 @@ export type Config<T = any> = {
61
76
  retry_base: number?,
62
77
  }
63
78
 
64
- --- Options shared by the retry helper. Both `ResolvedConfig` and
79
+ --- Options shared by the storage retry helpers. Both `ResolvedConfig` and
65
80
  --- `ResolvedTransactionConfig` satisfy it.
66
81
  export type RetryConfig = {
67
82
  _scheduler: Scheduler,
@@ -77,6 +92,8 @@ export type ResolvedConfig<T = any> = RetryConfig & {
77
92
  _scheduler: Scheduler,
78
93
 
79
94
  migrations: Array<Migration>,
95
+ --- nil when the store declared none (nothing is gated).
96
+ predicate: Predicate<T>?,
80
97
 
81
98
  autosave_interval: number,
82
99
  flush_interval: number,
@@ -115,6 +132,10 @@ export type ResolvedTransactionConfig = RetryConfig & {
115
132
  }
116
133
 
117
134
  export type Lock = {
135
+ --- Identifies the *acquisition* that holds the lock, not the server:
136
+ --- `{job_id}#{n}` for a profile's session lock, `{job_id}-{n}` for a
137
+ --- transaction's. Two sessions of one key on the same server hold
138
+ --- different ids, so they contend like sessions on different servers do.
118
139
  id: string,
119
140
  expires: number,
120
141
  --- Short-lived lock a transaction takes on a lockless key (see `lock_ttl`
@@ -139,10 +160,191 @@ export type DataRecord<T = any> = {
139
160
  migrations: { string },
140
161
  }
141
162
 
163
+ --[[
164
+ Errors and results
165
+ ]]
166
+
167
+ --- Every runtime failure is one of these tables (see `errors.luau`): `type`
168
+ --- tells them apart, `message` is human readable (`tostring(err)` returns
169
+ --- it), the rest is typed extra data. `profile` / `store` fields are typed
170
+ --- `any` on purpose: naming `ProfileBase` or `Store` here would put them in a
171
+ --- recursive generic group with `Result`, which Luau rejects.
172
+ export type RobloxError = {
173
+ type: "roblox",
174
+ message: string,
175
+ op: "get_async" | "update_async",
176
+ store: string,
177
+ key: string,
178
+ --- the last raw error the storage hook threw
179
+ cause: any,
180
+ }
181
+
182
+ export type TimeoutError = {
183
+ type: "timeout",
184
+ message: string,
185
+ key: string,
186
+ --- the last lock seen on the record while waiting; its `id` names the
187
+ --- acquisition holding it, which may be another session on this server
188
+ lock: Lock?,
189
+ }
190
+
191
+ export type LockLostError = {
192
+ type: "lock_lost",
193
+ message: string,
194
+ profile: any,
195
+ --- the lock now on the record, nil when it has none
196
+ lock: Lock?,
197
+ }
198
+
199
+ export type ReleasedError = {
200
+ type: "released",
201
+ message: string,
202
+ profile: any,
203
+ }
204
+
205
+ export type LocklessLockedError = {
206
+ type: "lockless_locked",
207
+ message: string,
208
+ profile: any,
209
+ --- the live session lock another server holds on the record
210
+ lock: Lock,
211
+ }
212
+
213
+ export type TxLockLostError = {
214
+ type: "tx_lock_lost",
215
+ message: string,
216
+ profile: any,
217
+ --- the transaction lock this profile held
218
+ expected: Lock,
219
+ --- the lock now on the record, nil when it has none
220
+ lock: Lock?,
221
+ }
222
+
223
+ --- The record lists more migrations than this server declares: a newer server
224
+ --- wrote it, so this one must not touch it (nothing is written, no lock taken).
225
+ export type OutdatedError = {
226
+ type: "outdated",
227
+ message: string,
228
+ key: string,
229
+ --- the profile the call ran on; nil for `store:load` and `store:peek`
230
+ profile: any?,
231
+ record_migrations: Array<string>,
232
+ declared_migrations: Array<string>,
233
+ }
234
+
235
+ --- The store's `predicate` returned false for a key's stored data: this server
236
+ --- must not work with that key. Nothing is written and no lock is taken.
237
+ export type PredicateFailedError = {
238
+ type: "predicate_failed",
239
+ message: string,
240
+ key: string,
241
+ --- the operation the predicate rejected
242
+ op: "load" | "save" | "fetch" | "peek",
243
+ --- the raw stored data the predicate rejected (nil when the key is empty)
244
+ data: any,
245
+ --- the profile the call ran on; nil for `store:load` and `store:peek`
246
+ profile: any?,
247
+ }
248
+
249
+ export type NotFetchedError = {
250
+ type: "not_fetched",
251
+ message: string,
252
+ profile: any,
253
+ }
254
+
255
+ export type ProfileClosedError = {
256
+ type: "profile_closed",
257
+ message: string,
258
+ profile: any,
259
+ reason: "unloaded" | "lock_lost",
260
+ }
261
+
262
+ --- A name in the record's applied migrations differs from the declared one
263
+ --- at `index`. A record with more migrations than declared is `outdated`.
264
+ export type MigrationMismatchError = {
265
+ type: "migration_mismatch",
266
+ message: string,
267
+ key: string,
268
+ --- the position that differs, and the declared / record's name there
269
+ index: number,
270
+ expected: string,
271
+ actual: string,
272
+ --- names the record has applied / this server declares
273
+ applied: Array<string>,
274
+ declared: Array<string>,
275
+ }
276
+
277
+ export type TxAbortedError = {
278
+ type: "tx_aborted",
279
+ message: string,
280
+ id: string,
281
+ --- the failure that made phase 1 give up; nil when the marker was already
282
+ --- resolved as aborted by another server
283
+ cause: Error?,
284
+ }
285
+
286
+ export type TxMarkerInvalidError = {
287
+ type: "tx_marker_invalid",
288
+ message: string,
289
+ id: string,
290
+ value: any,
291
+ }
292
+
293
+ export type StoreClosedError = {
294
+ type: "store_closed",
295
+ message: string,
296
+ store: any,
297
+ }
298
+
299
+ export type AlreadyLoadedError = {
300
+ type: "already_loaded",
301
+ message: string,
302
+ store: any,
303
+ key: string,
304
+ }
305
+
306
+ export type Error =
307
+ | RobloxError
308
+ | TimeoutError
309
+ | LockLostError
310
+ | ReleasedError
311
+ | LocklessLockedError
312
+ | TxLockLostError
313
+ | OutdatedError
314
+ | PredicateFailedError
315
+ | NotFetchedError
316
+ | ProfileClosedError
317
+ | MigrationMismatchError
318
+ | TxAbortedError
319
+ | TxMarkerInvalidError
320
+ | StoreClosedError
321
+ | AlreadyLoadedError
322
+
323
+ export type Ok<T> = {
324
+ success: true,
325
+ value: T,
326
+ --- Returns `value`.
327
+ unwrap: (self: any) -> T,
328
+ }
329
+
330
+ export type Err = {
331
+ success: false,
332
+ error: Error,
333
+ --- Throws `error` (the table itself; `tostring` gives its message).
334
+ --- Typed `any`: Luau cannot call a union of two function types, and this
335
+ --- keeps `result:unwrap()` callable on `Result<T>` while `success` still
336
+ --- narrows the union.
337
+ unwrap: any,
338
+ }
339
+
340
+ --- What every fallible library call returns. See `result.luau`.
341
+ export type Result<T> = Ok<T> | Err
342
+
142
343
  --- FIFO mutex for coroutines. See `mutex.luau`.
143
344
  export type Mutex = {
144
345
  _locked: boolean,
145
- _closed: string?,
346
+ --- The `Error` (a `profile_closed`) every lock is rejected with once closed.
347
+ _closed: any?,
146
348
  _waiters: { thread },
147
349
  _settlers: { thread },
148
350
 
@@ -151,7 +353,7 @@ export type Mutex = {
151
353
  wait_settled: (self: Mutex) -> (),
152
354
  lock: (self: Mutex) -> (),
153
355
  unlock: (self: Mutex) -> (),
154
- close: (self: Mutex, reason: string) -> (),
356
+ close: (self: Mutex, reason: any) -> (),
155
357
  is_locked: (self: Mutex) -> boolean,
156
358
  is_closed: (self: Mutex) -> boolean,
157
359
  with: (self: Mutex, fn: () -> ...any) -> any,
@@ -170,9 +372,9 @@ export type TxPatch = {
170
372
  --- Set once `prepare` succeeded (the record carries `pending`).
171
373
  prepared: boolean,
172
374
 
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,
375
+ --- Phase 1: persist `pending` next to the current data. Throws an `Error`
376
+ --- (`lock_lost`, `tx_lock_lost`, `lockless_locked`, `roblox`, ...) on failure.
377
+ prepare: (pending: Pending) -> (),
176
378
  --- Applies the outcome in memory. Called exactly once by the operation.
177
379
  commit: (operation: TxOperation) -> (),
178
380
  abort: (operation: TxOperation) -> (),
@@ -227,11 +429,14 @@ export type ProfileBase<T = any> = {
227
429
  --- Operations waiting for this profile to write a record without `pending`.
228
430
  _cleanup_txs: Array<TxOperation>,
229
431
 
230
- --- Current data. Throws if the profile is closed or (lockless) not fetched.
432
+ --- Current data. Throws an `Error` (`profile_closed`, or `not_fetched` for
433
+ --- a lockless profile) instead of returning a Result: it is a plain accessor.
231
434
  get_data: (self: ProfileBase<T>) -> T,
232
- save: (self: ProfileBase<T>) -> (),
435
+ save: (self: ProfileBase<T>) -> Result<nil>,
233
436
  unload: (self: ProfileBase<T>) -> (),
234
- update: (self: ProfileBase<T>, (T) -> T | false) -> boolean,
437
+ --- Ok(true) when the data changed, Ok(false) when the dispatcher returned
438
+ --- nil / false. An error thrown by the dispatcher propagates as is.
439
+ update: (self: ProfileBase<T>, dispatcher: DataDispatcher<T>) -> Result<boolean>,
235
440
 
236
441
  on_change: (self: ProfileBase<T>, callback: (new: T, old: T?) -> ()) -> (),
237
442
  on_save: (self: ProfileBase<T>, callback: () -> ()) -> (),
@@ -256,6 +461,9 @@ export type ProfileBase<T = any> = {
256
461
  export type Profile<T = any> = ProfileBase<T> & {
257
462
  kind: "locked",
258
463
  last_write: number,
464
+ --- The id of this acquisition's lock; re-stamped unchanged on every save,
465
+ --- replaced by `readquire`. See `Lock.id`.
466
+ lock_id: string,
259
467
  --- Whether the profile currently holds its session lock. True after `load`;
260
468
  --- `release` clears it, `readquire` takes it again. While released,
261
469
  --- `update` / `save` throw and the profile cannot join a transaction.
@@ -274,13 +482,21 @@ export type Profile<T = any> = ProfileBase<T> & {
274
482
 
275
483
  --- Writes the data and gives the session lock back, keeping the profile
276
484
  --- open so another server may take the key. No-op when already released.
277
- release: (self: Profile<T>) -> (),
485
+ release: (self: Profile<T>) -> Result<nil>,
278
486
  --- Takes the session lock again (waits like `load` does) and adopts the
279
487
  --- stored record as the current data. No-op when already locked.
280
- readquire: (self: Profile<T>) -> (),
281
-
282
- -- internal, used by transactions. Returns false when the lock is gone.
283
- _write_record: (self: Profile<T>, options: ProfileWriteOptions) -> boolean,
488
+ readquire: (self: Profile<T>) -> Result<nil>,
489
+
490
+ -- internal, used by transactions. Returns false (and the lock now on the
491
+ -- record) when the lock is gone.
492
+ _write_record: (self: Profile<T>, options: ProfileWriteOptions) -> (boolean, Lock?),
493
+
494
+ --- internal, set by the store: called once, as soon as the profile stops
495
+ --- being usable (an unload before its final write, or a lost lock), so the
496
+ --- key can be loaded again without waiting for that write.
497
+ _on_detach: (() -> ())?,
498
+ --- internal: whether `_on_detach` has run.
499
+ _detached: boolean,
284
500
  }
285
501
 
286
502
  export type ProfileWriteOptions = {
@@ -312,7 +528,7 @@ export type LocklessProfile<T = any> = ProfileBase<T> & {
312
528
  lock_lost: Array<() -> ()>,
313
529
  },
314
530
 
315
- fetch: (self: LocklessProfile<T>) -> T,
531
+ fetch: (self: LocklessProfile<T>) -> Result<T>,
316
532
 
317
533
  -- internal, used by transactions
318
534
  _write_queued: (self: LocklessProfile<T>, options: LocklessWriteOptions) -> (),
@@ -328,6 +544,7 @@ export type PeekProfile<T = any> = {
328
544
  _datastore: DatastoreHandle,
329
545
 
330
546
  --- Lock currently on the record (session or transaction), nil when free.
547
+ --- Its `id` names an acquisition, not a server (see `Lock.id`).
331
548
  lock: Lock?,
332
549
  --- Transaction marker on the record, if any. Left as stored, never resolved.
333
550
  pending: Pending<T>?,
@@ -340,7 +557,7 @@ export type PeekProfile<T = any> = {
340
557
  get_data: (self: PeekProfile<T>) -> T,
341
558
  --- Calls `GetAsync` again and replaces `lock`, `pending`, `migrations` and
342
559
  --- the data. Yields.
343
- refresh: (self: PeekProfile<T>) -> T,
560
+ refresh: (self: PeekProfile<T>) -> Result<T>,
344
561
  }
345
562
 
346
563
  export type LocklessWriteOptions = {
@@ -367,7 +584,9 @@ export type LoadOptions = {
367
584
  export type MigrationsResult = {
368
585
  applied: boolean,
369
586
  success: boolean,
370
- err: any,
587
+ --- `outdated` when the record lists more migrations than declared,
588
+ --- `migration_mismatch` when a name differs
589
+ err: (MigrationMismatchError | OutdatedError)?,
371
590
  result: any,
372
591
  new_migrations: { string },
373
592
  }
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