@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PepeElToro41
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/index.d.ts CHANGED
@@ -29,7 +29,7 @@ declare namespace dataforge {
29
29
 
30
30
  /** How a store reaches storage. */
31
31
  interface Hook {
32
- /** Identifies this server. Session locks and transaction ids carry it. */
32
+ /** Identifies this server. Session lock and transaction ids are built from it. */
33
33
  job_id: () => string;
34
34
  /** Returns the handle for a named store. Called once per store name. */
35
35
  get_datastore: (name: string) => DatastoreHandle;
@@ -115,16 +115,40 @@ declare namespace dataforge {
115
115
  Configuration
116
116
  */
117
117
 
118
- interface Migration {
118
+ /** A named migration whose output can be checked against a target schema. */
119
+ interface Migration<T = unknown> {
119
120
  name: string;
120
- apply: (data: unknown) => unknown;
121
+ /** Stored data may have an older shape; narrow it before reading fields. */
122
+ apply: (data: unknown) => T;
121
123
  }
122
124
 
125
+ /** Earlier steps may return intermediate shapes; the last must return T. */
126
+ type Migrations<T = unknown> = [] | [...Migration[], Migration<T>];
127
+
128
+ /**
129
+ * Decides whether this server may work with a key at all. `data` is the
130
+ * raw stored data as it is in storage: `undefined` when the key does not
131
+ * exist yet, and with the declared migrations *not* applied. Returning
132
+ * `false` rejects the operation with a `predicate_failed` error: no lock
133
+ * is taken and nothing is written.
134
+ *
135
+ * It runs inside the `update_async` transform, so it must be pure: no
136
+ * yielding, no storage calls, and no mutation of `data`.
137
+ */
138
+ type Predicate<T = unknown> = (data: T | undefined, key: string) => boolean;
139
+
123
140
  interface Config<T = unknown> {
124
141
  name: string;
125
142
  template: T;
126
143
 
127
- migrations?: Migration[];
144
+ /** The final output must match the template, rather than influence its inference. */
145
+ migrations?: Migrations<NoInfer<T>>;
146
+
147
+ /**
148
+ * Gate on a key's stored data: every load, save, fetch and peek is
149
+ * rejected with `predicate_failed` when it returns `false`.
150
+ */
151
+ predicate?: Predicate<T>;
128
152
 
129
153
  /** seconds, default 30 (locked profiles) */
130
154
  autosave_interval?: number;
@@ -142,8 +166,13 @@ declare namespace dataforge {
142
166
  retry_base?: number;
143
167
  }
144
168
 
145
- /** `Config<T>` with every optional field filled in. */
146
- type ResolvedConfig<T = unknown> = Required<Config<T>>;
169
+ /**
170
+ * `Config<T>` with every optional field filled in. `predicate` stays
171
+ * optional: a store without one gates nothing.
172
+ */
173
+ type ResolvedConfig<T = unknown> = Required<Omit<Config<T>, "predicate">> & {
174
+ predicate?: Predicate<T>;
175
+ };
147
176
 
148
177
  interface TransactionConfig {
149
178
  /**
@@ -167,6 +196,12 @@ declare namespace dataforge {
167
196
  */
168
197
 
169
198
  interface Lock {
199
+ /**
200
+ * Identifies the *acquisition* that holds the lock, not the server:
201
+ * `{job_id}#{n}` for a profile's session lock, `{job_id}-{n}` for a
202
+ * transaction's. Two sessions of one key on the same server hold
203
+ * different ids, so they contend like sessions on different servers do.
204
+ */
170
205
  id: string;
171
206
  expires: number;
172
207
  /**
@@ -197,6 +232,188 @@ declare namespace dataforge {
197
232
 
198
233
  type TxOutcome = "committed" | "aborted";
199
234
 
235
+ /*
236
+ Errors and results
237
+ */
238
+
239
+ interface ErrorBase {
240
+ readonly type: string;
241
+ /** Human readable; `tostring(err)` returns it. */
242
+ readonly message: string;
243
+ }
244
+
245
+ /** Storage kept failing after `retry_attempts`. */
246
+ interface RobloxError extends ErrorBase {
247
+ readonly type: "roblox";
248
+ readonly op: "get_async" | "update_async";
249
+ readonly store: string;
250
+ readonly key: string;
251
+ /** The last raw error the storage hook threw. */
252
+ readonly cause: unknown;
253
+ }
254
+
255
+ /** `load` / `readquire` gave up after `load_timeout`. */
256
+ interface TimeoutError extends ErrorBase {
257
+ readonly type: "timeout";
258
+ readonly key: string;
259
+ /** The last lock seen on the record (its holder). */
260
+ readonly lock: Lock | undefined;
261
+ }
262
+
263
+ /** A write found the session lock is not ours; the profile is closed. */
264
+ interface LockLostError extends ErrorBase {
265
+ readonly type: "lock_lost";
266
+ readonly profile: Profile<any>;
267
+ /** The lock now on the record, `undefined` when it was wiped. */
268
+ readonly lock: Lock | undefined;
269
+ }
270
+
271
+ /** The profile was released and not readquired. */
272
+ interface ReleasedError extends ErrorBase {
273
+ readonly type: "released";
274
+ readonly profile: Profile<any>;
275
+ }
276
+
277
+ /** Another server holds a live session lock on a lockless key; the profile is closed. */
278
+ interface LocklessLockedError extends ErrorBase {
279
+ readonly type: "lockless_locked";
280
+ readonly profile: LocklessProfile<any>;
281
+ readonly lock: Lock;
282
+ }
283
+
284
+ /** The transaction lock a lockless participant held is gone or replaced; the profile is closed. */
285
+ interface TxLockLostError extends ErrorBase {
286
+ readonly type: "tx_lock_lost";
287
+ readonly profile: LocklessProfile<any>;
288
+ /** The transaction lock this profile held. */
289
+ readonly expected: Lock;
290
+ /** The lock now on the record, `undefined` when none. */
291
+ readonly lock: Lock | undefined;
292
+ }
293
+
294
+ /**
295
+ * The record was written by a newer server (it lists more migrations than
296
+ * this store declares). Nothing is written and no lock is taken.
297
+ */
298
+ interface OutdatedError extends ErrorBase {
299
+ readonly type: "outdated";
300
+ readonly key: string;
301
+ /** The profile the call ran on; `undefined` for `store.load` and `store.peek`. */
302
+ readonly profile: AnyProfile<any> | undefined;
303
+ readonly record_migrations: string[];
304
+ readonly declared_migrations: string[];
305
+ }
306
+
307
+ /**
308
+ * `config.predicate` returned `false` for a key's stored data: this server
309
+ * must not work with that key. Nothing is written and no lock is taken.
310
+ */
311
+ interface PredicateFailedError extends ErrorBase {
312
+ readonly type: "predicate_failed";
313
+ readonly key: string;
314
+ /** The operation the predicate rejected. */
315
+ readonly op: "load" | "save" | "fetch" | "peek";
316
+ /** The raw stored data the predicate rejected (`undefined` when the key is empty). */
317
+ readonly data: unknown;
318
+ /** The profile the call ran on; `undefined` for `store.load` and `store.peek`. */
319
+ readonly profile: AnyProfile<any> | undefined;
320
+ }
321
+
322
+ /** Lockless `get_data` before any fetch / flush. Thrown, never returned. */
323
+ interface NotFetchedError extends ErrorBase {
324
+ readonly type: "not_fetched";
325
+ readonly profile: LocklessProfile<any>;
326
+ }
327
+
328
+ /** The profile is closed (or closed while the call waited for it). */
329
+ interface ProfileClosedError extends ErrorBase {
330
+ readonly type: "profile_closed";
331
+ readonly profile: AnyProfile<any>;
332
+ readonly reason: "unloaded" | "lock_lost";
333
+ }
334
+
335
+ /**
336
+ * A name in the record's applied migrations differs from the declared one
337
+ * at `index`. A record with more migrations than declared is `outdated`.
338
+ */
339
+ interface MigrationMismatchError extends ErrorBase {
340
+ readonly type: "migration_mismatch";
341
+ readonly key: string;
342
+ readonly index: number;
343
+ readonly expected: string;
344
+ readonly actual: string;
345
+ /** Names the record has applied / this store declares. */
346
+ readonly applied: string[];
347
+ readonly declared: string[];
348
+ }
349
+
350
+ /** The transaction aborted. */
351
+ interface TxAbortedError extends ErrorBase {
352
+ readonly type: "tx_aborted";
353
+ readonly id: string;
354
+ /**
355
+ * The error of the participant that failed phase 1; `undefined` when
356
+ * the marker was already resolved as aborted by another server.
357
+ */
358
+ readonly cause: DataforgeError | undefined;
359
+ }
360
+
361
+ /** The marker datastore holds something other than "committed" / "aborted". */
362
+ interface TxMarkerInvalidError extends ErrorBase {
363
+ readonly type: "tx_marker_invalid";
364
+ readonly id: string;
365
+ readonly value: unknown;
366
+ }
367
+
368
+ interface StoreClosedError extends ErrorBase {
369
+ readonly type: "store_closed";
370
+ readonly store: Store<any>;
371
+ }
372
+
373
+ interface AlreadyLoadedError extends ErrorBase {
374
+ readonly type: "already_loaded";
375
+ readonly store: Store<any>;
376
+ readonly key: string;
377
+ }
378
+
379
+ /** Every runtime failure the library reports. Narrow on `type`. */
380
+ type DataforgeError =
381
+ | RobloxError
382
+ | TimeoutError
383
+ | LockLostError
384
+ | ReleasedError
385
+ | LocklessLockedError
386
+ | TxLockLostError
387
+ | OutdatedError
388
+ | PredicateFailedError
389
+ | NotFetchedError
390
+ | ProfileClosedError
391
+ | MigrationMismatchError
392
+ | TxAbortedError
393
+ | TxMarkerInvalidError
394
+ | StoreClosedError
395
+ | AlreadyLoadedError;
396
+
397
+ interface Ok<T> {
398
+ readonly success: true;
399
+ readonly value: T;
400
+ /** Returns `value`. */
401
+ unwrap(): T;
402
+ }
403
+
404
+ interface Err {
405
+ readonly success: false;
406
+ readonly error: DataforgeError;
407
+ /** Throws `error` (the table itself). */
408
+ unwrap(): never;
409
+ }
410
+
411
+ /**
412
+ * What every fallible call returns. Programmer errors (bad config, misuse
413
+ * of a transaction) and errors thrown by your own callbacks still throw.
414
+ */
415
+ type Result<T> = Ok<T> | Err;
416
+
200
417
  /*
201
418
  Profiles
202
419
  */
@@ -216,16 +433,25 @@ declare namespace dataforge {
216
433
  readonly open: boolean;
217
434
  readonly migrations: string[];
218
435
 
219
- /** Current data. Throws if the profile is closed or (lockless) not fetched. */
436
+ /**
437
+ * Current data. A plain accessor: throws a `DataforgeError`
438
+ * (`profile_closed`, or `not_fetched` for a lockless profile).
439
+ */
220
440
  get_data(): T;
221
- save(): void;
441
+ /**
442
+ * Fails with `profile_closed`, `released`, `roblox`, `predicate_failed`,
443
+ * `lock_lost` (locked) or `lockless_locked`, `outdated`, ... (lockless).
444
+ */
445
+ save(): Result<void>;
222
446
  unload(): void;
223
447
  /**
224
448
  * Applies `dispatcher` to the current data. Returning `undefined` or
225
- * `false` leaves the data untouched (returns `false`); anything else
226
- * becomes the new data and is persisted on the next save.
449
+ * `false` leaves the data untouched (`Ok(false)`); anything else
450
+ * becomes the new data and is persisted on the next save (`Ok(true)`).
451
+ * Fails with `profile_closed` / `released`; an error thrown by the
452
+ * dispatcher propagates as is.
227
453
  */
228
- update(dispatcher: DataDispatcher<T>): boolean;
454
+ update(dispatcher: DataDispatcher<T>): Result<boolean>;
229
455
 
230
456
  on_change(callback: (newData: T, oldData: T | undefined) => void): void;
231
457
  on_save(callback: () => void): void;
@@ -252,11 +478,16 @@ declare namespace dataforge {
252
478
  interface Profile<T = unknown> extends ProfileBase<T> {
253
479
  readonly kind: "locked";
254
480
  readonly last_write: number;
481
+ /**
482
+ * The id of this acquisition's lock; re-stamped unchanged on every
483
+ * save, replaced by `readquire`. See `Lock.id`.
484
+ */
485
+ readonly lock_id: string;
255
486
  /**
256
487
  * Whether the profile currently holds its session lock. True after
257
488
  * `load`; `release` clears it, `readquire` takes it again. While
258
- * released, `update` / `save` throw and the profile cannot join a
259
- * transaction.
489
+ * released, `update` / `save` fail with `released` and the profile
490
+ * cannot join a transaction.
260
491
  */
261
492
  readonly is_locked: boolean;
262
493
 
@@ -266,13 +497,16 @@ declare namespace dataforge {
266
497
  /**
267
498
  * Writes the data and gives the session lock back, keeping the profile
268
499
  * open so another server may take the key. No-op when already released.
500
+ * Fails with `profile_closed`, `roblox`, `predicate_failed` or `lock_lost`.
269
501
  */
270
- release(): void;
502
+ release(): Result<void>;
271
503
  /**
272
504
  * Takes the session lock again (waits like `load` does) and adopts the
273
- * stored record as the current data. No-op when already locked.
505
+ * stored record as the current data. No-op when already locked. Fails
506
+ * with `timeout`, `profile_closed`, `roblox`, `outdated`,
507
+ * `predicate_failed`, `migration_mismatch`.
274
508
  */
275
- readquire(): void;
509
+ readquire(): Result<void>;
276
510
  }
277
511
 
278
512
  /** Profile without a session lock; updates are queued and folded into storage on save. */
@@ -286,9 +520,11 @@ declare namespace dataforge {
286
520
  /**
287
521
  * Reads the stored record into the local data: migrations are applied,
288
522
  * then the queued updates replayed on top. Marks the profile fetched.
289
- * Yields. Throws (keeping the queue) if a session lock is on the record.
523
+ * Yields. Fails (keeping the queue) with `lockless_locked` if a session
524
+ * lock is on the record, `outdated`, `predicate_failed`, `roblox`,
525
+ * `migration_mismatch`, `profile_closed`.
290
526
  */
291
- fetch(): T;
527
+ fetch(): Result<T>;
292
528
  }
293
529
 
294
530
  /** Either profile kind; narrow on `kind`. */
@@ -310,8 +546,12 @@ declare namespace dataforge {
310
546
  readonly migrations: string[];
311
547
 
312
548
  get_data(): T;
313
- /** Calls `GetAsync` again and replaces `lock`, `pending`, `migrations` and the data. Yields. */
314
- refresh(): T;
549
+ /**
550
+ * Calls `GetAsync` again and replaces `lock`, `pending`, `migrations`
551
+ * and the data. Yields. Fails with `roblox`, `predicate_failed`,
552
+ * `outdated` or `migration_mismatch`, keeping the previous snapshot.
553
+ */
554
+ refresh(): Result<T>;
315
555
  }
316
556
 
317
557
  /*
@@ -334,37 +574,57 @@ declare namespace dataforge {
334
574
  readonly profiles: ReadonlyMap<string, Profile<T>>;
335
575
  readonly lockless_profiles: ReadonlyMap<string, LocklessProfile<T>>;
336
576
 
337
- /** Takes the session lock for `key` and loads it. Yields. Throws when already loaded. */
338
- load(key: string, user_ids: number[]): Profile<T>;
339
- /** Returns the profile for `key`, waiting for it to load or loading it if nobody has yet. */
340
- wait_loaded(key: string, user_ids: number[]): Profile<T>;
577
+ /**
578
+ * Takes the session lock for `key` and loads it. Yields. A profile that
579
+ * is closing no longer counts as loaded: the load waits out its final
580
+ * write through the record lock instead of failing `already_loaded`.
581
+ *
582
+ * Fails with `store_closed`, `already_loaded` (a profile for `key` is
583
+ * still open on this store), `timeout`, `roblox`, `outdated`,
584
+ * `predicate_failed` (`config.predicate` rejected the stored data; no
585
+ * lock is taken), `migration_mismatch` or `tx_marker_invalid`.
586
+ */
587
+ load(key: string, user_ids: number[]): Result<Profile<T>>;
588
+ /**
589
+ * Returns the profile for `key`, waiting for it to load or loading it
590
+ * if nobody has yet. Waiters get the same result as the load.
591
+ */
592
+ wait_loaded(key: string, user_ids: number[]): Result<Profile<T>>;
341
593
  get_loaded(key: string): Profile<T> | undefined;
342
594
 
343
595
  /**
344
596
  * Returns the lockless profile for `key`, creating it if needed. Never
345
597
  * yields: a lockless profile does no storage work until `fetch`.
598
+ * Throws a `store_closed` error on a closed store.
346
599
  */
347
600
  get_lockless(key: string, user_ids: number[]): LocklessProfile<T>;
348
601
 
349
602
  /**
350
603
  * Read-only snapshot of `key` via `GetAsync`: the current data and lock.
351
604
  * Yields. The result is not tracked by the store, takes no lock and
352
- * cannot join a transaction.
605
+ * cannot join a transaction. Fails with `store_closed`, `roblox`,
606
+ * `outdated`, `predicate_failed` or `migration_mismatch`.
353
607
  */
354
- peek(key: string): PeekProfile<T>;
608
+ peek(key: string): Result<PeekProfile<T>>;
355
609
 
356
610
  /**
357
611
  * Atomically updates profiles of this store (locked or lockless).
358
612
  * `transform` gets their data in order and returns the new data in the
359
- * same order, or `false` to cancel. Returns `false` when cancelled,
360
- * `true` when committed; throws when aborted.
613
+ * same order, or `false` to cancel. `Ok(false)` when cancelled,
614
+ * `Ok(true)` when committed; fails like `dataforge.transaction`, plus
615
+ * `store_closed`.
361
616
  */
362
617
  transaction(
363
618
  profiles: ProfileBase<T>[],
364
619
  transform: (data: T[]) => T[] | false,
365
620
  config?: TransactionConfig,
366
- ): boolean;
621
+ ): Result<boolean>;
367
622
 
623
+ /**
624
+ * Fires for every data change of every profile of this store, including
625
+ * the data a key loads with (`oldData === undefined` marks that initial
626
+ * value).
627
+ */
368
628
  on_change(callback: (profile: AnyProfile<T>, newData: T, oldData: T | undefined) => void): void;
369
629
  on_save(callback: (profile: AnyProfile<T>) => void): void;
370
630
  on_closing(callback: (profile: AnyProfile<T>) => void): void;
@@ -390,14 +650,40 @@ declare namespace dataforge {
390
650
  * and lockless ones. `process` reads with `ctx.get(profile)` and stages
391
651
  * writes with `ctx.set(profile, newData)`; return `false` to cancel.
392
652
  *
393
- * Returns `false` when cancelled, `true` when committed. Throws when aborted.
394
- * `config` defaults its retry settings to the first profile's.
653
+ * `Ok(false)` when cancelled, `Ok(true)` when committed. Fails with
654
+ * `tx_aborted` (see its `cause`), `profile_closed`, `released`, or what
655
+ * flushing a lockless participant raised. Misuse and errors thrown by
656
+ * `process` throw. `config` defaults its retry settings to the first
657
+ * profile's.
395
658
  */
396
659
  function transaction(
397
660
  profiles: ProfileBase<any>[],
398
661
  process: (ctx: TxContext) => boolean | undefined,
399
662
  config?: TransactionConfig,
400
- ): boolean;
663
+ ): Result<boolean>;
664
+
665
+ /**
666
+ * Whether `value` is a dataforge error (what a failed `Result` carries and
667
+ * what the library throws internally). Lets a wrapper around library calls
668
+ * tell a library error from any other thrown value.
669
+ */
670
+ function is_error(value: unknown): boolean;
671
+
672
+ /**
673
+ * The `Result` helpers, so you can build library-shaped results in your own
674
+ * wrappers. `err` only accepts a dataforge error, so a malformed result
675
+ * cannot be built with it.
676
+ */
677
+ namespace result {
678
+ function ok<T>(value: T): Ok<T>;
679
+ function err(error: DataforgeError): Err;
680
+ /** Whether `value` is a `Result`. */
681
+ function is(value: unknown): boolean;
682
+ /** `Ok(value)` when `ok`, `Err` when `value` is a dataforge error, rethrown otherwise. */
683
+ function of<T>(ok: boolean, value: unknown): Result<T>;
684
+ /** `pcall(fn)` folded through `of`. */
685
+ function capture<T>(fn: () => T): Result<T>;
686
+ }
401
687
 
402
688
  namespace hooks {
403
689
  namespace memory {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rbxts/dataforge",
3
- "version": "0.1.0",
3
+ "version": "0.4.1",
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",
@@ -17,6 +17,9 @@
17
17
  ],
18
18
  "main": "src/init.luau",
19
19
  "types": "index.d.ts",
20
+ "scripts": {
21
+ "test:types": "tsc --project tests/types/tsconfig.json"
22
+ },
20
23
  "files": [
21
24
  "src",
22
25
  "index.d.ts",
@@ -27,6 +30,8 @@
27
30
  "access": "public"
28
31
  },
29
32
  "devDependencies": {
30
- "@rbxts/types": "^1.0.947"
33
+ "@rbxts/compiler-types": "3.0.0-types.0",
34
+ "@rbxts/types": "^1.0.947",
35
+ "typescript": "5.5.3"
31
36
  }
32
37
  }