@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/LICENSE +21 -0
- package/index.d.ts +318 -32
- package/package.json +7 -2
- package/src/errors.luau +211 -0
- package/src/hooks/memory.luau +1 -12
- package/src/hooks/roblox.luau +10 -1
- package/src/init.luau +20 -2
- package/src/mutex.luau +5 -5
- package/src/profile/common.luau +61 -12
- package/src/profile/lockless.luau +122 -51
- package/src/profile/peek.luau +30 -16
- package/src/profile/profile.luau +246 -116
- package/src/result.luau +61 -0
- package/src/store.luau +129 -44
- package/src/tx/handle.luau +82 -59
- package/src/tx/operation.luau +6 -3
- package/src/types.luau +236 -17
- package/src/utils.luau +37 -3
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
|
|
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
|
-
|
|
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:
|
|
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.
|
|
174
|
-
---
|
|
175
|
-
prepare: (pending: Pending) ->
|
|
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
|
|
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
|
-
|
|
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
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|