@spooky-sync/core 0.0.1-canary.315 → 0.0.1-canary.317

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/AGENTS.md CHANGED
@@ -33,7 +33,7 @@ re-materialized from state, never patched in place.
33
33
 
34
34
  ## Key exports (`src/index.ts`)
35
35
 
36
- - `Sp00kyClient<S>` — main class. Methods: `init()`, `create(id, payload)`, `update(table, id, payload, options?)`, `delete(table, id)`, `query(table, opts?)`, `preload(query, { signal? })`, `run(backend, route, payload)`, `bucket(name)`, `remoteQuery(sql, vars?)`, `useRemote(fn)`, `authenticate(token)`, `auth.signOut()`. Admin impersonation (`impersonation.enabled` in sp00ky.yml, admins only, server-enforced): `auth.impersonate(targetId, reason)`, `auth.stopImpersonating()`, `auth.impersonation`, `auth.subscribeImpersonation(cb)`, `auth.searchImpersonationTargets(search)`; while active the client injects a warning bar with a Stop button and shifts the page down onto a hazard-striped layer. `impersonationBanner` in the client config restyles it (`theme`), replaces it (`mode: 'custom'` + `client.acknowledgeImpersonationBanner()`, else it falls back after ~2.5s) or turns it off (`mode: 'none'`). See `/docs/client/impersonation`. Traffic indicators: `pendingMutationCount` / `subscribeToPendingMutations(cb)` (outbox depth), `fetchingQueryCount` / `subscribeToFetchActivity(cb)`. Failed-writes tray: `failedMutationCount`, `subscribeToFailedMutations(cb)`, `listFailedMutations()`, `retryFailedMutation(id)`, `discardFailedMutation(id)`.
36
+ - `Sp00kyClient<S>` — main class. Methods: `init()`, `create(id, payload)`, `update(table, id, payload, options?)`, `delete(table, id)`, `query(table, opts?)`, `preload(query, { signal? })`, `run(backend, route, payload)`, `bucket(name)`, `remoteQuery(sql, vars?)`, `useRemote(fn)`, `authenticate(token)`, `auth.signOut()`. Admin impersonation (`impersonation.enabled` in sp00ky.yml, admins only, server-enforced): `auth.impersonate(targetId, reason)`, `auth.stopImpersonating()`, `auth.impersonation`, `auth.subscribeImpersonation(cb)`, `auth.searchImpersonationTargets(search)`; while active the client injects a warning bar with a Stop button and shifts the page down onto a hazard-striped layer. `impersonationBanner` in the client config restyles it (`theme`), replaces it (`mode: 'custom'` + `client.acknowledgeImpersonationBanner()`, else it falls back after ~2.5s) or turns it off (`mode: 'none'`). See `/docs/client/impersonation`. Traffic indicators: `pendingMutationCount` / `subscribeToPendingMutations(cb)` (outbox depth), `fetchingQueryCount` / `subscribeToFetchActivity(cb)` (queries a user waits on; a query that already shows rows and is only re-registering, as after a reload, is not counted). Failed-writes tray: `failedMutationCount`, `subscribeToFailedMutations(cb)`, `listFailedMutations()`, `retryFailedMutation(id)`, `discardFailedMutation(id)`.
37
37
  - `FailedMutation` — a row of the `_00_failed_mutations` tray (`mutationType`, `recordId`, `data`, `beforeRecord`, `error`, `revert`).
38
38
  - `BucketHandle` — file storage handle (`put`, `get`, `delete`, `exists`), plus the cache-aware `read`, `url`, `pin`/`unpin`, `evict`, `prefetch`. `get` is always remote; `read`/`url` go through the blob cache.
39
39
  - `services/blobs/` — durable cache for bucket bytes: OPFS holds the files, `_00_blob` holds a manifest that reconcile rebuilds from disk (so a wiped local store costs metadata, not the offline cache). Nothing expires on a timer; eviction is LRU under a byte budget only, skipping pinned and on-screen entries. Config: `blobCache` in `types.ts`.
