@spooky-sync/core 0.0.1-canary.316 → 0.0.1-canary.318

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.316";
2182
- const WASM_VERSION = "0.0.1-canary.316";
2181
+ const CORE_VERSION = "0.0.1-canary.318";
2182
+ const WASM_VERSION = "0.0.1-canary.318";
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 {
@@ -14587,6 +14673,38 @@ function writeBootBucketHint(bucketId) {
14587
14673
  if (typeof localStorage !== "undefined") localStorage.setItem(LAST_BUCKET_KEY, bucketId);
14588
14674
  } catch {}
14589
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
+ }
14590
14708
  function mintSalt() {
14591
14709
  const c = globalThis.crypto;
14592
14710
  if (c?.randomUUID) return c.randomUUID();
@@ -14648,7 +14766,7 @@ function createTabsCoordinator(config, s, host) {
14648
14766
  return new TabsCoordinator({
14649
14767
  tabId: s.tabId,
14650
14768
  fingerprint: computeTabsFingerprint({
14651
- coreVersion: "0.0.1-canary.316",
14769
+ coreVersion: "0.0.1-canary.318",
14652
14770
  schemaHash: hash53(config.schemaSurql),
14653
14771
  endpoint: config.database.endpoint ?? "",
14654
14772
  namespace: config.database.namespace,
@@ -14752,6 +14870,8 @@ function createAdapters(config, s, host, lateModules) {
14752
14870
  const services = {
14753
14871
  "hint.read": readBootBucketHint,
14754
14872
  "hint.write": writeBootBucketHint,
14873
+ "salt.take": takeReloadSalt,
14874
+ "salt.leave": leaveReloadSalt,
14755
14875
  "local.connect": (bucketId) => s.local.connect(bucketId),
14756
14876
  "local.switchStore": (bucketId) => s.local.switchStore(bucketId),
14757
14877
  "local.beginSwitch": () => s.local.beginSwitch(),
@@ -14798,8 +14918,8 @@ function createAdapters(config, s, host, lateModules) {
14798
14918
  "auth.consumeEndedImpersonation": () => s.auth.consumeEndedImpersonation(),
14799
14919
  "remote.connect": () => s.remote.connect(),
14800
14920
  "remote.releaseViews": (ids) => {
14801
- const list = ids.map((id) => String(id)).filter((id) => /^_00_query:[0-9a-f]{64}$/.test(id)).join(", ");
14802
- 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);
14803
14923
  },
14804
14924
  "supervisor.start": () => s.connectionSupervisor.start(),
14805
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
  /**
@@ -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.316",
3
+ "version": "0.0.1-canary.318",
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.316",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.316",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.318",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.318",
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);
@@ -43,8 +43,9 @@ export function* boot(env: SagaEnv, opts: BootOptions): Saga<void> {
43
43
  yield fx.dispatch({ type: 'PrimeCircuit' });
44
44
 
45
45
  const restoredUserId = (yield fx.service('auth.restoreSession')) as string | null;
46
- const salt = (yield fx.id('salt')) as string;
47
46
  const authId = (yield fx.service('auth.sessionAuthId')) as string | null;
47
+ // A reload keeps the salt its tab had, so registrations land on warm views.
48
+ const salt = (((yield fx.service('salt.take', authId)) as string | null) ?? (yield fx.id('salt'))) as string;
48
49
  yield fx.state.update(R.setIdentity({ sessionId: salt, userId: restoredUserId, saltUserId: authId }));
49
50
  yield fx.service('crdt.setSessionId', salt);
50
51
  if (restoredUserId) yield fx.service('ssp.setSessionAuth', authId, (yield fx.service('auth.access')) as string | null);
@@ -115,9 +116,19 @@ export function* versionsPrimed(entries: Array<readonly [string, number]>): Saga
115
116
  yield fx.state.update(R.setVersions(entries));
116
117
  }
117
118
 
118
- /** Hand this tab's views back to the server when the page goes away for good. */
119
+ /**
120
+ * The page is going away for good: leave the salt for a reload of this tab,
121
+ * then hand this tab's views back to the server. The release is deferred
122
+ * server-side (`fn::query::leave`), so a reload that re-registers within the
123
+ * grace finds its views still warm.
124
+ */
119
125
  export function* pageHide(): Saga<void> {
120
- const ids = (yield fx.state.read((s) => [...s.queries.values()].filter((e) => e.lifecycle.remote === 'registered').map((e) => e.def.id))) as unknown[];
126
+ const [salt, saltUserId, ids] = (yield fx.state.read((s) => [
127
+ s.sessionId,
128
+ s.saltUserId,
129
+ [...s.queries.values()].filter((e) => e.lifecycle.remote === 'registered').map((e) => e.def.id),
130
+ ])) as [string | null, string | null, unknown[]];
131
+ if (salt) yield fx.service('salt.leave', salt, saltUserId);
121
132
  if (ids.length > 0) yield fx.service('remote.releaseViews', ids);
122
133
  }
123
134
 
@@ -0,0 +1,51 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { leaveReloadSalt, takeReloadSalt } from './services';
3
+
4
+ class MemoryStorage {
5
+ private items = new Map<string, string>();
6
+ getItem(key: string): string | null {
7
+ return this.items.get(key) ?? null;
8
+ }
9
+ setItem(key: string, value: string): void {
10
+ this.items.set(key, value);
11
+ }
12
+ removeItem(key: string): void {
13
+ this.items.delete(key);
14
+ }
15
+ }
16
+
17
+ describe('reload salt', () => {
18
+ const g = globalThis as { sessionStorage?: unknown };
19
+ beforeEach(() => {
20
+ g.sessionStorage = new MemoryStorage();
21
+ });
22
+ afterEach(() => {
23
+ delete g.sessionStorage;
24
+ });
25
+
26
+ it('a salt left on the way out is taken once, by the same principal', () => {
27
+ leaveReloadSalt('s1', 'user:a');
28
+ expect(takeReloadSalt('user:a')).toBe('s1');
29
+ // Taken, not read: a tab duplicated from this page finds nothing.
30
+ expect(takeReloadSalt('user:a')).toBeNull();
31
+ });
32
+
33
+ it('another principal, or none, mints a fresh salt', () => {
34
+ leaveReloadSalt('s1', 'user:a');
35
+ expect(takeReloadSalt('user:b')).toBeNull();
36
+ leaveReloadSalt('s2', null);
37
+ expect(takeReloadSalt(null)).toBe('s2');
38
+ leaveReloadSalt('s3', null);
39
+ expect(takeReloadSalt('user:a')).toBeNull();
40
+ });
41
+
42
+ it('garbage or missing storage is a fresh salt, never a throw', () => {
43
+ (g.sessionStorage as MemoryStorage).setItem('sp00ky:salt', '{not json');
44
+ expect(takeReloadSalt(null)).toBeNull();
45
+ (g.sessionStorage as MemoryStorage).setItem('sp00ky:salt', JSON.stringify({ salt: '', authId: null }));
46
+ expect(takeReloadSalt(null)).toBeNull();
47
+ delete g.sessionStorage;
48
+ expect(takeReloadSalt(null)).toBeNull();
49
+ expect(() => leaveReloadSalt('s1', null)).not.toThrow();
50
+ });
51
+ });
@@ -28,6 +28,7 @@ import type { RuntimeEvent } from '../kernel/events';
28
28
  import type { ServiceCalls } from '../kernel/effects';
29
29
  import { encodeRecordId } from '../utils/index';
30
30
  import { sha256Hex } from '../utils/sha256';
31
+ import { leaveViewsBeacon } from '../query/sql';
31
32
  import { hashOfEdge, rowOfEdge } from '../utils/edges';
32
33
  import { mintMutationId } from '../mutation/mutation-id';
33
34
  import { BucketHandle, bucketContentToBlob } from './bucket-handle';
@@ -50,6 +51,40 @@ export function writeBootBucketHint(bucketId: string): void {
50
51
  }
51
52
  }
52
53
 
54
+ const RELOAD_SALT_KEY = 'sp00ky:salt';
55
+
56
+ /**
57
+ * Take the salt this tab's previous page left on its way out, if it was minted
58
+ * for the same principal. A reload that keeps its salt re-registers every
59
+ * query onto the `_00_query` row it already had, which the SSP answers from
60
+ * the live view instead of building a new one.
61
+ *
62
+ * Taken, not read: the key is absent while a page lives, so a duplicated tab
63
+ * (which copies sessionStorage) mints its own salt rather than sharing this
64
+ * tab's remote views.
65
+ */
66
+ export function takeReloadSalt(authId: string | null): string | null {
67
+ try {
68
+ if (typeof sessionStorage === 'undefined') return null;
69
+ const raw = sessionStorage.getItem(RELOAD_SALT_KEY);
70
+ if (raw === null) return null;
71
+ sessionStorage.removeItem(RELOAD_SALT_KEY);
72
+ const left = JSON.parse(raw) as { salt?: unknown; authId?: unknown };
73
+ return typeof left.salt === 'string' && left.salt !== '' && (left.authId ?? null) === authId ? left.salt : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /** Leave this page's salt for the next page in this tab; see {@link takeReloadSalt}. */
80
+ export function leaveReloadSalt(salt: string, authId: string | null): void {
81
+ try {
82
+ if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({ salt, authId }));
83
+ } catch {
84
+ /* private-mode storage errors: the next page mints a fresh salt */
85
+ }
86
+ }
87
+
53
88
  // Moved to a pure module so `@spooky-sync/core/pure` and the live feed share
54
89
  // it; re-exported here for existing imports.
55
90
  export { sha256Hex };
@@ -220,6 +255,8 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
220
255
  const services: ServiceCalls = {
221
256
  'hint.read': readBootBucketHint,
222
257
  'hint.write': writeBootBucketHint,
258
+ 'salt.take': takeReloadSalt,
259
+ 'salt.leave': leaveReloadSalt,
223
260
  'local.connect': (bucketId) => s.local.connect(bucketId),
224
261
  'local.switchStore': (bucketId) => s.local.switchStore(bucketId),
225
262
  'local.beginSwitch': () => s.local.beginSwitch(),
@@ -260,11 +297,8 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
260
297
  'auth.consumeEndedImpersonation': () => s.auth.consumeEndedImpersonation(),
261
298
  'remote.connect': () => s.remote.connect(),
262
299
  'remote.releaseViews': (ids) => {
263
- const list = ids
264
- .map((id) => String(id))
265
- .filter((id) => /^_00_query:[0-9a-f]{64}$/.test(id))
266
- .join(', ');
267
- if (list) s.remote.beaconSql(`FOR $id IN [${list}] { LET $_released = fn::query::unsubscribe($id); };`);
300
+ const sql = leaveViewsBeacon(ids, s.remote.sessionIds());
301
+ if (sql) s.remote.beaconSql(sql);
268
302
  },
269
303
  'supervisor.start': () => s.connectionSupervisor.start(),
270
304
  'tabs.start': (bucketId) => {
@@ -70,6 +70,8 @@ export type EffectKind = Effect['kind'];
70
70
  export interface ServiceCalls {
71
71
  'hint.read': () => string | null;
72
72
  'hint.write': (bucketId: string) => void;
73
+ 'salt.take': (authId: string | null) => string | null;
74
+ 'salt.leave': (salt: string, authId: string | null) => void;
73
75
  'local.connect': (bucketId: string) => Promise<void>;
74
76
  'local.switchStore': (bucketId: string) => Promise<void>;
75
77
  'local.beginSwitch': () => () => void;
@@ -156,6 +156,24 @@ describe('registerRemote', () => {
156
156
  expect(out.dispatched).toEqual([{ type: 'FetchRows' }, { type: 'FetchRows' }, { type: 'SyncOutcome', ok: true }]);
157
157
  expect(out.emitted).toContainEqual({ type: 'query:authority', hash: 'a', known: true });
158
158
  });
159
+ it('a cold registration is foreground; a cached or live one is background', async () => {
160
+ for (const [phase, background] of [['cold', 0], ['cached', 1], ['live', 1]] as const) {
161
+ const seen: Array<[number, number]> = [];
162
+ const out = await runPure(registerRemote(env, 'a'), {
163
+ state: buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { phase } })]),
164
+ handlers: {
165
+ 'remote.query': (_e: unknown, ctx) => {
166
+ const l = ctx.state.queries.get('a')!.lifecycle;
167
+ seen.push([l.fetchDepth, l.backgroundDepth]);
168
+ return happy();
169
+ },
170
+ 'local.upsert': () => undefined,
171
+ },
172
+ });
173
+ expect(seen).toEqual([[1, background]]);
174
+ expect(out.state.queries.get('a')!.lifecycle).toMatchObject({ fetchDepth: 0, backgroundDepth: 0 });
175
+ }
176
+ });
159
177
  it('guards: missing entry, already registered / failed, registering without retry', async () => {
160
178
  const none = await runPure(registerRemote(env, 'zzz'), { state: buildState([]) });
161
179
  expect(none.log.filter((x) => x.kind === 'remote.query')).toHaveLength(0);
@@ -151,8 +151,11 @@ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): S
151
151
  if (!entry) return;
152
152
  const { remote } = entry.lifecycle;
153
153
  if (remote === 'registered' || remote === 'failed' || (remote === 'registering' && !retry)) return;
154
+ // A query that already shows rows (cached or live) only re-verifies them
155
+ // here: the cycle holds `fetching` but is not traffic the user waits on.
156
+ const background = entry.lifecycle.phase !== 'cold';
154
157
  yield fx.state.update(
155
- R.compose(R.applyLifecycle(hash, { type: 'remote-registering' }), R.applyLifecycle(hash, { type: 'fetch-begin' }))
158
+ R.compose(R.applyLifecycle(hash, { type: 'remote-registering' }), R.applyLifecycle(hash, { type: 'fetch-begin', background }))
156
159
  );
157
160
  try {
158
161
  const table = listRefTable(env, state);
@@ -221,6 +224,6 @@ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): S
221
224
  }
222
225
  } finally {
223
226
  const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
224
- if (stillThere) yield fx.state.update(R.applyLifecycle(hash, { type: 'fetch-end' }));
227
+ if (stillThere) yield fx.state.update(R.applyLifecycle(hash, { type: 'fetch-end', background }));
225
228
  }
226
229
  }
@@ -50,6 +50,24 @@ describe('query sql builders (golden against the previous builders)', () => {
50
50
  expect(sql.heartbeatRowGone([{ id: a }])).toBe(false);
51
51
  expect(sql.heartbeatRowGone(undefined)).toBe(false);
52
52
  });
53
+ it('the pagehide beacon names the page sessions and inlines only shapes it minted', () => {
54
+ const a = new RecordId('_00_query', 'a'.repeat(64));
55
+ const b = new RecordId('_00_query', 'b'.repeat(64));
56
+ const s1 = 'de81a067-f4bc-4351-a2a3-1ecf06b6305a';
57
+ const s2 = '8A14ED5A-32B5-4B4E-973D-D2B88860932F';
58
+ expect(sql.leaveViewsBeacon([a, b], [s1, s2])).toBe(
59
+ `FOR $id IN [${a}, ${b}] { LET $_left = fn::query::leave($id, ['${s1}', '${s2}']); };`
60
+ );
61
+ const digits = new RecordId('_00_query', '1'.repeat(64));
62
+ expect(sql.leaveViewsBeacon([digits], [s1])).toBe(`FOR $id IN [${digits}] { LET $_left = fn::query::leave($id, ['${s1}']); };`);
63
+ // Anything else is dropped rather than interpolated.
64
+ expect(sql.leaveViewsBeacon([a, new RecordId('_00_query', "x']; DELETE user; --"), 'user:1'], [s1, "x'; DELETE user; --"])).toBe(
65
+ `FOR $id IN [${a}] { LET $_left = fn::query::leave($id, ['${s1}']); };`
66
+ );
67
+ // No session ids: the server could not tell this page's entries from a reload's, so send nothing.
68
+ expect(sql.leaveViewsBeacon([a], [])).toBeNull();
69
+ expect(sql.leaveViewsBeacon([], [s1])).toBeNull();
70
+ });
53
71
  it('upsertBodiesTx MERGEs each body inside one transaction', () => {
54
72
  const { query, vars } = sql.upsertBodiesTx([
55
73
  { id: 'thing:1', content: { a: 1 } },
package/src/query/sql.ts CHANGED
@@ -76,6 +76,25 @@ export function heartbeatBatch(ids: ReadonlyArray<RecordId<string>>): { sql: str
76
76
  /** A heartbeat answer whose statement matched no row: the view was reclaimed. */
77
77
  export const heartbeatRowGone = (result: unknown): boolean => Array.isArray(result) && result.length === 0;
78
78
 
79
+ // The SDK escapes an all-digit key as `⟨…⟩`; both forms parse back to the same id.
80
+ const QUERY_ID = /^_00_query:(?:[0-9a-f]{64}|⟨[0-9a-f]{64}⟩)$/;
81
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
82
+
83
+ /**
84
+ * The pagehide release: one bare statement for `beaconSql`, which has no params
85
+ * channel, so ids are inlined. Only shapes the client minted itself get in, a
86
+ * `_00_query:<sha256>` id and a UUID session id, so nothing else is ever
87
+ * interpolated. `null` when there is nothing the server could act on: with no
88
+ * session ids it could not tell this page's entries from anyone else's.
89
+ */
90
+ export function leaveViewsBeacon(ids: ReadonlyArray<unknown>, sessionIds: ReadonlyArray<string>): string | null {
91
+ const list = ids.map((id) => String(id)).filter((id) => QUERY_ID.test(id));
92
+ const sids = sessionIds.filter((sid) => SESSION_ID.test(sid));
93
+ if (list.length === 0 || sids.length === 0) return null;
94
+ const sidList = sids.map((sid) => `'${sid}'`).join(', ');
95
+ return `FOR $id IN [${list.join(', ')}] { LET $_left = fn::query::leave($id, [${sidList}]); };`;
96
+ }
97
+
79
98
  export const bodySelect = (): string => 'SELECT * FROM $ids';
80
99
 
81
100
  /** Local transaction that MERGEs many fetched bodies. */
@@ -37,6 +37,12 @@ export type RemoteConnectionEvent = ConnectionState | 'error';
37
37
  */
38
38
  const REMOTE_MAX_CONCURRENT_QUERIES = 6;
39
39
 
40
+ /**
41
+ * Session ids a page remembers for its pagehide release. Only the sessions of
42
+ * the last ttl matter; this many reconnects inside one ttl is a broken network.
43
+ */
44
+ const MAX_SESSION_IDS = 16;
45
+
40
46
  export class RemoteDatabaseService extends AbstractDatabaseService {
41
47
  private config: Sp00kyConfig<any>['database'];
42
48
  protected eventType = DatabaseEventTypes.RemoteQuery;
@@ -67,6 +73,17 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
67
73
  * returns nothing, on a page that still looks signed in.
68
74
  */
69
75
  private authToken: string | null = null;
76
+ /**
77
+ * Every `session::id()` this page's sockets have had, oldest first.
78
+ *
79
+ * The server stamps a view's `subscribers` entry with the session that
80
+ * registered or heartbeated it, and every socket (an SDK reconnect, a
81
+ * supervisor revive) is a new session. The pagehide release goes over HTTP,
82
+ * whose own session id matches none of them, so it names these instead. Kept
83
+ * for the page, not the socket: a view registered before a reconnect still
84
+ * carries the old id until its next heartbeat.
85
+ */
86
+ private sessionIdsSeen: string[] = [];
70
87
 
71
88
  constructor(config: Sp00kyConfig<any>['database'], logger: Logger) {
72
89
  const events = createDatabaseEventSystem();
@@ -98,6 +115,27 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
98
115
  this.reconnectConfig = resolveReconnectConfig(config.reconnect);
99
116
  this.queryTimeoutMs = Math.max(0, config.queryTimeoutMs ?? 60_000);
100
117
  this.maxConcurrentQueries = REMOTE_MAX_CONCURRENT_QUERIES;
118
+ this.client.subscribe('connected', () => void this.recordSessionId());
119
+ }
120
+
121
+ /** See {@link sessionIdsSeen}. */
122
+ sessionIds(): readonly string[] {
123
+ return this.sessionIdsSeen;
124
+ }
125
+
126
+ /**
127
+ * Ask the socket that just connected for its session id. Straight to the
128
+ * SDK: it needs no namespace or identity, so it need not wait behind an
129
+ * in-flight connect or take a slot from the query queue.
130
+ */
131
+ private async recordSessionId(): Promise<void> {
132
+ try {
133
+ const [sid] = (await this.client.query('RETURN <string>session::id()')) as unknown[];
134
+ if (typeof sid !== 'string' || this.sessionIdsSeen.includes(sid)) return;
135
+ this.sessionIdsSeen = [...this.sessionIdsSeen, sid].slice(-MAX_SESSION_IDS);
136
+ } catch {
137
+ // Without it this socket's views fall back to the TTL sweep.
138
+ }
101
139
  }
102
140
 
103
141
  getConfig(): Sp00kyConfig<any>['database'] {
@@ -116,6 +154,10 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
116
154
  *
117
155
  * Best-effort by design: no await, no retry, errors swallowed. Every caller
118
156
  * must have a server-side fallback that makes a lost beacon a non-event.
157
+ *
158
+ * The statement runs in a fresh HTTP session: `session::id()` there is a new
159
+ * id, not this page's socket's, so anything keyed on it must be passed the
160
+ * ids from {@link sessionIds} instead.
119
161
  */
120
162
  beaconSql(sql: string): void {
121
163
  try {
@@ -1,11 +1,12 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { LifecycleEvent, QueryLifecycle, QueryPhase } from './lifecycle';
3
- import { deriveStatus, hasServerMembership, isAuthoritative, LifecycleError, seedLifecycle, transition } from './lifecycle';
3
+ import { deriveStatus, hasServerMembership, isAuthoritative, isForegroundFetching, LifecycleError, seedLifecycle, transition } from './lifecycle';
4
4
 
5
5
  const at = (phase: QueryPhase, over: Partial<QueryLifecycle> = {}): QueryLifecycle => ({
6
6
  phase,
7
7
  remote: 'unregistered',
8
8
  fetchDepth: 0,
9
+ backgroundDepth: 0,
9
10
  notified: false,
10
11
  ...over,
11
12
  });
@@ -33,7 +34,7 @@ describe('transition table (phase axis)', () => {
33
34
  });
34
35
  it('bucket-switch reseeds and clears fetch depth', () => {
35
36
  const l = transition(at('live', { fetchDepth: 3, notified: true }), { type: 'bucket-switch', resolvedBefore: true });
36
- expect(l).toEqual({ phase: 'cached', remote: 'unregistered', fetchDepth: 0, notified: false });
37
+ expect(l).toEqual({ phase: 'cached', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false });
37
38
  });
38
39
  });
39
40
 
@@ -63,6 +64,24 @@ describe('remote / fetch / notified axes', () => {
63
64
  const same = transition(l, { type: 'fetch-end' });
64
65
  expect(same).toBe(l);
65
66
  });
67
+ it('background cycles hold fetching but are not foreground', () => {
68
+ let l = at('cached');
69
+ l = transition(l, { type: 'fetch-begin', background: true });
70
+ expect(deriveStatus(l)).toBe('fetching');
71
+ expect(isForegroundFetching(l)).toBe(false);
72
+ l = transition(l, { type: 'fetch-begin' });
73
+ expect(isForegroundFetching(l)).toBe(true);
74
+ l = transition(l, { type: 'fetch-end' });
75
+ expect(l).toMatchObject({ fetchDepth: 1, backgroundDepth: 1 });
76
+ expect(isForegroundFetching(l)).toBe(false);
77
+ l = transition(l, { type: 'fetch-end', background: true });
78
+ expect(l).toMatchObject({ fetchDepth: 0, backgroundDepth: 0 });
79
+ expect(deriveStatus(l)).toBe('idle');
80
+ });
81
+ it('an unbalanced end never leaves more background cycles than cycles', () => {
82
+ const l = transition(at('live', { fetchDepth: 1, backgroundDepth: 1 }), { type: 'fetch-end' });
83
+ expect(l).toMatchObject({ fetchDepth: 0, backgroundDepth: 0 });
84
+ });
66
85
  it('notified is idempotent', () => {
67
86
  const l = transition(at('live'), { type: 'notified' });
68
87
  expect(transition(l, { type: 'notified' })).toBe(l);
@@ -89,6 +108,8 @@ describe('invariants over random event sequences', () => {
89
108
  { type: 'remote-dropped' },
90
109
  { type: 'fetch-begin' },
91
110
  { type: 'fetch-end' },
111
+ { type: 'fetch-begin', background: true },
112
+ { type: 'fetch-end', background: true },
92
113
  { type: 'notified' },
93
114
  { type: 'bucket-switch', resolvedBefore: false },
94
115
  { type: 'seed', resolvedBefore: true },
@@ -97,7 +118,7 @@ describe('invariants over random event sequences', () => {
97
118
  let seed = 42;
98
119
  const rand = () => (seed = (seed * 1664525 + 1013904223) % 2 ** 32) / 2 ** 32;
99
120
 
100
- it('fetchDepth >= 0, notified only after live-or-cached, view-lost never from cold', () => {
121
+ it('0 <= backgroundDepth <= fetchDepth, notified only after live-or-cached, view-lost never from cold', () => {
101
122
  for (let run = 0; run < 200; run++) {
102
123
  let l = seedLifecycle(rand() < 0.5);
103
124
  for (let i = 0; i < 25; i++) {
@@ -110,6 +131,8 @@ describe('invariants over random event sequences', () => {
110
131
  continue;
111
132
  }
112
133
  expect(l.fetchDepth).toBeGreaterThanOrEqual(0);
134
+ expect(l.backgroundDepth).toBeGreaterThanOrEqual(0);
135
+ expect(l.backgroundDepth).toBeLessThanOrEqual(l.fetchDepth);
113
136
  if (ev.type === 'row-missing' && before.phase === 'cold') expect(l.phase).toBe('cold');
114
137
  if (l.phase === 'view-lost') expect(before.phase).not.toBe('cold');
115
138
  }
@@ -14,6 +14,11 @@ import type { QueryStatus } from '../types';
14
14
  * `remote` tracks the server-side registration, `fetchDepth` is the refcount
15
15
  * behind `status: 'fetching'`, `notified` says whether subscribers received
16
16
  * at least one materialization this registration.
17
+ *
18
+ * `backgroundDepth` is the part of `fetchDepth` that re-verifies rows the
19
+ * query already shows: a registration of a query that is not `cold`. It still
20
+ * holds `status: 'fetching'` (results are not yet confirmed), but it is not
21
+ * traffic the user waits on, so the activity indicator leaves it out.
17
22
  */
18
23
  export type QueryPhase = 'cold' | 'cached' | 'live' | 'view-lost';
19
24
  export type RemotePhase = 'unregistered' | 'registering' | 'registered' | 'failed';
@@ -22,6 +27,7 @@ export interface QueryLifecycle {
22
27
  readonly phase: QueryPhase;
23
28
  readonly remote: RemotePhase;
24
29
  readonly fetchDepth: number;
30
+ readonly backgroundDepth: number;
25
31
  readonly notified: boolean;
26
32
  }
27
33
 
@@ -33,8 +39,8 @@ export type LifecycleEvent =
33
39
  | { type: 'remote-registered' }
34
40
  | { type: 'remote-failed' }
35
41
  | { type: 'remote-dropped' }
36
- | { type: 'fetch-begin' }
37
- | { type: 'fetch-end' }
42
+ | { type: 'fetch-begin'; background?: boolean }
43
+ | { type: 'fetch-end'; background?: boolean }
38
44
  | { type: 'notified' }
39
45
  | { type: 'bucket-switch'; resolvedBefore: boolean };
40
46
 
@@ -46,7 +52,7 @@ export class LifecycleError extends Error {
46
52
  }
47
53
 
48
54
  export function seedLifecycle(resolvedBefore: boolean): QueryLifecycle {
49
- return { phase: resolvedBefore ? 'cached' : 'cold', remote: 'unregistered', fetchDepth: 0, notified: false };
55
+ return { phase: resolvedBefore ? 'cached' : 'cold', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false };
50
56
  }
51
57
 
52
58
  /** Total on every (lifecycle, event) pair the sagas can produce; throws on the rest. */
@@ -70,9 +76,14 @@ export function transition(l: QueryLifecycle, ev: LifecycleEvent): QueryLifecycl
70
76
  case 'remote-dropped':
71
77
  return { ...l, remote: 'unregistered', notified: false };
72
78
  case 'fetch-begin':
73
- return { ...l, fetchDepth: l.fetchDepth + 1 };
74
- case 'fetch-end':
75
- return l.fetchDepth === 0 ? l : { ...l, fetchDepth: l.fetchDepth - 1 };
79
+ return { ...l, fetchDepth: l.fetchDepth + 1, backgroundDepth: l.backgroundDepth + (ev.background ? 1 : 0) };
80
+ case 'fetch-end': {
81
+ if (l.fetchDepth === 0) return l;
82
+ const fetchDepth = l.fetchDepth - 1;
83
+ // Never more background cycles than cycles: an unbalanced end clamps.
84
+ const backgroundDepth = Math.min(fetchDepth, l.backgroundDepth - (ev.background && l.backgroundDepth > 0 ? 1 : 0));
85
+ return { ...l, fetchDepth, backgroundDepth };
86
+ }
76
87
  case 'notified':
77
88
  return l.notified ? l : { ...l, notified: true };
78
89
  case 'bucket-switch':
@@ -84,3 +95,5 @@ export const isAuthoritative = (l: QueryLifecycle): boolean => l.phase !== 'cold
84
95
  export const hasServerMembership = (l: QueryLifecycle): boolean =>
85
96
  l.phase === 'live' || l.phase === 'view-lost';
86
97
  export const deriveStatus = (l: QueryLifecycle): QueryStatus => (l.fetchDepth > 0 ? 'fetching' : 'idle');
98
+ /** A fetch cycle the user is waiting on: rows to download, or a first registration. */
99
+ export const isForegroundFetching = (l: QueryLifecycle): boolean => l.fetchDepth > l.backgroundDepth;
@@ -247,7 +247,7 @@ describe('bucket switch reducers', () => {
247
247
  R.outboxReplace([buildOutboxItem()])
248
248
  );
249
249
  const id = { table: '_00_query', id: 'new' } as any;
250
- const s1 = R.rebindQuery('a', { id, lifecycle: { phase: 'cached', remote: 'unregistered', fetchDepth: 0, notified: false }, remoteArray: [['t:2', 1]], localArray: [] })({ ...s0, dirty: new Set() });
250
+ const s1 = R.rebindQuery('a', { id, lifecycle: { phase: 'cached', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false }, remoteArray: [['t:2', 1]], localArray: [] })({ ...s0, dirty: new Set() });
251
251
  const en = s1.queries.get('a')!;
252
252
  expect(en.def.id).toBe(id);
253
253
  expect(en.lifecycle.phase).toBe('cached');
@@ -81,6 +81,14 @@ describe('needed / planFetch / settled', () => {
81
81
  const s = buildState([e('a', { lifecycle: { fetchDepth: 2 } }), e('b')]);
82
82
  expect(S.fetchingQueryCount(s)).toBe(1);
83
83
  });
84
+ it('fetchingQueryCount leaves out background re-verification', () => {
85
+ const s = buildState([
86
+ e('verifying', { lifecycle: { phase: 'cached', fetchDepth: 1, backgroundDepth: 1 } }),
87
+ e('downloading', { lifecycle: { phase: 'live', fetchDepth: 2, backgroundDepth: 1 } }),
88
+ ]);
89
+ expect(S.fetchingQueryCount(s)).toBe(1);
90
+ expect(S.queryStatus(s, 'verifying')).toBe('fetching');
91
+ });
84
92
  });
85
93
 
86
94
  describe('registration / eviction / ttl', () => {
@@ -1,6 +1,6 @@
1
1
  import type { PhaseStat, QueryHash, QueryState, QueryStatus, QueryTimings, RecordVersionArray } from '../types';
2
2
  import type { ClientState, QueryEntry } from './client-state';
3
- import { deriveStatus, hasServerMembership, isAuthoritative } from './lifecycle';
3
+ import { deriveStatus, hasServerMembership, isAuthoritative, isForegroundFetching } from './lifecycle';
4
4
  import { FETCH_CHUNK } from '../kernel/constants';
5
5
 
6
6
  /** Pure reads over `ClientState`. Sagas use them through `state.read`. */
@@ -75,8 +75,14 @@ export const hasAckedWrites = (s: ClientState): boolean => s.outbox.some((i) =>
75
75
  export const pendingMutationCount = (s: ClientState): number =>
76
76
  s.outbox.filter((i) => i.status === 'pending').length;
77
77
 
78
+ /**
79
+ * Queries with traffic the user is waiting on. A re-registration of a query
80
+ * that already shows rows (every mounted query after a reload) is left out:
81
+ * it only confirms what the cache painted, and any newer body it finds is
82
+ * fetched by `fetchRows`, which counts.
83
+ */
78
84
  export const fetchingQueryCount = (s: ClientState): number =>
79
- [...s.queries.values()].filter((e) => e.lifecycle.fetchDepth > 0).length;
85
+ [...s.queries.values()].filter((e) => isForegroundFetching(e.lifecycle)).length;
80
86
 
81
87
  /** Membership entries whose body is missing or older locally. */
82
88
  export function needed(s: ClientState, hash: QueryHash): RecordVersionArray {