@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 +218 -26
- package/package.json +1 -1
- package/src/errors.luau +195 -0
- package/src/hooks/memory.luau +1 -12
- package/src/hooks/roblox.luau +10 -1
- package/src/init.luau +6 -2
- package/src/mutex.luau +5 -5
- package/src/profile/common.luau +41 -8
- package/src/profile/lockless.luau +102 -50
- package/src/profile/peek.luau +22 -16
- package/src/profile/profile.luau +156 -95
- package/src/result.luau +61 -0
- package/src/store.luau +89 -41
- package/src/tx/handle.luau +82 -59
- package/src/tx/operation.luau +6 -3
- package/src/types.luau +187 -16
- package/src/utils.luau +37 -3
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
|
-
/**
|
|
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
|
-
|
|
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 (
|
|
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`
|
|
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.
|
|
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
|
-
/**
|
|
314
|
-
|
|
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
|
-
/**
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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.
|
|
360
|
-
* `true` when committed;
|
|
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
|
-
*
|
|
394
|
-
* `
|
|
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.
|
|
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",
|
package/src/errors.luau
ADDED
|
@@ -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
|
package/src/hooks/memory.luau
CHANGED
|
@@ -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 =
|
|
105
|
+
local job_id = utils.uuid()
|
|
117
106
|
function self.job_id()
|
|
118
107
|
return job_id
|
|
119
108
|
end
|
package/src/hooks/roblox.luau
CHANGED
|
@@ -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
|
|
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,
|
|
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,
|
|
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
|
|
63
|
-
--- close keeps the first reason.
|
|
64
|
-
function mutex.close(self: Mutex, reason:
|
|
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
|