package/dist/index.d.ts CHANGED
@@ -1011,6 +1011,11 @@ type OutEvent = {
1011
1011
  * `remote` tracks the server-side registration, `fetchDepth` is the refcount
1012
1012
  * behind `status: 'fetching'`, `notified` says whether subscribers received
1013
1013
  * at least one materialization this registration.
1014
+ *
1015
+ * `backgroundDepth` is the part of `fetchDepth` that re-verifies rows the
1016
+ * query already shows: a registration of a query that is not `cold`. It still
1017
+ * holds `status: 'fetching'` (results are not yet confirmed), but it is not
1018
+ * traffic the user waits on, so the activity indicator leaves it out.
1014
1019
  */
1015
1020
  type QueryPhase = 'cold' | 'cached' | 'live' | 'view-lost';
1016
1021
  type RemotePhase = 'unregistered' | 'registering' | 'registered' | 'failed';
@@ -1018,6 +1023,7 @@ interface QueryLifecycle {
1018
1023
  readonly phase: QueryPhase;
1019
1024
  readonly remote: RemotePhase;
1020
1025
  readonly fetchDepth: number;
1026
+ readonly backgroundDepth: number;
1021
1027
  readonly notified: boolean;
1022
1028
  }
1023
1029
  //#endregion
@@ -1143,6 +1149,8 @@ type Effect = {
1143
1149
  interface ServiceCalls {
1144
1150
  'hint.read': () => string | null;
1145
1151
  'hint.write': (bucketId: string) => void;
1152
+ 'salt.take': (authId: string | null) => string | null;
1153
+ 'salt.leave': (salt: string, authId: string | null) => void;
1146
1154
  'local.connect': (bucketId: string) => Promise<void>;
1147
1155
  'local.switchStore': (bucketId: string) => Promise<void>;
1148
1156
  'local.beginSwitch': () => () => void;
package/dist/index.js CHANGED
@@ -2178,8 +2178,8 @@ function trimHistory(history, cap) {
2178
2178
 
2179
2179
  //#endregion
2180
2180
  //#region src/modules/devtools/index.ts
2181
- const CORE_VERSION = "0.0.1-canary.315";
2182
- const WASM_VERSION = "0.0.1-canary.315";
2181
+ const CORE_VERSION = "0.0.1-canary.317";
2182
+ const WASM_VERSION = "0.0.1-canary.317";
2183
2183
  const SURREAL_VERSION = "3.0.3";
2184
2184
  var DevToolsService = class DevToolsService {
2185
2185
  version = CORE_VERSION;
@@ -2910,6 +2910,7 @@ function seedLifecycle(resolvedBefore) {
2910
2910
  phase: resolvedBefore ? "cached" : "cold",
2911
2911
  remote: "unregistered",
2912
2912
  fetchDepth: 0,
2913
+ backgroundDepth: 0,
2913
2914
  notified: false
2914
2915
  };
2915
2916
  }
@@ -2946,12 +2947,19 @@ function transition(l, ev) {
2946
2947
  };
2947
2948
  case "fetch-begin": return {
2948
2949
  ...l,
2949
- fetchDepth: l.fetchDepth + 1
2950
- };
2951
- case "fetch-end": return l.fetchDepth === 0 ? l : {
2952
- ...l,
2953
- fetchDepth: l.fetchDepth - 1
2950
+ fetchDepth: l.fetchDepth + 1,
2951
+ backgroundDepth: l.backgroundDepth + (ev.background ? 1 : 0)
2954
2952
  };
2953
+ case "fetch-end": {
2954
+ if (l.fetchDepth === 0) return l;
2955
+ const fetchDepth = l.fetchDepth - 1;
2956
+ const backgroundDepth = Math.min(fetchDepth, l.backgroundDepth - (ev.background && l.backgroundDepth > 0 ? 1 : 0));
2957
+ return {
2958
+ ...l,
2959
+ fetchDepth,
2960
+ backgroundDepth
2961
+ };
2962
+ }
2955
2963
  case "notified": return l.notified ? l : {
2956
2964
  ...l,
2957
2965
  notified: true
@@ -2965,6 +2973,8 @@ function transition(l, ev) {
2965
2973
  const isAuthoritative = (l) => l.phase !== "cold";
2966
2974
  const hasServerMembership = (l) => l.phase === "live" || l.phase === "view-lost";
2967
2975
  const deriveStatus = (l) => l.fetchDepth > 0 ? "fetching" : "idle";
2976
+ /** A fetch cycle the user is waiting on: rows to download, or a first registration. */
2977
+ const isForegroundFetching = (l) => l.fetchDepth > l.backgroundDepth;
2968
2978
 
2969
2979
  //#endregion
2970
2980
  //#region src/kernel/constants.ts
@@ -3571,7 +3581,13 @@ function retained(s) {
3571
3581
  const pendingDeleteIds = (s) => new Set(s.outbox.filter((i) => i.type === "delete").map((i) => i.recordId));
3572
3582
  const hasAckedWrites = (s) => s.outbox.some((i) => i.status === "acked");
3573
3583
  const pendingMutationCount = (s) => s.outbox.filter((i) => i.status === "pending").length;
3574
- const fetchingQueryCount = (s) => [...s.queries.values()].filter((e) => e.lifecycle.fetchDepth > 0).length;
3584
+ /**
3585
+ * Queries with traffic the user is waiting on. A re-registration of a query
3586
+ * that already shows rows (every mounted query after a reload) is left out:
3587
+ * it only confirms what the cache painted, and any newer body it finds is
3588
+ * fetched by `fetchRows`, which counts.
3589
+ */
3590
+ const fetchingQueryCount = (s) => [...s.queries.values()].filter((e) => isForegroundFetching(e.lifecycle)).length;
3575
3591
  /** Membership entries whose body is missing or older locally. */
3576
3592
  function needed(s, hash) {
3577
3593
  const e = s.queries.get(hash);
@@ -4362,6 +4378,22 @@ function heartbeatBatch(ids) {
4362
4378
  }
4363
4379
  /** A heartbeat answer whose statement matched no row: the view was reclaimed. */
4364
4380
  const heartbeatRowGone = (result) => Array.isArray(result) && result.length === 0;
4381
+ const QUERY_ID = /^_00_query:(?:[0-9a-f]{64}|⟨[0-9a-f]{64}⟩)$/;
4382
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4383
+ /**
4384
+ * The pagehide release: one bare statement for `beaconSql`, which has no params
4385
+ * channel, so ids are inlined. Only shapes the client minted itself get in, a
4386
+ * `_00_query:<sha256>` id and a UUID session id, so nothing else is ever
4387
+ * interpolated. `null` when there is nothing the server could act on: with no
4388
+ * session ids it could not tell this page's entries from anyone else's.
4389
+ */
4390
+ function leaveViewsBeacon(ids, sessionIds) {
4391
+ const list = ids.map((id) => String(id)).filter((id) => QUERY_ID.test(id));
4392
+ const sids = sessionIds.filter((sid) => SESSION_ID.test(sid));
4393
+ if (list.length === 0 || sids.length === 0) return null;
4394
+ const sidList = sids.map((sid) => `'${sid}'`).join(", ");
4395
+ return `FOR $id IN [${list.join(", ")}] { LET $_left = fn::query::leave($id, [${sidList}]); };`;
4396
+ }
4365
4397
  const bodySelect = () => "SELECT * FROM $ids";
4366
4398
  /** Local transaction that MERGEs many fetched bodies. */
4367
4399
  function upsertBodiesTx(records) {
@@ -4751,7 +4783,11 @@ function* registerRemote(env, hash, retry = false) {
4751
4783
  if (!entry) return;
4752
4784
  const { remote } = entry.lifecycle;
4753
4785
  if (remote === "registered" || remote === "failed" || remote === "registering" && !retry) return;
4754
- yield fx.state.update(compose(applyLifecycle(hash, { type: "remote-registering" }), applyLifecycle(hash, { type: "fetch-begin" })));
4786
+ const background = entry.lifecycle.phase !== "cold";
4787
+ yield fx.state.update(compose(applyLifecycle(hash, { type: "remote-registering" }), applyLifecycle(hash, {
4788
+ type: "fetch-begin",
4789
+ background
4790
+ })));
4755
4791
  try {
4756
4792
  const table = listRefTable(env, state);
4757
4793
  const results = yield fx.remote.query(registerSelect(table), registerVars({
@@ -4839,7 +4875,10 @@ function* registerRemote(env, hash, retry = false) {
4839
4875
  retry: true
4840
4876
  });
4841
4877
  } finally {
4842
- if (yield fx.state.read((s) => s.queries.has(hash))) yield fx.state.update(applyLifecycle(hash, { type: "fetch-end" }));
4878
+ if (yield fx.state.read((s) => s.queries.has(hash))) yield fx.state.update(applyLifecycle(hash, {
4879
+ type: "fetch-end",
4880
+ background
4881
+ }));
4843
4882
  }
4844
4883
  }
4845
4884
 
@@ -5802,8 +5841,8 @@ function* boot(env, opts) {
5802
5841
  yield fx.service("ssp.setPermissions");
5803
5842
  yield fx.dispatch({ type: "PrimeCircuit" });
5804
5843
  const restoredUserId = yield fx.service("auth.restoreSession");
5805
- const salt = yield fx.id("salt");
5806
5844
  const authId = yield fx.service("auth.sessionAuthId");
5845
+ const salt = (yield fx.service("salt.take", authId)) ?? (yield fx.id("salt"));
5807
5846
  yield fx.state.update(setIdentity({
5808
5847
  sessionId: salt,
5809
5848
  userId: restoredUserId,
@@ -5895,9 +5934,19 @@ function* primeCircuit() {
5895
5934
  function* versionsPrimed(entries) {
5896
5935
  yield fx.state.update(setVersions(entries));
5897
5936
  }
5898
- /** Hand this tab's views back to the server when the page goes away for good. */
5937
+ /**
5938
+ * The page is going away for good: leave the salt for a reload of this tab,
5939
+ * then hand this tab's views back to the server. The release is deferred
5940
+ * server-side (`fn::query::leave`), so a reload that re-registers within the
5941
+ * grace finds its views still warm.
5942
+ */
5899
5943
  function* pageHide() {
5900
- const ids = yield fx.state.read((s) => [...s.queries.values()].filter((e) => e.lifecycle.remote === "registered").map((e) => e.def.id));
5944
+ const [salt, saltUserId, ids] = yield fx.state.read((s) => [
5945
+ s.sessionId,
5946
+ s.saltUserId,
5947
+ [...s.queries.values()].filter((e) => e.lifecycle.remote === "registered").map((e) => e.def.id)
5948
+ ]);
5949
+ if (salt) yield fx.service("salt.leave", salt, saltUserId);
5901
5950
  if (ids.length > 0) yield fx.service("remote.releaseViews", ids);
5902
5951
  }
5903
5952
  function* warmBlobs() {
@@ -8163,6 +8212,11 @@ function resolveReconnectConfig(input) {
8163
8212
  * an app's own one-shot reads, small enough to stay polite to the server.
8164
8213
  */
8165
8214
  const REMOTE_MAX_CONCURRENT_QUERIES = 6;
8215
+ /**
8216
+ * Session ids a page remembers for its pagehide release. Only the sessions of
8217
+ * the last ttl matter; this many reconnects inside one ttl is a broken network.
8218
+ */
8219
+ const MAX_SESSION_IDS = 16;
8166
8220
  var RemoteDatabaseService = class extends AbstractDatabaseService {
8167
8221
  config;
8168
8222
  eventType = DatabaseEventTypes.RemoteQuery;
@@ -8193,6 +8247,17 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
8193
8247
  * returns nothing, on a page that still looks signed in.
8194
8248
  */
8195
8249
  authToken = null;
8250
+ /**
8251
+ * Every `session::id()` this page's sockets have had, oldest first.
8252
+ *
8253
+ * The server stamps a view's `subscribers` entry with the session that
8254
+ * registered or heartbeated it, and every socket (an SDK reconnect, a
8255
+ * supervisor revive) is a new session. The pagehide release goes over HTTP,
8256
+ * whose own session id matches none of them, so it names these instead. Kept
8257
+ * for the page, not the socket: a view registered before a reconnect still
8258
+ * carries the old id until its next heartbeat.
8259
+ */
8260
+ sessionIdsSeen = [];
8196
8261
  constructor(config, logger) {
8197
8262
  const events = createDatabaseEventSystem();
8198
8263
  super(new Surreal({ engines: applyDiagnostics(createRemoteEngines(), ({ key, type, phase, ...other }) => {
@@ -8209,6 +8274,23 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
8209
8274
  this.reconnectConfig = resolveReconnectConfig(config.reconnect);
8210
8275
  this.queryTimeoutMs = Math.max(0, config.queryTimeoutMs ?? 6e4);
8211
8276
  this.maxConcurrentQueries = REMOTE_MAX_CONCURRENT_QUERIES;
8277
+ this.client.subscribe("connected", () => void this.recordSessionId());
8278
+ }
8279
+ /** See {@link sessionIdsSeen}. */
8280
+ sessionIds() {
8281
+ return this.sessionIdsSeen;
8282
+ }
8283
+ /**
8284
+ * Ask the socket that just connected for its session id. Straight to the
8285
+ * SDK: it needs no namespace or identity, so it need not wait behind an
8286
+ * in-flight connect or take a slot from the query queue.
8287
+ */
8288
+ async recordSessionId() {
8289
+ try {
8290
+ const [sid] = await this.client.query("RETURN <string>session::id()");
8291
+ if (typeof sid !== "string" || this.sessionIdsSeen.includes(sid)) return;
8292
+ this.sessionIdsSeen = [...this.sessionIdsSeen, sid].slice(-MAX_SESSION_IDS);
8293
+ } catch {}
8212
8294
  }
8213
8295
  getConfig() {
8214
8296
  return this.config;
@@ -8225,6 +8307,10 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
8225
8307
  *
8226
8308
  * Best-effort by design: no await, no retry, errors swallowed. Every caller
8227
8309
  * must have a server-side fallback that makes a lost beacon a non-event.
8310
+ *
8311
+ * The statement runs in a fresh HTTP session: `session::id()` there is a new
8312
+ * id, not this page's socket's, so anything keyed on it must be passed the
8313
+ * ids from {@link sessionIds} instead.
8228
8314
  */
8229
8315
  beaconSql(sql) {
8230
8316
  try {
@@ -11445,6 +11531,13 @@ var AuthService = class {
11445
11531
  /** Target of an impersonation that just ended, until the client purges its
11446
11532
  * local bucket (see `consumeEndedImpersonation`). */
11447
11533
  endedImpersonationTarget = null;
11534
+ /** The way back to the admin in flight. The Stop button, the session poll,
11535
+ * a refused renewal and a rejected check can all end an impersonation, and
11536
+ * they share this one: a second stop running beside the first found the
11537
+ * admin token already taken and signed the admin out. */
11538
+ stopping = null;
11539
+ /** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
11540
+ renewing = null;
11448
11541
  impersonationTimers = new ImpersonationTimers({
11449
11542
  current: () => this.impersonation,
11450
11543
  renew: () => this.renewImpersonation(),
@@ -11595,9 +11688,18 @@ var AuthService = class {
11595
11688
  * a full sign-out rather than staying on the target.
11596
11689
  */
11597
11690
  async stopImpersonating(opts = {}) {
11691
+ if (this.stopping) return this.stopping;
11692
+ if (!this.impersonation) return;
11693
+ this.stopping = this.leaveImpersonation(opts).finally(() => {
11694
+ this.stopping = null;
11695
+ });
11696
+ return this.stopping;
11697
+ }
11698
+ async leaveImpersonation(opts) {
11699
+ this.impersonationTimers.clear();
11700
+ await this.renewing?.catch(() => void 0);
11598
11701
  const info = this.impersonation;
11599
11702
  if (!info) return;
11600
- this.impersonationTimers.clear();
11601
11703
  if (!opts.serverEnded) try {
11602
11704
  await this.remote.query("RETURN fn::_00_impersonate::stop($session)", { session: info.session });
11603
11705
  } catch (error) {
@@ -11632,9 +11734,19 @@ var AuthService = class {
11632
11734
  }
11633
11735
  /** Re-sign the impersonation token before it expires. */
11634
11736
  async renewImpersonation() {
11635
- if (!this.impersonation) return;
11737
+ if (!this.impersonation || this.stopping) return;
11738
+ const renewal = this.applyRenewal();
11739
+ this.renewing = renewal;
11740
+ try {
11741
+ await renewal;
11742
+ } finally {
11743
+ if (this.renewing === renewal) this.renewing = null;
11744
+ }
11745
+ }
11746
+ async applyRenewal() {
11636
11747
  const [renewed] = await this.remote.query("RETURN fn::_00_impersonate::renew()");
11637
11748
  if (!renewed?.token) throw new Error("Impersonation renewal returned no token.");
11749
+ if (this.stopping || !this.impersonation) return;
11638
11750
  this.remote.setAuthToken(renewed.token);
11639
11751
  await this.remote.getClient().authenticate(renewed.token);
11640
11752
  this.token = renewed.token;
@@ -11700,6 +11812,7 @@ var AuthService = class {
11700
11812
  async check(accessToken) {
11701
11813
  this.isLoading = true;
11702
11814
  let token;
11815
+ let accepted = false;
11703
11816
  try {
11704
11817
  token = accessToken || await this.persistenceClient.get("sp00ky_auth_token") || void 0;
11705
11818
  if (!token) {
@@ -11711,6 +11824,7 @@ var AuthService = class {
11711
11824
  }
11712
11825
  this.remote.setAuthToken(token);
11713
11826
  await this.remote.getClient().authenticate(token);
11827
+ accepted = true;
11714
11828
  const result = await this.remote.query("SELECT * FROM ONLY $auth.id");
11715
11829
  const items = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result;
11716
11830
  const user = Array.isArray(items) ? items[0] : items;
@@ -11741,7 +11855,13 @@ var AuthService = class {
11741
11855
  error,
11742
11856
  Category: "sp00ky-client::AuthService::check"
11743
11857
  }, "Auth check unreachable; keeping the cached session and retrying later");
11744
- else {
11858
+ else if (accepted && token) {
11859
+ this.logger.warn({
11860
+ error,
11861
+ Category: "sp00ky-client::AuthService::check"
11862
+ }, "Auth check accepted the token but could not read the user; keeping the session");
11863
+ await this.keepAcceptedSession(token);
11864
+ } else {
11745
11865
  this.logger.error({
11746
11866
  error,
11747
11867
  stack: error.stack,
@@ -11753,6 +11873,17 @@ var AuthService = class {
11753
11873
  this.isLoading = false;
11754
11874
  }
11755
11875
  }
11876
+ /** Hold a session the server accepted without its user row: the id comes
11877
+ * from the token, as on a warm boot. A token naming no user is refused. */
11878
+ async keepAcceptedSession(token) {
11879
+ if (this.isAuthenticated && this.token === token && this.currentUser) return;
11880
+ const { userId } = decodeTokenClaims(token);
11881
+ if (!userId) {
11882
+ await this.rejectToken(token);
11883
+ return;
11884
+ }
11885
+ await this.setSession(token, { id: userId });
11886
+ }
11756
11887
  /**
11757
11888
  * The server refused `token`. A refused impersonation token (stopped,
11758
11889
  * expired, or revoked) returns to the admin session instead of signing the
@@ -14542,6 +14673,38 @@ function writeBootBucketHint(bucketId) {
14542
14673
  if (typeof localStorage !== "undefined") localStorage.setItem(LAST_BUCKET_KEY, bucketId);
14543
14674
  } catch {}
14544
14675
  }
14676
+ const RELOAD_SALT_KEY = "sp00ky:salt";
14677
+ /**
14678
+ * Take the salt this tab's previous page left on its way out, if it was minted
14679
+ * for the same principal. A reload that keeps its salt re-registers every
14680
+ * query onto the `_00_query` row it already had, which the SSP answers from
14681
+ * the live view instead of building a new one.
14682
+ *
14683
+ * Taken, not read: the key is absent while a page lives, so a duplicated tab
14684
+ * (which copies sessionStorage) mints its own salt rather than sharing this
14685
+ * tab's remote views.
14686
+ */
14687
+ function takeReloadSalt(authId) {
14688
+ try {
14689
+ if (typeof sessionStorage === "undefined") return null;
14690
+ const raw = sessionStorage.getItem(RELOAD_SALT_KEY);
14691
+ if (raw === null) return null;
14692
+ sessionStorage.removeItem(RELOAD_SALT_KEY);
14693
+ const left = JSON.parse(raw);
14694
+ return typeof left.salt === "string" && left.salt !== "" && (left.authId ?? null) === authId ? left.salt : null;
14695
+ } catch {
14696
+ return null;
14697
+ }
14698
+ }
14699
+ /** Leave this page's salt for the next page in this tab; see {@link takeReloadSalt}. */
14700
+ function leaveReloadSalt(salt, authId) {
14701
+ try {
14702
+ if (typeof sessionStorage !== "undefined") sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({
14703
+ salt,
14704
+ authId
14705
+ }));
14706
+ } catch {}
14707
+ }
14545
14708
  function mintSalt() {
14546
14709
  const c = globalThis.crypto;
14547
14710
  if (c?.randomUUID) return c.randomUUID();
@@ -14603,7 +14766,7 @@ function createTabsCoordinator(config, s, host) {
14603
14766
  return new TabsCoordinator({
14604
14767
  tabId: s.tabId,
14605
14768
  fingerprint: computeTabsFingerprint({
14606
- coreVersion: "0.0.1-canary.315",
14769
+ coreVersion: "0.0.1-canary.317",
14607
14770
  schemaHash: hash53(config.schemaSurql),
14608
14771
  endpoint: config.database.endpoint ?? "",
14609
14772
  namespace: config.database.namespace,
@@ -14707,6 +14870,8 @@ function createAdapters(config, s, host, lateModules) {
14707
14870
  const services = {
14708
14871
  "hint.read": readBootBucketHint,
14709
14872
  "hint.write": writeBootBucketHint,
14873
+ "salt.take": takeReloadSalt,
14874
+ "salt.leave": leaveReloadSalt,
14710
14875
  "local.connect": (bucketId) => s.local.connect(bucketId),
14711
14876
  "local.switchStore": (bucketId) => s.local.switchStore(bucketId),
14712
14877
  "local.beginSwitch": () => s.local.beginSwitch(),
@@ -14753,8 +14918,8 @@ function createAdapters(config, s, host, lateModules) {
14753
14918
  "auth.consumeEndedImpersonation": () => s.auth.consumeEndedImpersonation(),
14754
14919
  "remote.connect": () => s.remote.connect(),
14755
14920
  "remote.releaseViews": (ids) => {
14756
- const list = ids.map((id) => String(id)).filter((id) => /^_00_query:[0-9a-f]{64}$/.test(id)).join(", ");
14757
- if (list) s.remote.beaconSql(`FOR $id IN [${list}] { LET $_released = fn::query::unsubscribe($id); };`);
14921
+ const sql = leaveViewsBeacon(ids, s.remote.sessionIds());
14922
+ if (sql) s.remote.beaconSql(sql);
14758
14923
  },
14759
14924
  "supervisor.start": () => s.connectionSupervisor.start(),
14760
14925
  "tabs.start": (bucketId) => {
package/dist/live.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as registerSelect, C as heartbeatRowGone, M as singleSnapshotSelect, R as sha256Hex, S as heartbeatBatch, a as metaFromRow, b as bodySelect, c as parseDuration, d as parseQueryParams, f as ANON_USER_ID, h as listRefTableFor, j as registerVars, l as parseRecordIdString, n as rowOfEdge, p as DEFAULT_REF_MODE, s as encodeRecordId, t as hashOfEdge, u as withTimeout, z as queryHashInput } from "./worker-edges.js";
1
+ import { B as queryHashInput, C as heartbeatRowGone, M as registerVars, N as singleSnapshotSelect, S as heartbeatBatch, a as metaFromRow, b as bodySelect, c as parseDuration, d as parseQueryParams, f as ANON_USER_ID, h as listRefTableFor, j as registerSelect, l as parseRecordIdString, n as rowOfEdge, p as DEFAULT_REF_MODE, s as encodeRecordId, t as hashOfEdge, u as withTimeout, z as sha256Hex } from "./worker-edges.js";
2
2
  import { n as decodeTokenClaims } from "./worker-impersonation.js";
3
3
  import { RecordId, Surreal } from "surrealdb";
4
4
 
package/dist/pure.d.ts CHANGED
@@ -217,6 +217,14 @@ declare function heartbeatBatch(ids: ReadonlyArray<RecordId<string>>): {
217
217
  };
218
218
  /** A heartbeat answer whose statement matched no row: the view was reclaimed. */
219
219
  declare const heartbeatRowGone: (result: unknown) => boolean;
220
+ /**
221
+ * The pagehide release: one bare statement for `beaconSql`, which has no params
222
+ * channel, so ids are inlined. Only shapes the client minted itself get in, a
223
+ * `_00_query:<sha256>` id and a UUID session id, so nothing else is ever
224
+ * interpolated. `null` when there is nothing the server could act on: with no
225
+ * session ids it could not tell this page's entries from anyone else's.
226
+ */
227
+ declare function leaveViewsBeacon(ids: ReadonlyArray<unknown>, sessionIds: ReadonlyArray<string>): string | null;
220
228
  declare const bodySelect: () => string;
221
229
  /** Local transaction that MERGEs many fetched bodies. */
222
230
  declare function upsertBodiesTx(records: ReadonlyArray<{
@@ -326,4 +334,4 @@ declare function sha256Hex(input: string): Promise<string>;
326
334
  */
327
335
  declare function queryKey(input: QueryKeyInput, sessionId: string | null): Promise<string>;
328
336
  //#endregion
329
- export { ANON_USER_ID, type Answer, BridgeSignOutMessage, BridgeTokenMessage, DEFAULT_REF_MODE, FAILED, IMPERSONATION_ACCESS, LEGACY_VIEW_TABLE, type ListRefSnapshot, PUSH_PAYLOAD_VERSION, PushNotification, PushNotificationAction, PushOp, PushPayload, PushPayloadKind, PushUrgency, type QueryKeyInput, type QueryMetaRow, type RefMode, RegisterPayload, type SealOptions, type SealedQuery, type SurqlHelper, type TokenClaims, type TxQuery, VIEW_TABLE, WebPushClientMessage, base64UrlToBytes, batchSnapshotSelect, bodySelect, bucketIdForUser, bytesToBase64Url, countViewRows, decodeTokenClaims, dedupeRecordVersions, encodeRecordId, hashOfEdge, heartbeatBatch, heartbeatRowGone, isPushPayload, listRefBatchSelect, listRefSelect, listRefTableFor, metaFromRow, parseDuration, parseQueryParams, parseRecordIdString, queryHashInput, queryKey, queryRowCountBatchSelect, queryRowCountSelect, readLegacyViewRows, readViewRows, registerSelect, registerVars, rowOfEdge, sameBytes, sanitizeUserId, sha256Hex, singleSnapshotSelect, snapshotFromSingle, subqueryListRefSelect, surql, upsertBodiesTx, viewKeyInput, viewRecordId, viewRow };
337
+ export { ANON_USER_ID, type Answer, BridgeSignOutMessage, BridgeTokenMessage, DEFAULT_REF_MODE, FAILED, IMPERSONATION_ACCESS, LEGACY_VIEW_TABLE, type ListRefSnapshot, PUSH_PAYLOAD_VERSION, PushNotification, PushNotificationAction, PushOp, PushPayload, PushPayloadKind, PushUrgency, type QueryKeyInput, type QueryMetaRow, type RefMode, RegisterPayload, type SealOptions, type SealedQuery, type SurqlHelper, type TokenClaims, type TxQuery, VIEW_TABLE, WebPushClientMessage, base64UrlToBytes, batchSnapshotSelect, bodySelect, bucketIdForUser, bytesToBase64Url, countViewRows, decodeTokenClaims, dedupeRecordVersions, encodeRecordId, hashOfEdge, heartbeatBatch, heartbeatRowGone, isPushPayload, leaveViewsBeacon, listRefBatchSelect, listRefSelect, listRefTableFor, metaFromRow, parseDuration, parseQueryParams, parseRecordIdString, queryHashInput, queryKey, queryRowCountBatchSelect, queryRowCountSelect, readLegacyViewRows, readViewRows, registerSelect, registerVars, rowOfEdge, sameBytes, sanitizeUserId, sha256Hex, singleSnapshotSelect, snapshotFromSingle, subqueryListRefSelect, surql, upsertBodiesTx, viewKeyInput, viewRecordId, viewRow };
package/dist/pure.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as registerSelect, B as viewKeyInput, C as heartbeatRowGone, D as queryRowCountSelect, E as queryRowCountBatchSelect, F as viewRecordId, I as viewRow, L as surql, M as singleSnapshotSelect, N as subqueryListRefSelect, O as readLegacyViewRows, P as upsertBodiesTx, R as sha256Hex, S as heartbeatBatch, T as listRefSelect, _ as LEGACY_VIEW_TABLE, a as metaFromRow, b as bodySelect, c as parseDuration, d as parseQueryParams, f as ANON_USER_ID, g as sanitizeUserId, h as listRefTableFor, i as dedupeRecordVersions, j as registerVars, k as readViewRows, l as parseRecordIdString, m as bucketIdForUser, n as rowOfEdge, o as snapshotFromSingle, p as DEFAULT_REF_MODE, r as FAILED, s as encodeRecordId, t as hashOfEdge, v as VIEW_TABLE, w as listRefBatchSelect, x as countViewRows, y as batchSnapshotSelect, z as queryHashInput } from "./worker-edges.js";
1
+ import { A as readViewRows, B as queryHashInput, C as heartbeatRowGone, D as queryRowCountBatchSelect, E as listRefSelect, F as upsertBodiesTx, I as viewRecordId, L as viewRow, M as registerVars, N as singleSnapshotSelect, O as queryRowCountSelect, P as subqueryListRefSelect, R as surql, S as heartbeatBatch, T as listRefBatchSelect, V as viewKeyInput, _ as LEGACY_VIEW_TABLE, a as metaFromRow, b as bodySelect, c as parseDuration, d as parseQueryParams, f as ANON_USER_ID, g as sanitizeUserId, h as listRefTableFor, i as dedupeRecordVersions, j as registerSelect, k as readLegacyViewRows, l as parseRecordIdString, m as bucketIdForUser, n as rowOfEdge, o as snapshotFromSingle, p as DEFAULT_REF_MODE, r as FAILED, s as encodeRecordId, t as hashOfEdge, v as VIEW_TABLE, w as leaveViewsBeacon, x as countViewRows, y as batchSnapshotSelect, z as sha256Hex } from "./worker-edges.js";
2
2
  import { n as decodeTokenClaims, t as IMPERSONATION_ACCESS } from "./worker-impersonation.js";
3
3
  import { a as sameBytes, i as isPushPayload, n as base64UrlToBytes, r as bytesToBase64Url, t as PUSH_PAYLOAD_VERSION } from "./worker-types.js";
4
4
 
@@ -21,4 +21,4 @@ function queryKey(input, sessionId) {
21
21
  }
22
22
 
23
23
  //#endregion
24
- export { ANON_USER_ID, DEFAULT_REF_MODE, FAILED, IMPERSONATION_ACCESS, LEGACY_VIEW_TABLE, PUSH_PAYLOAD_VERSION, VIEW_TABLE, base64UrlToBytes, batchSnapshotSelect, bodySelect, bucketIdForUser, bytesToBase64Url, countViewRows, decodeTokenClaims, dedupeRecordVersions, encodeRecordId, hashOfEdge, heartbeatBatch, heartbeatRowGone, isPushPayload, listRefBatchSelect, listRefSelect, listRefTableFor, metaFromRow, parseDuration, parseQueryParams, parseRecordIdString, queryHashInput, queryKey, queryRowCountBatchSelect, queryRowCountSelect, readLegacyViewRows, readViewRows, registerSelect, registerVars, rowOfEdge, sameBytes, sanitizeUserId, sha256Hex, singleSnapshotSelect, snapshotFromSingle, subqueryListRefSelect, surql, upsertBodiesTx, viewKeyInput, viewRecordId, viewRow };
24
+ export { ANON_USER_ID, DEFAULT_REF_MODE, FAILED, IMPERSONATION_ACCESS, LEGACY_VIEW_TABLE, PUSH_PAYLOAD_VERSION, VIEW_TABLE, base64UrlToBytes, batchSnapshotSelect, bodySelect, bucketIdForUser, bytesToBase64Url, countViewRows, decodeTokenClaims, dedupeRecordVersions, encodeRecordId, hashOfEdge, heartbeatBatch, heartbeatRowGone, isPushPayload, leaveViewsBeacon, listRefBatchSelect, listRefSelect, listRefTableFor, metaFromRow, parseDuration, parseQueryParams, parseRecordIdString, queryHashInput, queryKey, queryRowCountBatchSelect, queryRowCountSelect, readLegacyViewRows, readViewRows, registerSelect, registerVars, rowOfEdge, sameBytes, sanitizeUserId, sha256Hex, singleSnapshotSelect, snapshotFromSingle, subqueryListRefSelect, surql, upsertBodiesTx, viewKeyInput, viewRecordId, viewRow };
package/dist/types.d.ts CHANGED
@@ -659,7 +659,26 @@ declare class RemoteDatabaseService extends AbstractDatabaseService {
659
659
  * returns nothing, on a page that still looks signed in.
660
660
  */
661
661
  private authToken;
662
+ /**
663
+ * Every `session::id()` this page's sockets have had, oldest first.
664
+ *
665
+ * The server stamps a view's `subscribers` entry with the session that
666
+ * registered or heartbeated it, and every socket (an SDK reconnect, a
667
+ * supervisor revive) is a new session. The pagehide release goes over HTTP,
668
+ * whose own session id matches none of them, so it names these instead. Kept
669
+ * for the page, not the socket: a view registered before a reconnect still
670
+ * carries the old id until its next heartbeat.
671
+ */
672
+ private sessionIdsSeen;
662
673
  constructor(config: Sp00kyConfig<any>['database'], logger: Logger$1);
674
+ /** See {@link sessionIdsSeen}. */
675
+ sessionIds(): readonly string[];
676
+ /**
677
+ * Ask the socket that just connected for its session id. Straight to the
678
+ * SDK: it needs no namespace or identity, so it need not wait behind an
679
+ * in-flight connect or take a slot from the query queue.
680
+ */
681
+ private recordSessionId;
663
682
  getConfig(): Sp00kyConfig<any>['database'];
664
683
  /**
665
684
  * Send one SurrealQL statement so that it survives the page going away.
@@ -673,6 +692,10 @@ declare class RemoteDatabaseService extends AbstractDatabaseService {
673
692
  *
674
693
  * Best-effort by design: no await, no retry, errors swallowed. Every caller
675
694
  * must have a server-side fallback that makes a lost beacon a non-event.
695
+ *
696
+ * The statement runs in a fresh HTTP session: `session::id()` there is a new
697
+ * id, not this page's socket's, so anything keyed on it must be passed the
698
+ * ids from {@link sessionIds} instead.
676
699
  */
677
700
  beaconSql(sql: string): void;
678
701
  /**
@@ -793,6 +816,13 @@ declare class AuthService<S extends SchemaStructure> {
793
816
  /** Target of an impersonation that just ended, until the client purges its
794
817
  * local bucket (see `consumeEndedImpersonation`). */
795
818
  private endedImpersonationTarget;
819
+ /** The way back to the admin in flight. The Stop button, the session poll,
820
+ * a refused renewal and a rejected check can all end an impersonation, and
821
+ * they share this one: a second stop running beside the first found the
822
+ * admin token already taken and signed the admin out. */
823
+ private stopping;
824
+ /** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
825
+ private renewing;
796
826
  private impersonationTimers;
797
827
  get eventSystem(): AuthEventSystem;
798
828
  constructor(schema: S, remote: RemoteDatabaseService, persistenceClient: PersistenceClient, logger: Logger$1);
@@ -839,8 +869,10 @@ declare class AuthService<S extends SchemaStructure> {
839
869
  stopImpersonating(opts?: {
840
870
  serverEnded?: boolean;
841
871
  }): Promise<void>;
872
+ private leaveImpersonation;
842
873
  /** Re-sign the impersonation token before it expires. */
843
874
  renewImpersonation(): Promise<void>;
875
+ private applyRenewal;
844
876
  /** Users an admin may pick from. Admin-only on the server. */
845
877
  searchImpersonationTargets(search: string): Promise<ImpersonationCandidate[]>;
846
878
  /** Open impersonation sessions. Admin-only on the server. */
@@ -872,6 +904,9 @@ declare class AuthService<S extends SchemaStructure> {
872
904
  * Check for existing session and validate
873
905
  */
874
906
  check(accessToken?: string): Promise<void>;
907
+ /** Hold a session the server accepted without its user row: the id comes
908
+ * from the token, as on a warm boot. A token naming no user is refused. */
909
+ private keepAcceptedSession;
875
910
  /**
876
911
  * The server refused `token`. A refused impersonation token (stopped,
877
912
  * expired, or revoked) returns to the admin session instead of signing the
@@ -147,6 +147,22 @@ function heartbeatBatch(ids) {
147
147
  }
148
148
  /** A heartbeat answer whose statement matched no row: the view was reclaimed. */
149
149
  const heartbeatRowGone = (result) => Array.isArray(result) && result.length === 0;
150
+ const QUERY_ID = /^_00_query:(?:[0-9a-f]{64}|⟨[0-9a-f]{64}⟩)$/;
151
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
152
+ /**
153
+ * The pagehide release: one bare statement for `beaconSql`, which has no params
154
+ * channel, so ids are inlined. Only shapes the client minted itself get in, a
155
+ * `_00_query:<sha256>` id and a UUID session id, so nothing else is ever
156
+ * interpolated. `null` when there is nothing the server could act on: with no
157
+ * session ids it could not tell this page's entries from anyone else's.
158
+ */
159
+ function leaveViewsBeacon(ids, sessionIds) {
160
+ const list = ids.map((id) => String(id)).filter((id) => QUERY_ID.test(id));
161
+ const sids = sessionIds.filter((sid) => SESSION_ID.test(sid));
162
+ if (list.length === 0 || sids.length === 0) return null;
163
+ const sidList = sids.map((sid) => `'${sid}'`).join(", ");
164
+ return `FOR $id IN [${list.join(", ")}] { LET $_left = fn::query::leave($id, [${sidList}]); };`;
165
+ }
150
166
  const bodySelect = () => "SELECT * FROM $ids";
151
167
  /** Local transaction that MERGEs many fetched bodies. */
152
168
  function upsertBodiesTx(records) {
@@ -420,4 +436,4 @@ function rowOfEdge(value) {
420
436
  }
421
437
 
422
438
  //#endregion
423
- export { registerSelect as A, viewKeyInput as B, heartbeatRowGone as C, queryRowCountSelect as D, queryRowCountBatchSelect as E, viewRecordId as F, viewRow as I, surql as L, singleSnapshotSelect as M, subqueryListRefSelect as N, readLegacyViewRows as O, upsertBodiesTx as P, sha256Hex as R, heartbeatBatch as S, listRefSelect as T, LEGACY_VIEW_TABLE as _, metaFromRow as a, bodySelect as b, parseDuration as c, parseQueryParams as d, ANON_USER_ID as f, sanitizeUserId as g, listRefTableFor as h, dedupeRecordVersions as i, registerVars as j, readViewRows as k, parseRecordIdString as l, bucketIdForUser as m, rowOfEdge as n, snapshotFromSingle as o, DEFAULT_REF_MODE as p, FAILED as r, encodeRecordId as s, hashOfEdge as t, withTimeout as u, VIEW_TABLE as v, listRefBatchSelect as w, countViewRows as x, batchSnapshotSelect as y, queryHashInput as z };
439
+ export { readViewRows as A, queryHashInput as B, heartbeatRowGone as C, queryRowCountBatchSelect as D, listRefSelect as E, upsertBodiesTx as F, viewRecordId as I, viewRow as L, registerVars as M, singleSnapshotSelect as N, queryRowCountSelect as O, subqueryListRefSelect as P, surql as R, heartbeatBatch as S, listRefBatchSelect as T, viewKeyInput as V, LEGACY_VIEW_TABLE as _, metaFromRow as a, bodySelect as b, parseDuration as c, parseQueryParams as d, ANON_USER_ID as f, sanitizeUserId as g, listRefTableFor as h, dedupeRecordVersions as i, registerSelect as j, readLegacyViewRows as k, parseRecordIdString as l, bucketIdForUser as m, rowOfEdge as n, snapshotFromSingle as o, DEFAULT_REF_MODE as p, FAILED as r, encodeRecordId as s, hashOfEdge as t, withTimeout as u, VIEW_TABLE as v, leaveViewsBeacon as w, countViewRows as x, batchSnapshotSelect as y, sha256Hex as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.315",
3
+ "version": "0.0.1-canary.317",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -75,8 +75,8 @@
75
75
  }
76
76
  },
77
77
  "dependencies": {
78
- "@spooky-sync/query-builder": "0.0.1-canary.315",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.315",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.317",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.317",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
81
  "@surrealdb/wasm": "^3.0.3",
82
82
  "blurhash": "^2.0.5",
@@ -34,6 +34,7 @@ describe('boot', () => {
34
34
  'ssp.setPermissions',
35
35
  'auth.restoreSession',
36
36
  'auth.sessionAuthId',
37
+ 'salt.take',
37
38
  'crdt.setSessionId',
38
39
  'auth.access',
39
40
  'ssp.setSessionAuth',
@@ -49,6 +50,18 @@ describe('boot', () => {
49
50
  expect(out.timers.get('gc')).toEqual({ ms: 30_000, event: { type: 'GcTick' } });
50
51
  expect(svc.names()).not.toContain('remote.connect');
51
52
  });
53
+ it('a reload keeps the salt its tab left for the same principal', async () => {
54
+ const svc = fakeServices({
55
+ 'hint.read': () => 'u1',
56
+ 'auth.restoreSession': async () => 'user:u1',
57
+ 'auth.sessionAuthId': () => 'user:u1',
58
+ 'salt.take': () => 'kept-salt',
59
+ });
60
+ const out = await runPure(boot(env, { sharedTabs: false }), { handlers: { service: svc.handler, 'local.query': () => [] } });
61
+ expect(svc.calls).toContainEqual(['salt.take', ['user:u1']]);
62
+ expect(svc.calls).toContainEqual(['crdt.setSessionId', ['kept-salt']]);
63
+ expect(out.state).toMatchObject({ sessionId: 'kept-salt', saltUserId: 'user:u1' });
64
+ });
52
65
  it('shared tabs: takes the role from the coordinator, or falls back to solo; schemaless engines skip provisioning; anonymous skips session auth', async () => {
53
66
  const leader = fakeServices({ 'hint.read': () => null, 'tabs.start': async () => 'leader', 'local.usesSurqlSchema': () => false, 'auth.restoreSession': async () => null, 'auth.sessionAuthId': () => null });
54
67
  const out = await runPure(boot(env, { sharedTabs: true }), { handlers: { service: leader.handler, 'local.query': () => [] } });
@@ -133,6 +146,15 @@ describe('startRemote / primeCircuit / versionsPrimed / pageHide / warmBlobs', (
133
146
  expect(f.state.primed).toBe(true);
134
147
  expect(f.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
135
148
  });
149
+ it('pageHide leaves the salt for a reload before releasing views', async () => {
150
+ const svc = fakeServices();
151
+ const s = { ...buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote: 'registered' } })]), sessionId: 'salt-1', saltUserId: 'user:u1' };
152
+ await runPure(pageHide(), { state: s, handlers: { service: svc.handler } });
153
+ expect(svc.calls).toEqual([
154
+ ['salt.leave', ['salt-1', 'user:u1']],
155
+ ['remote.releaseViews', [[new RecordId('_00_query', 'a')]]],
156
+ ]);
157
+ });
136
158
  it('versionsPrimed records versions; pageHide releases registered views; warmBlobs is best-effort', async () => {
137
159
  const v = await runPure(versionsPrimed([['thing:1', 3]]), { state: buildState() });
138
160
  expect(v.state.versions.get('thing:1')).toBe(3);