@rbxts/dataforge 0.2.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
  /**
@@ -269,6 +304,21 @@ declare namespace dataforge {
269
304
  readonly declared_migrations: string[];
270
305
  }
271
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
+
272
322
  /** Lockless `get_data` before any fetch / flush. Thrown, never returned. */
273
323
  interface NotFetchedError extends ErrorBase {
274
324
  readonly type: "not_fetched";
@@ -335,6 +385,7 @@ declare namespace dataforge {
335
385
  | LocklessLockedError
336
386
  | TxLockLostError
337
387
  | OutdatedError
388
+ | PredicateFailedError
338
389
  | NotFetchedError
339
390
  | ProfileClosedError
340
391
  | MigrationMismatchError
@@ -387,7 +438,10 @@ declare namespace dataforge {
387
438
  * (`profile_closed`, or `not_fetched` for a lockless profile).
388
439
  */
389
440
  get_data(): T;
390
- /** Fails with `profile_closed`, `released`, `roblox`, `lock_lost` (locked) or `lockless_locked`, `outdated`, ... (lockless). */
441
+ /**
442
+ * Fails with `profile_closed`, `released`, `roblox`, `predicate_failed`,
443
+ * `lock_lost` (locked) or `lockless_locked`, `outdated`, ... (lockless).
444
+ */
391
445
  save(): Result<void>;
392
446
  unload(): void;
393
447
  /**
@@ -424,6 +478,11 @@ declare namespace dataforge {
424
478
  interface Profile<T = unknown> extends ProfileBase<T> {
425
479
  readonly kind: "locked";
426
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;
427
486
  /**
428
487
  * Whether the profile currently holds its session lock. True after
429
488
  * `load`; `release` clears it, `readquire` takes it again. While
@@ -438,13 +497,14 @@ declare namespace dataforge {
438
497
  /**
439
498
  * Writes the data and gives the session lock back, keeping the profile
440
499
  * open so another server may take the key. No-op when already released.
441
- * Fails with `profile_closed`, `roblox` or `lock_lost`.
500
+ * Fails with `profile_closed`, `roblox`, `predicate_failed` or `lock_lost`.
442
501
  */
443
502
  release(): Result<void>;
444
503
  /**
445
504
  * Takes the session lock again (waits like `load` does) and adopts the
446
505
  * stored record as the current data. No-op when already locked. Fails
447
- * with `timeout`, `profile_closed`, `roblox`, `outdated`, `migration_mismatch`.
506
+ * with `timeout`, `profile_closed`, `roblox`, `outdated`,
507
+ * `predicate_failed`, `migration_mismatch`.
448
508
  */
449
509
  readquire(): Result<void>;
450
510
  }
@@ -461,7 +521,8 @@ declare namespace dataforge {
461
521
  * Reads the stored record into the local data: migrations are applied,
462
522
  * then the queued updates replayed on top. Marks the profile fetched.
463
523
  * Yields. Fails (keeping the queue) with `lockless_locked` if a session
464
- * lock is on the record, `outdated`, `roblox`, `migration_mismatch`, `profile_closed`.
524
+ * lock is on the record, `outdated`, `predicate_failed`, `roblox`,
525
+ * `migration_mismatch`, `profile_closed`.
465
526
  */
466
527
  fetch(): Result<T>;
467
528
  }
@@ -487,8 +548,8 @@ declare namespace dataforge {
487
548
  get_data(): T;
488
549
  /**
489
550
  * 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.
551
+ * and the data. Yields. Fails with `roblox`, `predicate_failed`,
552
+ * `outdated` or `migration_mismatch`, keeping the previous snapshot.
492
553
  */
493
554
  refresh(): Result<T>;
494
555
  }
@@ -514,9 +575,14 @@ declare namespace dataforge {
514
575
  readonly lockless_profiles: ReadonlyMap<string, LocklessProfile<T>>;
515
576
 
516
577
  /**
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`.
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`.
520
586
  */
521
587
  load(key: string, user_ids: number[]): Result<Profile<T>>;
522
588
  /**
@@ -536,8 +602,8 @@ declare namespace dataforge {
536
602
  /**
537
603
  * Read-only snapshot of `key` via `GetAsync`: the current data and lock.
538
604
  * Yields. The result is not tracked by the store, takes no lock and
539
- * cannot join a transaction. Fails with `store_closed`, `roblox`, `outdated`
540
- * or `migration_mismatch`.
605
+ * cannot join a transaction. Fails with `store_closed`, `roblox`,
606
+ * `outdated`, `predicate_failed` or `migration_mismatch`.
541
607
  */
542
608
  peek(key: string): Result<PeekProfile<T>>;
543
609
 
@@ -554,6 +620,11 @@ declare namespace dataforge {
554
620
  config?: TransactionConfig,
555
621
  ): Result<boolean>;
556
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
+ */
557
628
  on_change(callback: (profile: AnyProfile<T>, newData: T, oldData: T | undefined) => void): void;
558
629
  on_save(callback: (profile: AnyProfile<T>) => void): void;
559
630
  on_closing(callback: (profile: AnyProfile<T>) => void): void;
@@ -591,6 +662,29 @@ declare namespace dataforge {
591
662
  config?: TransactionConfig,
592
663
  ): Result<boolean>;
593
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
+ }
687
+
594
688
  namespace hooks {
595
689
  namespace memory {
596
690
  function create(scheduler: Scheduler): MemoryHook;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rbxts/dataforge",
3
- "version": "0.2.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
  }
package/src/errors.luau CHANGED
@@ -102,6 +102,22 @@ function errors.outdated(
102
102
  }
103
103
  end
104
104
 
105
+ function errors.predicate_failed(
106
+ key: string,
107
+ op: "load" | "save" | "fetch" | "peek",
108
+ data: any,
109
+ profile: any?
110
+ ): types.PredicateFailedError
111
+ return make {
112
+ type = "predicate_failed" :: "predicate_failed",
113
+ message = `datastore: the store predicate rejected the stored data of "{key}" (on {op})`,
114
+ key = key,
115
+ op = op,
116
+ data = data,
117
+ profile = profile,
118
+ }
119
+ end
120
+
105
121
  function errors.not_fetched(profile: any): types.NotFetchedError
106
122
  return make {
107
123
  type = "not_fetched" :: "not_fetched",
package/src/init.luau CHANGED
@@ -1,3 +1,5 @@
1
+ local errors = require "@self/errors"
2
+ local result = require "@self/result"
1
3
  local store = require "@self/store"
2
4
  local tx = require "@self/tx/handle"
3
5
  local types = require "@self/types"
@@ -20,6 +22,8 @@ export type ProfileBase<T = any> = types.ProfileBase<T>
20
22
  export type PeekProfile<T = any> = types.PeekProfile<T>
21
23
  export type Result<T> = types.Result<T>
22
24
  export type Error = types.Error
25
+ export type Migration = types.Migration
26
+ export type DataDispatcher<T = any> = types.DataDispatcher<T>
23
27
 
24
28
  local dataforge = {}
25
29
 
@@ -49,6 +53,16 @@ function dataforge.transaction(
49
53
  return tx.run(tx.resolve_config(config, defaults), profiles, process)
50
54
  end
51
55
 
56
+ --- Whether `value` is a dataforge `Error` table (what a failed Result carries
57
+ --- and what the library throws internally). Lets a wrapper around library calls
58
+ --- tell a library error from any other thrown value.
59
+ dataforge.is_error = errors.is
60
+
61
+ --- The `Result` helpers (`ok`, `err`, `is`, `of`, `capture`), so a game can
62
+ --- build library-shaped results in its own wrappers. `result.err` only accepts
63
+ --- an `Error`, so a malformed result cannot be fabricated with it.
64
+ dataforge.result = result
65
+
52
66
  dataforge.hooks = {
53
67
  memory = memory_hook,
54
68
  roblox = roblox_hook,
@@ -90,6 +90,18 @@ function common.is_outdated(record: types.DataRecord?, config: types.ResolvedCon
90
90
  return #record.migrations > #config.migrations
91
91
  end
92
92
 
93
+ function common.run_predicate(config: types.ResolvedConfig, key: string, data: any): (boolean, any?)
94
+ local predicate = config.predicate
95
+ if predicate == nil then
96
+ return true, nil
97
+ end
98
+ local ok, passed = pcall(predicate, data, key)
99
+ if not ok then
100
+ return false, passed
101
+ end
102
+ return not not passed, nil
103
+ end
104
+
93
105
  --- Rethrows a failed `apply_migrations` with the profile attached to an
94
106
  --- `outdated` error (a `migration_mismatch` has no profile field).
95
107
  function common.raise_migrations(profile: ProfileBase, migrated: MigrationsResult)
@@ -100,7 +112,9 @@ function common.raise_migrations(profile: ProfileBase, migrated: MigrationsResul
100
112
  error(err, 0)
101
113
  end
102
114
 
103
- function common.is_lock_owned(record: types.DataRecord?, job_id: string)
115
+ --- Whether `lock_id` (the id of one acquisition) is the lock currently on
116
+ --- the record, so this acquisition may write it.
117
+ function common.is_lock_owned(record: types.DataRecord?, lock_id: string)
104
118
  if not record then
105
119
  return false
106
120
  end
@@ -110,10 +124,12 @@ function common.is_lock_owned(record: types.DataRecord?, job_id: string)
110
124
  return false
111
125
  end
112
126
 
113
- return lock.id == job_id
127
+ return lock.id == lock_id
114
128
  end
115
129
 
116
- function common.is_locked_for(record: types.DataRecord?, job_id: string, now: number)
130
+ --- Whether a live lock of another acquisition holds the record, in which case
131
+ --- `lock_id` must wait it out or steal it once it expires.
132
+ function common.is_locked_for(record: types.DataRecord?, lock_id: string, now: number)
117
133
  if not record then
118
134
  return false
119
135
  end
@@ -122,7 +138,7 @@ function common.is_locked_for(record: types.DataRecord?, job_id: string, now: nu
122
138
  return false
123
139
  end
124
140
 
125
- return lock.id ~= job_id and lock.expires > now
141
+ return lock.id ~= lock_id and lock.expires > now
126
142
  end
127
143
 
128
144
  function common.has_lock(record: types.DataRecord?)
@@ -146,8 +146,9 @@ type WriteOptions = types.LocklessWriteOptions
146
146
  ---
147
147
  --- Throws `lockless_locked` (a live session lock; the profile is closed),
148
148
  --- `tx_lock_lost` (closed too), `outdated` (a newer server wrote the record;
149
- --- nothing written), `migration_mismatch`, `roblox`, or whatever a queued
150
- --- dispatcher / migration threw.
149
+ --- nothing written), `predicate_failed` (the store's predicate rejected the
150
+ --- stored data; nothing written), `migration_mismatch`, `roblox`, or whatever
151
+ --- a queued dispatcher / migration threw.
151
152
  local function write_queued(self: LocklessProfile, options: WriteOptions)
152
153
  local config = self.config
153
154
  local scheduler = config._scheduler
@@ -205,6 +206,14 @@ local function write_queued(self: LocklessProfile, options: WriteOptions)
205
206
  return nil
206
207
  end
207
208
 
209
+ -- the store's predicate gates the key: a rejected record is not written
210
+ -- to, and no `pending` of it is resolved
211
+ local stored = if old then old.data else nil
212
+ local passed, predicate_failure = common.run_predicate(config, self.key, stored)
213
+ if not passed then
214
+ error(predicate_failure or errors.predicate_failed(self.key, "save", stored, self), 0)
215
+ end
216
+
208
217
  -- a `pending` left by some transaction (ours after commit, or another
209
218
  -- server's) decides which data this write builds on
210
219
  local base: any = nil
@@ -341,7 +350,8 @@ end
341
350
  --- refreshes the local data with the result. Marks the profile fetched.
342
351
  --- Fails (keeping the queue) with `lockless_locked` if another server holds a
343
352
  --- live session lock (the profile is closed), `outdated` if the record was
344
- --- already written by a newer server, `migration_mismatch`, `roblox` or
353
+ --- already written by a newer server, `predicate_failed` if the store's
354
+ --- predicate rejected the stored data, `migration_mismatch`, `roblox` or
345
355
  --- `profile_closed`. An error thrown by a queued dispatcher propagates as is.
346
356
  function lockless_profile.save(self: LocklessProfile): types.Result<nil>
347
357
  return result.capture(function()
@@ -417,7 +427,8 @@ end
417
427
  --- Reads the stored record (get_async) into the local data: migrations are
418
428
  --- applied, then the queued updates replayed on top, so `get_data()` reflects the
419
429
  --- local changes still waiting for a save. Marks the profile fetched. Throws
420
- --- (keeping the queue) if any session lock is on the record. Caller holds the mutex.
430
+ --- (keeping the queue) if any session lock is on the record, or if the store's
431
+ --- predicate rejects the stored data. Caller holds the mutex.
421
432
  local function read_record(self: LocklessProfile)
422
433
  local config = self.config
423
434
  local datastore = self._datastore
@@ -433,6 +444,13 @@ local function read_record(self: LocklessProfile)
433
444
  -- a transaction lock does not matter for a read: the data under it is the
434
445
  -- data until that transaction's `pending` lands (handled below)
435
446
 
447
+ local stored = if record then record.data else nil
448
+ local passed, predicate_failure = common.run_predicate(config, self.key, stored)
449
+ if not passed then
450
+ -- nothing is adopted: the profile keeps whatever it had and stays open
451
+ error(predicate_failure or errors.predicate_failed(self.key, "fetch", stored, self), 0)
452
+ end
453
+
436
454
  -- a `pending` on the record: find out whether it committed (the record is
437
455
  -- left as is; the next write folds it in)
438
456
  local base: any = nil
@@ -455,7 +473,8 @@ local function read_record(self: LocklessProfile)
455
473
  end
456
474
 
457
475
  --- Fails with `lockless_locked` (a session lock is on the record; the profile
458
- --- is closed), `outdated` (a newer server wrote the record), `migration_mismatch`,
476
+ --- is closed), `outdated` (a newer server wrote the record), `predicate_failed`
477
+ --- (the store's predicate rejected the stored data), `migration_mismatch`,
459
478
  --- `roblox`, `tx_marker_invalid` or `profile_closed`. An error thrown by a
460
479
  --- queued dispatcher propagates as is.
461
480
  function lockless_profile.fetch(self: LocklessProfile): types.Result<any>
@@ -1,4 +1,5 @@
1
1
  local common = require "./common"
2
+ local errors = require "../errors"
2
3
  local result = require "../result"
3
4
  local types = require "../types"
4
5
  local utils = require "../utils"
@@ -18,13 +19,20 @@ peek_profile.__index = peek_profile
18
19
  --- Reads the record with `GetAsync` and adopts it: `lock` and `pending` are
19
20
  --- taken as stored, the data gets the declared migrations applied in memory
20
21
  --- (nothing is written). A missing key resolves to the template. Throws
21
- --- `roblox`, `outdated` or `migration_mismatch`.
22
+ --- `roblox`, `predicate_failed`, `outdated` or `migration_mismatch`.
22
23
  local function read(self: PeekProfile): any
23
24
  local config = self._config
24
25
  local datastore = self._datastore
25
26
 
26
27
  local record = utils.get_async(config, datastore, self.key) :: DataRecord?
27
28
 
29
+ -- before anything is adopted: a rejected key leaves the snapshot untouched
30
+ local stored = if record then record.data else nil
31
+ local passed, predicate_failure = common.run_predicate(config, self.key, stored)
32
+ if not passed then
33
+ error(predicate_failure or errors.predicate_failed(self.key, "peek", stored), 0)
34
+ end
35
+
28
36
  self.lock = if record then record.lock else nil
29
37
  self.pending = if record then record.pending else nil
30
38
 
@@ -52,15 +60,15 @@ function peek_profile.get_data(self: PeekProfile)
52
60
  end
53
61
 
54
62
  --- Calls `get_async` again and replaces the snapshot. Yields. Fails with
55
- --- `roblox`, `outdated` or `migration_mismatch`, leaving the previous snapshot
56
- --- in place.
63
+ --- `roblox`, `predicate_failed`, `outdated` or `migration_mismatch`, leaving
64
+ --- the previous snapshot in place.
57
65
  function peek_profile.refresh(self: PeekProfile): types.Result<any>
58
66
  return result.capture(read, self)
59
67
  end
60
68
 
61
69
  --- Takes the first snapshot right away (yields). The profile has no mutex, no
62
70
  --- loops and no hooks: it is a plain value that can be refreshed. Fails with
63
- --- `roblox`, `outdated` or `migration_mismatch`.
71
+ --- `roblox`, `predicate_failed`, `outdated` or `migration_mismatch`.
64
72
  function peek_profile.create(options: PeekOptions): types.Result<PeekProfile>
65
73
  local self = {} :: PeekProfile
66
74
  self.kind = "peek"
@@ -15,12 +15,22 @@ export type LoadOptions = types.LoadOptions
15
15
  local profile = {}
16
16
  profile.__index = profile
17
17
 
18
- local function adquire_lock(load_options: LoadOptions): types.DataRecord
18
+ --- One counter per process: a lock id must be unique per acquisition, not per
19
+ --- server, so two sessions of the same key on this server can tell their locks
20
+ --- apart. `#` keeps them out of the transaction id namespace (`{job_id}-{n}`),
21
+ --- which lockless writes compare by id.
22
+ local lock_counter = utils.counter()
23
+
24
+ --- Takes the session lock on `key`, waiting out (or stealing by expiry) any
25
+ --- holder. Returns the locked record and the id this acquisition stamped on it.
26
+ local function adquire_lock(load_options: LoadOptions): (types.DataRecord, string)
19
27
  local config = load_options.config
20
28
  local user_ids = load_options.user_ids
21
29
  local datastore = load_options._datastore
22
- local job_id = config._hook.job_id()
23
30
  local key = load_options.key
31
+ -- minted once per acquisition, before the loop: waiting may write the lock
32
+ -- several times and every attempt must stamp the same id
33
+ local lock_id = `{config._hook.job_id()}#{lock_counter()}`
24
34
 
25
35
  local scheduler = config._scheduler
26
36
 
@@ -30,6 +40,9 @@ local function adquire_lock(load_options: LoadOptions): types.DataRecord
30
40
  -- set by the transform: the record lists more migrations than this server
31
41
  -- declares, so the lock must not be taken (the write is cancelled)
32
42
  local outdated_migrations: { string }? = nil
43
+ -- set by the transform: the error the store's predicate rejected the record
44
+ -- with (or the one the predicate itself threw); the lock is not taken
45
+ local predicate_error: any = nil
33
46
 
34
47
  while true do
35
48
  local now = scheduler.now()
@@ -39,11 +52,20 @@ local function adquire_lock(load_options: LoadOptions): types.DataRecord
39
52
  key,
40
53
  function(old: types.DataRecord?): types.DataRecord?
41
54
  outdated_migrations = nil
55
+ predicate_error = nil
42
56
  if common.is_outdated(old, config) then
43
57
  outdated_migrations = (old :: types.DataRecord).migrations
44
58
  return nil
45
59
  end
46
- if common.is_locked_for(old :: types.DataRecord, job_id, now) then
60
+ -- before the lock check on purpose: a key this server must not
61
+ -- own is rejected right away instead of waiting out its holder
62
+ local stored = if old then old.data else nil
63
+ local passed, failure = common.run_predicate(config, key, stored)
64
+ if not passed then
65
+ predicate_error = failure or errors.predicate_failed(key, "load", stored)
66
+ return nil
67
+ end
68
+ if common.is_locked_for(old :: types.DataRecord, lock_id, now) then
47
69
  return nil
48
70
  end
49
71
  local record: types.DataRecord = old or {} :: types.DataRecord
@@ -53,7 +75,7 @@ local function adquire_lock(load_options: LoadOptions): types.DataRecord
53
75
  pending = record.pending,
54
76
  migrations = record.migrations or {},
55
77
  lock = {
56
- id = job_id,
78
+ id = lock_id,
57
79
  expires = now + config.lock_ttl,
58
80
  },
59
81
  }
@@ -64,8 +86,11 @@ local function adquire_lock(load_options: LoadOptions): types.DataRecord
64
86
  if outdated_migrations ~= nil then
65
87
  error(errors.outdated(key, outdated_migrations, common.all_migration_names(config.migrations)), 0)
66
88
  end
67
- if common.is_lock_owned(lock_applied, job_id) then
68
- return lock_applied
89
+ if predicate_error ~= nil then
90
+ error(predicate_error, 0)
91
+ end
92
+ if common.is_lock_owned(lock_applied, lock_id) then
93
+ return lock_applied, lock_id
69
94
  end
70
95
  last_lock = if lock_applied then lock_applied.lock else nil
71
96
 
@@ -78,7 +103,7 @@ local function adquire_lock(load_options: LoadOptions): types.DataRecord
78
103
  local current = utils.get_async(config, datastore, key) :: types.DataRecord?
79
104
  last_lock = if current then current.lock else nil
80
105
 
81
- if not common.is_locked_for(current, job_id, scheduler.now()) then
106
+ if not common.is_locked_for(current, lock_id, scheduler.now()) then
82
107
  break
83
108
  end
84
109
  end
@@ -130,6 +155,18 @@ local function set_data(self: Profile, data: any)
130
155
  fire_hooks(self, self._hooks.change, new, old)
131
156
  end
132
157
 
158
+ --- Lets the store drop the key as soon as the profile stops being usable,
159
+ --- without waiting for the final write to land. Idempotent: a lost lock
160
+ --- followed by an unload must only detach once.
161
+ local function detach(self: Profile)
162
+ local on_detach = self._on_detach
163
+ if on_detach == nil or self._detached then
164
+ return
165
+ end
166
+ self._detached = true
167
+ on_detach()
168
+ end
169
+
133
170
  --- Another server took the key: the profile closes without a release write.
134
171
  --- Runs inside a write (mutex held by the caller), so it only closes the mutex:
135
172
  --- the holder finishes, everything waiting on it is rejected.
@@ -138,6 +175,7 @@ local function process_lock_lost(self: Profile)
138
175
  return
139
176
  end
140
177
  self.open = false
178
+ detach(self)
141
179
  self._mutex:close(errors.profile_closed(self, "lock_lost"))
142
180
  fire_hooks(self, self._hooks.lock_lost)
143
181
  fire_hooks(self, self._hooks.closed)
@@ -151,23 +189,37 @@ type WriteOptions = {
151
189
  --- Writes the data (and `pending`, if any) under our session lock, refreshing
152
190
  --- or releasing it. Returns true when written; false and the lock now on the
153
191
  --- record (nil when none) when the lock is not ours any more, in which case
154
- --- the profile is closed. Throws a `roblox` error when storage fails.
192
+ --- the profile is closed. Throws a `roblox` error when storage fails, or
193
+ --- `predicate_failed` when the store's predicate rejected the stored data
194
+ --- (nothing is written and the lock is kept).
155
195
  local function write_data_record(self: Profile, options: WriteOptions): (boolean, types.Lock?)
156
196
  local config = self.config
157
197
  local scheduler = config._scheduler
158
198
  local datastore = self._datastore
159
199
  local now = scheduler.now()
160
- local job_id = config._hook.job_id()
200
+ -- this acquisition's id, not the job id: another session of this key on this
201
+ -- server holds a different lock and must not be written over
202
+ local lock_id = self.lock_id
161
203
 
162
204
  -- set by the transform: whether the write went through (the transform may
163
205
  -- run more than once when the storage retries it), and the lock it found
164
206
  local applied = false
165
207
  local seen_lock: types.Lock? = nil
208
+ -- set by the transform: the store's predicate rejected the stored data (or
209
+ -- threw); the write is cancelled without the lock being lost
210
+ local predicate_error: any = nil
166
211
 
167
212
  local function transform(old: any?): DataRecord?
168
213
  applied = false
214
+ predicate_error = nil
169
215
  seen_lock = if old then old.lock else nil
170
- if not common.is_lock_owned(old, job_id) then
216
+ if not common.is_lock_owned(old, lock_id) then
217
+ return nil
218
+ end
219
+ local stored = if old then old.data else nil
220
+ local passed, failure = common.run_predicate(config, self.key, stored)
221
+ if not passed then
222
+ predicate_error = failure or errors.predicate_failed(self.key, "save", stored, self)
171
223
  return nil
172
224
  end
173
225
  applied = true
@@ -175,7 +227,9 @@ local function write_data_record(self: Profile, options: WriteOptions): (boolean
175
227
  local new_lock: types.Lock? = nil
176
228
  if not options.release then
177
229
  new_lock = {
178
- id = job_id,
230
+ -- the same id every time: a fresh one would look like a new lock
231
+ -- to everyone else
232
+ id = lock_id,
179
233
  expires = now + config.lock_ttl,
180
234
  tx = nil,
181
235
  } :: types.Lock
@@ -202,6 +256,12 @@ local function write_data_record(self: Profile, options: WriteOptions): (boolean
202
256
  error(err, 0)
203
257
  end
204
258
 
259
+ if predicate_error ~= nil then
260
+ -- the predicate cancelled the write, not a lost lock: the profile keeps
261
+ -- its lock and stays open, so the next save may go through again
262
+ error(predicate_error, 0)
263
+ end
264
+
205
265
  if not applied then
206
266
  -- the transform bailed out: the lock is not ours any more
207
267
  process_lock_lost(self)
@@ -222,16 +282,16 @@ local function write_data_record(self: Profile, options: WriteOptions): (boolean
222
282
  return true
223
283
  end
224
284
 
225
- --- Gives the lock taken by `adquire_lock` back, leaving the record otherwise
226
- --- untouched. Used when the load fails after locking; a failure here is only
227
- --- warned about (the lock expires on its own).
228
- local function release_lock(load_options: LoadOptions)
285
+ --- Gives the lock `lock_id` (taken by `adquire_lock`) back, leaving the record
286
+ --- otherwise untouched. Used when the load fails after locking; a failure here
287
+ --- is only warned about (the lock expires on its own). Takes the acquisition id
288
+ --- so a failed load cannot clear a lock another local acquisition just took.
289
+ local function release_lock(load_options: LoadOptions, lock_id: string)
229
290
  local config = load_options.config
230
- local job_id = config._hook.job_id()
231
291
 
232
292
  local ok, err = utils.try(function()
233
293
  utils.update_async(config, load_options._datastore, load_options.key, function(old: DataRecord?): DataRecord?
234
- if not common.is_lock_owned(old, job_id) then
294
+ if not common.is_lock_owned(old, lock_id) then
235
295
  return nil
236
296
  end
237
297
  local record = old :: DataRecord
@@ -298,8 +358,8 @@ end
298
358
 
299
359
  --- Takes the session lock and resolves the locked record into data. If the
300
360
  --- resolution fails the lock is given back before rethrowing.
301
- local function adquire_and_resolve(load_options: LoadOptions): (any, { string }, boolean)
302
- local record = adquire_lock(load_options)
361
+ local function adquire_and_resolve(load_options: LoadOptions): (any, { string }, boolean, string)
362
+ local record, lock_id = adquire_lock(load_options)
303
363
  local config = load_options.config
304
364
 
305
365
  -- from here on the key is locked: anything failing must give it back
@@ -309,10 +369,10 @@ local function adquire_and_resolve(load_options: LoadOptions): (any, { string },
309
369
  config._hook.warn(
310
370
  `datastore: load of "{load_options.key}" failed after taking its lock, releasing it: {tostring(err)}`
311
371
  )
312
- release_lock(load_options)
372
+ release_lock(load_options, lock_id)
313
373
  error(err, 0)
314
374
  end
315
- return data, new_migrations, dirty
375
+ return data, new_migrations, dirty, lock_id
316
376
  end
317
377
 
318
378
  local function load_options_of(self: Profile): LoadOptions
@@ -331,7 +391,7 @@ local function assert_locked(self: Profile)
331
391
  end
332
392
 
333
393
  function profile.load(load_options: LoadOptions): Profile
334
- local data, new_migrations, dirty = adquire_and_resolve(load_options)
394
+ local data, new_migrations, dirty, lock_id = adquire_and_resolve(load_options)
335
395
  local config = load_options.config
336
396
 
337
397
  local self: Profile = {} :: any
@@ -341,6 +401,8 @@ function profile.load(load_options: LoadOptions): Profile
341
401
  self._datastore = load_options._datastore
342
402
  self.open = true
343
403
  self.is_locked = true
404
+ self.lock_id = lock_id
405
+ self._detached = false
344
406
 
345
407
  self.config = load_options.config
346
408
  self.migrations = new_migrations
@@ -416,8 +478,9 @@ function profile.on_lock_lost(self: Profile, callback: () -> ())
416
478
  end
417
479
 
418
480
  --- Writes the data, refreshing the session lock. Fails with `profile_closed`,
419
- --- `released`, `roblox`, or `lock_lost` (another server took the key; the
420
- --- profile is closed).
481
+ --- `released`, `roblox`, `predicate_failed` (the store's predicate rejected
482
+ --- the stored data; nothing is written and the lock is kept), or `lock_lost`
483
+ --- (another server took the key; the profile is closed).
421
484
  function profile.save(self: Profile): types.Result<nil>
422
485
  return result.capture(function()
423
486
  common.assert_open(self)
@@ -441,8 +504,8 @@ end
441
504
  --- Writes the data and gives the session lock back. The profile stays open
442
505
  --- (another server may load the key meanwhile); `update` and `save` fail with
443
506
  --- `released` until `readquire`. No-op when already released. Fails with
444
- --- `profile_closed`, `roblox` (the lock stays on the record, `is_locked`
445
- --- stays true) or `lock_lost` (the profile is closed).
507
+ --- `profile_closed`, `roblox` or `predicate_failed` (the lock stays on the
508
+ --- record, `is_locked` stays true), or `lock_lost` (the profile is closed).
446
509
  function profile.release(self: Profile): types.Result<nil>
447
510
  return result.capture(function()
448
511
  common.assert_open(self)
@@ -468,8 +531,8 @@ end
468
531
  --- Takes the session lock again, waiting for a holder like `load` does, and
469
532
  --- adopts the stored record (resolved pending, migrations) as the current
470
533
  --- data. Fails with `timeout` (the profile stays open and released),
471
- --- `profile_closed`, `roblox`, `outdated`, `migration_mismatch` or `tx_marker_invalid`. No-op when
472
- --- already locked.
534
+ --- `profile_closed`, `roblox`, `outdated`, `predicate_failed`,
535
+ --- `migration_mismatch` or `tx_marker_invalid`. No-op when already locked.
473
536
  function profile.readquire(self: Profile): types.Result<nil>
474
537
  return result.capture(function()
475
538
  common.assert_open(self)
@@ -481,7 +544,7 @@ function profile.readquire(self: Profile): types.Result<nil>
481
544
  if self.is_locked then
482
545
  return
483
546
  end
484
- local ok, data, new_migrations, dirty = pcall(adquire_and_resolve, load_options_of(self))
547
+ local ok, data, new_migrations, dirty, lock_id = pcall(adquire_and_resolve, load_options_of(self))
485
548
  if not ok then
486
549
  local err: any = data
487
550
  if errors.is(err) and err.type == "outdated" then
@@ -491,6 +554,9 @@ function profile.readquire(self: Profile): types.Result<nil>
491
554
  end
492
555
 
493
556
  self.migrations = new_migrations
557
+ -- a fresh acquisition, so a fresh id (the released profile kept the
558
+ -- old one; nothing could be written with it while released)
559
+ self.lock_id = lock_id
494
560
  set_data(self, data)
495
561
  self._dirty = dirty
496
562
  self.last_write = self.config._scheduler.now()
@@ -511,6 +577,9 @@ function profile.unload(self: Profile)
511
577
  end
512
578
 
513
579
  self.open = false
580
+ -- before the release write (which may take seconds under throttling): the
581
+ -- key is free to be loaded again, the new load waits on the record lock
582
+ detach(self)
514
583
  self._mutex:lock()
515
584
  self._mutex:close(errors.profile_closed(self, "unloaded"))
516
585
  local ok, err = utils.try(function()
package/src/store.luau CHANGED
@@ -48,8 +48,9 @@ export type Store<T = any> = {
48
48
  config: ResolvedConfig<T>,
49
49
  --- Takes the session lock for `key` and loads it. Yields. Fails with
50
50
  --- `store_closed`, `already_loaded`, `timeout`, `roblox`, `outdated` (a
51
- --- newer server wrote the record; no lock is taken), `migration_mismatch`
52
- --- or `tx_marker_invalid`.
51
+ --- newer server wrote the record; no lock is taken), `predicate_failed`
52
+ --- (`config.predicate` rejected the stored data; no lock is taken),
53
+ --- `migration_mismatch` or `tx_marker_invalid`.
53
54
  load: (self: Store<T>, key: string, user_ids: { number }) -> Result<Profile<T>>,
54
55
  --- Returns the profile for `key`, waiting for it to load or loading it if
55
56
  --- nobody has yet. Waiters get the same Result as the load.
@@ -63,8 +64,8 @@ export type Store<T = any> = {
63
64
 
64
65
  --- Read-only snapshot of `key` via `GetAsync`: the current data and lock.
65
66
  --- Yields. The result is not tracked by the store, takes no lock and cannot
66
- --- join a transaction. Fails with `store_closed`, `roblox`, `outdated` or
67
- --- `migration_mismatch`.
67
+ --- join a transaction. Fails with `store_closed`, `roblox`, `outdated`,
68
+ --- `predicate_failed` or `migration_mismatch`.
68
69
  peek: (self: Store<T>, key: string) -> Result<PeekProfile<T>>,
69
70
 
70
71
  --- Atomically updates profiles of this store (locked or lockless). `transform`
@@ -79,6 +80,8 @@ export type Store<T = any> = {
79
80
  config: types.TransactionConfig?
80
81
  ) -> Result<boolean>,
81
82
 
83
+ --- Fires for every data change of every profile of this store, including
84
+ --- the data a key loads with (`old == nil` marks that initial value).
82
85
  on_change: (self: Store<T>, callback: (profile: Profile<T>, new: T, old: T?) -> ()) -> (),
83
86
  on_save: (self: Store<T>, callback: (profile: Profile<T>) -> ()) -> (),
84
87
  on_closing: (self: Store<T>, callback: (profile: Profile<T>) -> ()) -> (),
@@ -113,6 +116,9 @@ local function insert_loaded_lock(self: Store, key: string)
113
116
  table.insert(waiters, thread)
114
117
  end
115
118
 
119
+ --- Drops `profile` from the registry, unless the key has already been taken by
120
+ --- a newer one: with instant detach a new profile may be registered while the
121
+ --- old one is still writing, and the old one's late unregister must not evict it.
116
122
  local function unregister_profile(self: Store, profile: Profile)
117
123
  local key = profile.key
118
124
  if self.profiles[key] == profile then
@@ -158,6 +164,10 @@ function store.create<T>(config: Config<T>): Store<T>
158
164
  assert(type(config) == "table", "datastore: config must be a table")
159
165
  assert(type(config.name) == "string" and #config.name > 0, "datastore: config.name is required")
160
166
  assert(config.template ~= nil, "datastore: config.template is required")
167
+ assert(
168
+ config.predicate == nil or type(config.predicate) == "function",
169
+ "datastore: config.predicate must be a function"
170
+ )
161
171
 
162
172
  local lock_ttl = config.lock_ttl or 60
163
173
  local hook = config._hook
@@ -181,6 +191,7 @@ function store.create<T>(config: Config<T>): Store<T>
181
191
  _scheduler = scheduler,
182
192
 
183
193
  migrations = config.migrations or {},
194
+ predicate = config.predicate,
184
195
 
185
196
  autosave_interval = config.autosave_interval or 30,
186
197
  flush_interval = config.flush_interval or config.autosave_interval or 30,
@@ -219,7 +230,10 @@ function store.load(self: Store, key: string, user_ids: { number }): Result<Prof
219
230
  if self.closed then
220
231
  return result.err(errors.store_closed(self))
221
232
  end
222
- if self.profiles[key] ~= nil or self._loading[key] then
233
+ -- a closing profile detaches before its final write, so it is normally gone
234
+ -- from the registry already; the `open` check is belt and braces
235
+ local existing = self.profiles[key]
236
+ if (existing ~= nil and existing.open) or self._loading[key] then
223
237
  return result.err(errors.already_loaded(self, key))
224
238
  end
225
239
  self._loading[key] = true
@@ -252,12 +266,24 @@ function store.load(self: Store, key: string, user_ids: { number }): Result<Prof
252
266
  return failure
253
267
  end
254
268
 
269
+ -- dropped from the registry the moment it stops being usable, so the key can
270
+ -- be loaded again while the old session's final write is still in flight
271
+ loaded._on_detach = function()
272
+ unregister_profile(self, loaded)
273
+ end
274
+ -- safety net: a profile that closes some other way still unregisters
255
275
  loaded:on_closed(function()
256
276
  unregister_profile(self, loaded)
257
277
  end)
258
278
  forward_hooks(self, loaded)
259
279
 
260
280
  self.profiles[key] = loaded
281
+ -- a load's initial data never reaches the profile's own hooks (they are
282
+ -- attached after `profile.load` set it), so report it here: `old == nil`
283
+ -- marks a key that just appeared. Lockless profiles need no equivalent:
284
+ -- `get_lockless` forwards their hooks before any `fetch` sets data.
285
+ fire_hooks(self, self._hooks.change, loaded, loaded:get_data(), nil)
286
+
261
287
  local success = result.ok(loaded)
262
288
  resume_load_locks(self, key, success)
263
289
  return success
@@ -424,10 +450,21 @@ function store.close(self: Store)
424
450
  end)
425
451
  end
426
452
 
453
+ -- snapshots: `unload` spawns a thread that runs up to its first yield right
454
+ -- away, and a detaching profile removes itself from these tables
455
+ local locked: Array<Profile> = {}
427
456
  for _, profile in self.profiles do
428
- unload(profile)
457
+ table.insert(locked, profile)
429
458
  end
459
+ local lockless: Array<LocklessProfile> = {}
430
460
  for _, profile in self.lockless_profiles do
461
+ table.insert(lockless, profile)
462
+ end
463
+
464
+ for _, profile in locked do
465
+ unload(profile)
466
+ end
467
+ for _, profile in lockless do
431
468
  unload(profile)
432
469
  end
433
470
  for key in self._loading do
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)
@@ -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`
@@ -162,7 +183,8 @@ export type TimeoutError = {
162
183
  type: "timeout",
163
184
  message: string,
164
185
  key: string,
165
- --- the last lock seen on the record while waiting
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
166
188
  lock: Lock?,
167
189
  }
168
190
 
@@ -210,6 +232,20 @@ export type OutdatedError = {
210
232
  declared_migrations: Array<string>,
211
233
  }
212
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
+
213
249
  export type NotFetchedError = {
214
250
  type: "not_fetched",
215
251
  message: string,
@@ -275,6 +311,7 @@ export type Error =
275
311
  | LocklessLockedError
276
312
  | TxLockLostError
277
313
  | OutdatedError
314
+ | PredicateFailedError
278
315
  | NotFetchedError
279
316
  | ProfileClosedError
280
317
  | MigrationMismatchError
@@ -399,7 +436,7 @@ export type ProfileBase<T = any> = {
399
436
  unload: (self: ProfileBase<T>) -> (),
400
437
  --- Ok(true) when the data changed, Ok(false) when the dispatcher returned
401
438
  --- nil / false. An error thrown by the dispatcher propagates as is.
402
- update: (self: ProfileBase<T>, (T) -> T | false) -> Result<boolean>,
439
+ update: (self: ProfileBase<T>, dispatcher: DataDispatcher<T>) -> Result<boolean>,
403
440
 
404
441
  on_change: (self: ProfileBase<T>, callback: (new: T, old: T?) -> ()) -> (),
405
442
  on_save: (self: ProfileBase<T>, callback: () -> ()) -> (),
@@ -424,6 +461,9 @@ export type ProfileBase<T = any> = {
424
461
  export type Profile<T = any> = ProfileBase<T> & {
425
462
  kind: "locked",
426
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,
427
467
  --- Whether the profile currently holds its session lock. True after `load`;
428
468
  --- `release` clears it, `readquire` takes it again. While released,
429
469
  --- `update` / `save` throw and the profile cannot join a transaction.
@@ -450,6 +490,13 @@ export type Profile<T = any> = ProfileBase<T> & {
450
490
  -- internal, used by transactions. Returns false (and the lock now on the
451
491
  -- record) when the lock is gone.
452
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,
453
500
  }
454
501
 
455
502
  export type ProfileWriteOptions = {
@@ -497,6 +544,7 @@ export type PeekProfile<T = any> = {
497
544
  _datastore: DatastoreHandle,
498
545
 
499
546
  --- Lock currently on the record (session or transaction), nil when free.
547
+ --- Its `id` names an acquisition, not a server (see `Lock.id`).
500
548
  lock: Lock?,
501
549
  --- Transaction marker on the record, if any. Left as stored, never resolved.
502
550
  pending: Pending<T>?,