@spooky-sync/core 0.0.1-canary.201 → 0.0.1-canary.203

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/dist/index.d.ts CHANGED
@@ -1471,6 +1471,24 @@ declare class AuthService<S extends SchemaStructure> {
1471
1471
  */
1472
1472
  subscribe(cb: (userId: string | null) => void): () => void;
1473
1473
  private notifyListeners;
1474
+ /**
1475
+ * Restore a session from the locally cached JWT, with NO network.
1476
+ *
1477
+ * This is what makes a warm boot paint instantly and what makes an offline
1478
+ * boot possible at all: the token is in local storage, and it already carries
1479
+ * both the access method and the `$auth.id` record id. Everything the client
1480
+ * needs to route queries (`setCurrentUserId`) and to satisfy `$auth`-gated
1481
+ * permission predicates in the in-browser SSP (`setSessionAuth`) is therefore
1482
+ * available before a socket exists.
1483
+ *
1484
+ * The session is OPTIMISTIC: the token is unverified here. `check()` runs
1485
+ * afterwards in the background and downgrades to a real sign-out if the
1486
+ * server rejects it. Nothing is trusted that the server has not also seen -
1487
+ * the local store only ever holds rows the server previously sent.
1488
+ *
1489
+ * Returns the restored user id, or null when there is no usable token.
1490
+ */
1491
+ restoreSessionFromToken(): Promise<string | null>;
1474
1492
  /**
1475
1493
  * Check for existing session and validate
1476
1494
  */
@@ -2166,6 +2184,15 @@ declare class Sp00kyClient<S extends SchemaStructure> {
2166
2184
  private sync;
2167
2185
  private devTools;
2168
2186
  private crdtManager;
2187
+ /**
2188
+ * True once the LOCAL half of boot is done and the client can serve reads
2189
+ * from the local store. Distinct from being connected: `syncHealth` covers
2190
+ * reaching the server and `storageHealth` covers whether the local store is
2191
+ * durable, but neither says "usable". Consumers gate their first paint on
2192
+ * this, which is what makes a warm boot instant and an offline boot possible.
2193
+ */
2194
+ private localReady;
2195
+ private saltUserId;
2169
2196
  private featureFlags;
2170
2197
  private appReleases;
2171
2198
  private preloadedHashes;
@@ -2211,6 +2238,16 @@ declare class Sp00kyClient<S extends SchemaStructure> {
2211
2238
  */
2212
2239
  private setupCallbacks;
2213
2240
  init(): Promise<void>;
2241
+ /**
2242
+ * The network half of boot: connect, verify the restored session, and let the
2243
+ * sync engine catch up. Runs in the background after `init()` has already
2244
+ * resolved, so nothing here is on the paint path.
2245
+ *
2246
+ * Every step is best-effort. A failure leaves the client in exactly the state
2247
+ * a warm offline boot is in - local reads working, writes queued in the
2248
+ * outbox - and the connection supervisor keeps retrying underneath.
2249
+ */
2250
+ private initRemote;
2214
2251
  private bucketSwitchChain;
2215
2252
  private pendingBucketTarget;
2216
2253
  /**
@@ -2363,14 +2400,38 @@ declare class Sp00kyClient<S extends SchemaStructure> {
2363
2400
  [x: string]: /*elided*/any;
2364
2401
  }>;
2365
2402
  delete(table: string, id: string): Promise<void>;
2366
- useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T>;
2367
2403
  /**
2368
- * Fetch SurrealDB's `session::id()` as a string. Used as a salt for
2369
- * query-id hashing so two sessions for the same user get distinct
2370
- * `_00_query` rows. Returns empty string if the query fails (we still
2371
- * boot, just without session scoping for IDs).
2404
+ * Whether the local store is initialized and reads can be served. See the
2405
+ * `localReady` field: this is deliberately independent of connectivity.
2372
2406
  */
2373
- private fetchSessionId;
2407
+ isLocalReady(): boolean;
2408
+ useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T>;
2409
+ /**
2410
+ * Mint the salt used for query-id hashing, so two sessions registering the
2411
+ * same logical query get distinct `_00_query` rows.
2412
+ *
2413
+ * Generated LOCALLY, deliberately. This used to be `RETURN <string>session::id()`,
2414
+ * which cost a serial round trip on the critical boot path and resolved to
2415
+ * `''` offline. The value never needed to come from the server: the server
2416
+ * derives its own `clientId` inside `fn::query::register` and *ignores*
2417
+ * whatever the caller passed, and the permission rules that matter gate on
2418
+ * `auth_id = $auth.id` rather than the session (`_00_list_ref`). Session
2419
+ * scoping via `clientId = session::id()` was in fact removed upstream because
2420
+ * it broke a user with two tabs open. All this value has to be is unique per
2421
+ * browser session, which `randomUUID` gives us for free and offline.
2422
+ */
2423
+ /**
2424
+ * The current principal as the `"table:id"` string the in-browser SSP wants
2425
+ * for `$auth.id`, or null when signed out.
2426
+ *
2427
+ * Tolerates BOTH shapes `currentUser.id` can take, which is the point:
2428
+ * a session restored from the cached token carries a plain string (the JWT's
2429
+ * `ID` claim), while one verified by the server carries a RecordId. Passing
2430
+ * the former to `encodeRecordId` reads `.table` off a string and throws
2431
+ * during boot.
2432
+ */
2433
+ private sessionAuthId;
2434
+ private mintSessionSalt;
2374
2435
  }
2375
2436
  //#endregion
2376
2437
  //#region src/utils/semver.d.ts
package/dist/index.js CHANGED
@@ -5873,6 +5873,7 @@ var Sp00kySync = class Sp00kySync {
5873
5873
  if (this.isInit) throw new Error("Sp00kySync is already initialized");
5874
5874
  this.isInit = true;
5875
5875
  await this.scheduler.init({ loadOutbox: this.tabRole !== "follower" });
5876
+ if (this.remote.getStatus() !== "connected") this.needsResubscribe = true;
5876
5877
  this.subscribeToReconnect();
5877
5878
  this.subscribeToConnectionState();
5878
5879
  this.scheduler.syncUp();
@@ -7093,8 +7094,8 @@ function selfAllowlistedVariant(flag, userId) {
7093
7094
 
7094
7095
  //#endregion
7095
7096
  //#region src/modules/devtools/index.ts
7096
- const CORE_VERSION = "0.0.1-canary.201";
7097
- const WASM_VERSION = "0.0.1-canary.201";
7097
+ const CORE_VERSION = "0.0.1-canary.203";
7098
+ const WASM_VERSION = "0.0.1-canary.203";
7098
7099
  const SURREAL_VERSION = "3.0.3";
7099
7100
  var DevToolsService = class DevToolsService {
7100
7101
  eventsHistory = [];
@@ -7579,26 +7580,44 @@ var DevToolsService = class DevToolsService {
7579
7580
  //#endregion
7580
7581
  //#region src/modules/auth/index.ts
7581
7582
  /**
7582
- * Read the `AC` (access-method name) claim from a SurrealDB record-access
7583
- * JWT without verifying it we only need the claim, the server enforces the
7584
- * token. Returns null on any malformed input. The in-browser SSP needs this
7585
- * to resolve `$access` in table permission predicates (mirrors the session's
7586
- * `$access` that the server's `fn::query::register` reads).
7583
+ * Read the claims of a SurrealDB record-access JWT WITHOUT verifying it. The
7584
+ * server still enforces the token on every request; this is only so the client
7585
+ * can act on what it already holds before a round trip completes.
7586
+ *
7587
+ * `AC` is the access-method name — the in-browser SSP needs it to resolve
7588
+ * `$access` in table permission predicates (mirrors the session's `$access`
7589
+ * that the server's `fn::query::register` reads). `ID` is the `$auth.id` record
7590
+ * id, which is what lets a warm boot restore a session locally.
7591
+ *
7592
+ * Returns nulls on any malformed input.
7587
7593
  */
7588
- function decodeAccessFromToken(token) {
7594
+ function decodeTokenClaims(token) {
7589
7595
  try {
7590
7596
  const payload = token.split(".")[1];
7591
- if (!payload) return null;
7597
+ if (!payload) return {
7598
+ access: null,
7599
+ userId: null
7600
+ };
7592
7601
  let b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
7593
7602
  b64 += "=".repeat((4 - b64.length % 4) % 4);
7594
7603
  const json = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("binary");
7595
7604
  const claims = JSON.parse(json);
7596
7605
  const ac = claims.AC ?? claims.ac;
7597
- return typeof ac === "string" ? ac : null;
7606
+ const id = claims.ID ?? claims.id;
7607
+ return {
7608
+ access: typeof ac === "string" ? ac : null,
7609
+ userId: typeof id === "string" ? id : null
7610
+ };
7598
7611
  } catch {
7599
- return null;
7612
+ return {
7613
+ access: null,
7614
+ userId: null
7615
+ };
7600
7616
  }
7601
7617
  }
7618
+ function decodeAccessFromToken(token) {
7619
+ return decodeTokenClaims(token).access;
7620
+ }
7602
7621
  var AuthService = class {
7603
7622
  token = null;
7604
7623
  currentUser = null;
@@ -7645,6 +7664,39 @@ var AuthService = class {
7645
7664
  this.events.emit(AuthEventTypes.AuthStateChanged, userId);
7646
7665
  }
7647
7666
  /**
7667
+ * Restore a session from the locally cached JWT, with NO network.
7668
+ *
7669
+ * This is what makes a warm boot paint instantly and what makes an offline
7670
+ * boot possible at all: the token is in local storage, and it already carries
7671
+ * both the access method and the `$auth.id` record id. Everything the client
7672
+ * needs to route queries (`setCurrentUserId`) and to satisfy `$auth`-gated
7673
+ * permission predicates in the in-browser SSP (`setSessionAuth`) is therefore
7674
+ * available before a socket exists.
7675
+ *
7676
+ * The session is OPTIMISTIC: the token is unverified here. `check()` runs
7677
+ * afterwards in the background and downgrades to a real sign-out if the
7678
+ * server rejects it. Nothing is trusted that the server has not also seen -
7679
+ * the local store only ever holds rows the server previously sent.
7680
+ *
7681
+ * Returns the restored user id, or null when there is no usable token.
7682
+ */
7683
+ async restoreSessionFromToken() {
7684
+ const token = await this.persistenceClient.get("sp00ky_auth_token");
7685
+ if (!token) return null;
7686
+ const { access, userId } = decodeTokenClaims(token);
7687
+ if (!userId) return null;
7688
+ this.token = token;
7689
+ this.currentUser = { id: userId };
7690
+ this.isAuthenticated = true;
7691
+ this.access = access ?? this.defaultAccessName();
7692
+ this.notifyListeners();
7693
+ this.logger.debug({
7694
+ userId,
7695
+ Category: "sp00ky-client::AuthService::restoreSessionFromToken"
7696
+ }, "Session restored optimistically from cached token");
7697
+ return userId;
7698
+ }
7699
+ /**
7648
7700
  * Check for existing session and validate
7649
7701
  */
7650
7702
  async check(accessToken) {
@@ -7685,12 +7737,18 @@ var AuthService = class {
7685
7737
  }
7686
7738
  }
7687
7739
  } catch (error) {
7688
- this.logger.error({
7740
+ if (classifySyncError(error) === "network") this.logger.warn({
7689
7741
  error,
7690
- stack: error.stack,
7691
7742
  Category: "sp00ky-client::AuthService::check"
7692
- }, "Auth check failed");
7693
- await this.signOut();
7743
+ }, "Auth check unreachable; keeping the cached session and retrying later");
7744
+ else {
7745
+ this.logger.error({
7746
+ error,
7747
+ stack: error.stack,
7748
+ Category: "sp00ky-client::AuthService::check"
7749
+ }, "Auth check failed");
7750
+ await this.signOut();
7751
+ }
7694
7752
  } finally {
7695
7753
  this.isLoading = false;
7696
7754
  }
@@ -7836,6 +7894,51 @@ var StreamProcessorService = class {
7836
7894
  */
7837
7895
  ingestMany(records) {
7838
7896
  if (records.length === 0) return;
7897
+ if (!this.processor) {
7898
+ this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::ingestMany" }, "Not initialized, skipping ingest");
7899
+ return;
7900
+ }
7901
+ const bulkIngest = this.processor.ingest_many;
7902
+ if (typeof bulkIngest === "function") {
7903
+ this.logger.debug({
7904
+ count: records.length,
7905
+ Category: "sp00ky-client::StreamProcessorService::ingestMany"
7906
+ }, "Ingesting batch into ssp");
7907
+ try {
7908
+ const items = records.map((record) => ({
7909
+ table: record.table,
7910
+ op: record.op,
7911
+ id: record.id,
7912
+ record: this.normalizeValue(record.record)
7913
+ }));
7914
+ const t0 = performance.now();
7915
+ const rawUpdates = bulkIngest.call(this.processor, items) ?? [];
7916
+ const materializationTimeMs = performance.now() - t0;
7917
+ this.logger.debug({
7918
+ count: records.length,
7919
+ rawUpdates: rawUpdates.length,
7920
+ materializationTimeMs,
7921
+ Category: "sp00ky-client::StreamProcessorService::ingestMany"
7922
+ }, "Ingesting batch into ssp done");
7923
+ if (rawUpdates.length > 0) this.dispatchUpdates(rawUpdates.map((u) => ({
7924
+ queryHash: u.query_id,
7925
+ localArray: u.result_data,
7926
+ op: "CREATE",
7927
+ materializationTimeMs,
7928
+ storeApplyMs: u.timing_store_apply_ms,
7929
+ circuitStepMs: u.timing_circuit_step_ms,
7930
+ transformMs: u.timing_transform_ms
7931
+ })));
7932
+ } catch (e) {
7933
+ this.logger.error({
7934
+ error: e,
7935
+ count: records.length,
7936
+ Category: "sp00ky-client::StreamProcessorService::ingestMany"
7937
+ }, "Ingesting batch into ssp failed");
7938
+ }
7939
+ this.markSnapshotDirty();
7940
+ return;
7941
+ }
7839
7942
  this.beginCoalescing();
7840
7943
  try {
7841
7944
  for (const record of records) this.ingest(record.table, record.op, record.id, record.record);
@@ -8363,14 +8466,12 @@ var CacheModule = class {
8363
8466
  const query = surql.seal(surql.tx(populatedRecords.map((_, i) => {
8364
8467
  return surql.upsertMerge(`id${i}`, `content${i}`);
8365
8468
  })));
8366
- const params = populatedRecords.reduce((acc, record, i) => {
8367
- const { id, ...content } = record.record;
8368
- return {
8369
- ...acc,
8370
- [`id${i}`]: id,
8371
- [`content${i}`]: content
8372
- };
8373
- }, {});
8469
+ const params = {};
8470
+ for (let i = 0; i < populatedRecords.length; i++) {
8471
+ const { id, ...content } = populatedRecords[i].record;
8472
+ params[`id${i}`] = id;
8473
+ params[`content${i}`] = content;
8474
+ }
8374
8475
  await this.local.execute(query, params, { epoch });
8375
8476
  }
8376
8477
  if (this.local.epoch !== epoch) throw new StaleEpochError();
@@ -11506,6 +11607,15 @@ var Sp00kyClient = class {
11506
11607
  sync;
11507
11608
  devTools;
11508
11609
  crdtManager;
11610
+ /**
11611
+ * True once the LOCAL half of boot is done and the client can serve reads
11612
+ * from the local store. Distinct from being connected: `syncHealth` covers
11613
+ * reaching the server and `storageHealth` covers whether the local store is
11614
+ * durable, but neither says "usable". Consumers gate their first paint on
11615
+ * this, which is what makes a warm boot instant and an offline boot possible.
11616
+ */
11617
+ localReady = false;
11618
+ saltUserId = null;
11509
11619
  featureFlags;
11510
11620
  appReleases;
11511
11621
  preloadedHashes = /* @__PURE__ */ new Set();
@@ -11662,7 +11772,7 @@ var Sp00kyClient = class {
11662
11772
  return new TabsCoordinator({
11663
11773
  tabId,
11664
11774
  fingerprint: computeTabsFingerprint({
11665
- coreVersion: "0.0.1-canary.201",
11775
+ coreVersion: "0.0.1-canary.203",
11666
11776
  schemaHash: hash53(this.config.schemaSurql),
11667
11777
  endpoint: this.config.database.endpoint ?? "",
11668
11778
  namespace: this.config.database.namespace,
@@ -11805,9 +11915,6 @@ var Sp00kyClient = class {
11805
11915
  await this.migrator.provision(this.config.schemaSurql);
11806
11916
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Schema provisioned");
11807
11917
  }
11808
- await this.remote.connect();
11809
- this.connectionSupervisor.start();
11810
- this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Remote database connected");
11811
11918
  (async () => {
11812
11919
  try {
11813
11920
  this.blobs.setMaxBytes(await resolveBlobBudget(this.config.blobCache?.maxBytes));
@@ -11823,23 +11930,31 @@ var Sp00kyClient = class {
11823
11930
  await this.streamProcessor.init();
11824
11931
  this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
11825
11932
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "StreamProcessor initialized");
11826
- await this.auth.init();
11827
- this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Auth initialized");
11828
- const sessionId = await this.fetchSessionId();
11933
+ const restoredUserId = await this.auth.restoreSessionFromToken();
11934
+ const sessionId = this.mintSessionSalt();
11935
+ this.saltUserId = restoredUserId;
11829
11936
  await this.dataModule.init(sessionId);
11830
11937
  this.crdtManager.setSessionId(sessionId);
11938
+ if (restoredUserId) {
11939
+ this.dataModule.setCurrentUserId(restoredUserId);
11940
+ this.streamProcessor.setSessionAuth(this.sessionAuthId(), this.auth.access);
11941
+ }
11831
11942
  this.logger.debug({
11832
11943
  sessionId,
11833
11944
  Category: "sp00ky-client::Sp00kyClient::init"
11834
11945
  }, "DataModule initialized");
11835
11946
  this.auth.subscribe(async (userId) => {
11836
11947
  this.dataModule.setCurrentUserId(userId);
11837
- this.streamProcessor.setSessionAuth(this.auth.currentUser?.id ? encodeRecordId(this.auth.currentUser.id) : null, this.auth.access);
11948
+ this.streamProcessor.setSessionAuth(this.sessionAuthId(), this.auth.access);
11838
11949
  writeBootBucketHint(bucketIdForUser(userId));
11839
11950
  await this.ensureLocalBucket(userId);
11840
- const next = await this.fetchSessionId();
11841
- this.dataModule.setSessionId(next);
11842
- this.crdtManager.setSessionId(next);
11951
+ const saltFor = this.sessionAuthId();
11952
+ if (saltFor !== this.saltUserId) {
11953
+ this.saltUserId = saltFor;
11954
+ const next = this.mintSessionSalt();
11955
+ this.dataModule.setSessionId(next);
11956
+ this.crdtManager.setSessionId(next);
11957
+ }
11843
11958
  try {
11844
11959
  await this.sync.setCurrentUserId(userId);
11845
11960
  } catch (e) {
@@ -11855,7 +11970,9 @@ var Sp00kyClient = class {
11855
11970
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "FeatureFlagModule initialized");
11856
11971
  this.appReleases.init();
11857
11972
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "AppReleaseModule initialized");
11858
- this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization completed successfully");
11973
+ this.localReady = true;
11974
+ this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient local initialization completed; connecting in the background");
11975
+ this.initRemote();
11859
11976
  } catch (e) {
11860
11977
  this.logger.error({
11861
11978
  error: e,
@@ -11864,6 +11981,36 @@ var Sp00kyClient = class {
11864
11981
  throw e;
11865
11982
  }
11866
11983
  }
11984
+ /**
11985
+ * The network half of boot: connect, verify the restored session, and let the
11986
+ * sync engine catch up. Runs in the background after `init()` has already
11987
+ * resolved, so nothing here is on the paint path.
11988
+ *
11989
+ * Every step is best-effort. A failure leaves the client in exactly the state
11990
+ * a warm offline boot is in - local reads working, writes queued in the
11991
+ * outbox - and the connection supervisor keeps retrying underneath.
11992
+ */
11993
+ async initRemote() {
11994
+ try {
11995
+ await this.remote.connect();
11996
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::initRemote" }, "Remote database connected");
11997
+ } catch (e) {
11998
+ this.logger.warn({
11999
+ err: e,
12000
+ Category: "sp00ky-client::Sp00kyClient::initRemote"
12001
+ }, "Remote connect failed; running from the local store and retrying in the background");
12002
+ }
12003
+ this.connectionSupervisor.start();
12004
+ try {
12005
+ await this.auth.init();
12006
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::initRemote" }, "Auth verified");
12007
+ } catch (e) {
12008
+ this.logger.warn({
12009
+ err: e,
12010
+ Category: "sp00ky-client::Sp00kyClient::initRemote"
12011
+ }, "Auth verification failed; keeping the restored session");
12012
+ }
12013
+ }
11867
12014
  bucketSwitchChain = Promise.resolve();
11868
12015
  pendingBucketTarget = null;
11869
12016
  /**
@@ -12238,26 +12385,49 @@ var Sp00kyClient = class {
12238
12385
  delete(table, id) {
12239
12386
  return this.dataModule.delete(table, id);
12240
12387
  }
12388
+ /**
12389
+ * Whether the local store is initialized and reads can be served. See the
12390
+ * `localReady` field: this is deliberately independent of connectivity.
12391
+ */
12392
+ isLocalReady() {
12393
+ return this.localReady;
12394
+ }
12241
12395
  async useRemote(fn) {
12242
12396
  return fn(this.remote.getClient());
12243
12397
  }
12244
12398
  /**
12245
- * Fetch SurrealDB's `session::id()` as a string. Used as a salt for
12246
- * query-id hashing so two sessions for the same user get distinct
12247
- * `_00_query` rows. Returns empty string if the query fails (we still
12248
- * boot, just without session scoping for IDs).
12249
- */
12250
- async fetchSessionId() {
12251
- try {
12252
- const [sid] = await this.remote.query("RETURN <string>session::id()");
12253
- return typeof sid === "string" ? sid : "";
12254
- } catch (e) {
12255
- this.logger.warn({
12256
- error: e,
12257
- Category: "sp00ky-client::Sp00kyClient::fetchSessionId"
12258
- }, "Failed to fetch session::id() — proceeding with empty salt");
12259
- return "";
12260
- }
12399
+ * Mint the salt used for query-id hashing, so two sessions registering the
12400
+ * same logical query get distinct `_00_query` rows.
12401
+ *
12402
+ * Generated LOCALLY, deliberately. This used to be `RETURN <string>session::id()`,
12403
+ * which cost a serial round trip on the critical boot path and resolved to
12404
+ * `''` offline. The value never needed to come from the server: the server
12405
+ * derives its own `clientId` inside `fn::query::register` and *ignores*
12406
+ * whatever the caller passed, and the permission rules that matter gate on
12407
+ * `auth_id = $auth.id` rather than the session (`_00_list_ref`). Session
12408
+ * scoping via `clientId = session::id()` was in fact removed upstream because
12409
+ * it broke a user with two tabs open. All this value has to be is unique per
12410
+ * browser session, which `randomUUID` gives us for free and offline.
12411
+ */
12412
+ /**
12413
+ * The current principal as the `"table:id"` string the in-browser SSP wants
12414
+ * for `$auth.id`, or null when signed out.
12415
+ *
12416
+ * Tolerates BOTH shapes `currentUser.id` can take, which is the point:
12417
+ * a session restored from the cached token carries a plain string (the JWT's
12418
+ * `ID` claim), while one verified by the server carries a RecordId. Passing
12419
+ * the former to `encodeRecordId` reads `.table` off a string and throws
12420
+ * during boot.
12421
+ */
12422
+ sessionAuthId() {
12423
+ const id = this.auth.currentUser?.id;
12424
+ if (!id) return null;
12425
+ return typeof id === "string" ? id : encodeRecordId(id);
12426
+ }
12427
+ mintSessionSalt() {
12428
+ const c = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
12429
+ if (c?.randomUUID) return c.randomUUID();
12430
+ return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
12261
12431
  }
12262
12432
  };
12263
12433
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.201",
3
+ "version": "0.0.1-canary.203",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@spooky-sync/query-builder": "0.0.1-canary.201",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.201",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.203",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.203",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "blurhash": "^2.0.5",
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { AuthService } from './index';
3
+
4
+ // A SurrealDB record-access JWT carries the access method as `AC` and the
5
+ // `$auth.id` record id as `ID`. Only the payload matters here - nothing in the
6
+ // client verifies the signature, the server does.
7
+ function jwt(claims: Record<string, unknown>): string {
8
+ const b64 = Buffer.from(JSON.stringify(claims)).toString('base64url');
9
+ return `header.${b64}.signature`;
10
+ }
11
+
12
+ const silentLogger = () =>
13
+ ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: () => silentLogger() }) as any;
14
+
15
+ function makeAuth(opts: { token?: string | null; query?: any; authenticate?: any } = {}) {
16
+ const store = new Map<string, unknown>();
17
+ if (opts.token) store.set('sp00ky_auth_token', opts.token);
18
+ const persistence = {
19
+ get: vi.fn(async (k: string) => store.get(k) ?? null),
20
+ set: vi.fn(async (k: string, v: unknown) => void store.set(k, v)),
21
+ remove: vi.fn(async (k: string) => void store.delete(k)),
22
+ } as any;
23
+ const remote = {
24
+ query: opts.query ?? vi.fn(async () => [[]]),
25
+ getClient: () => ({ authenticate: opts.authenticate ?? vi.fn(async () => undefined) }),
26
+ } as any;
27
+ return { auth: new AuthService({} as any, remote, persistence, silentLogger()), remote, persistence, store };
28
+ }
29
+
30
+ describe('restoreSessionFromToken', () => {
31
+ it('restores the session from the cached token with NO network', async () => {
32
+ const { auth, remote } = makeAuth({ token: jwt({ AC: 'account', ID: 'user:abc' }) });
33
+
34
+ const userId = await auth.restoreSessionFromToken();
35
+
36
+ expect(userId).toBe('user:abc');
37
+ expect(auth.isAuthenticated).toBe(true);
38
+ expect(auth.currentUser?.id).toBe('user:abc');
39
+ expect(auth.access).toBe('account');
40
+ // The whole point: this is what makes a warm/offline boot paint.
41
+ expect(remote.query).not.toHaveBeenCalled();
42
+ });
43
+
44
+ it('notifies subscribers so query routing can be set before any registration', async () => {
45
+ const { auth } = makeAuth({ token: jwt({ AC: 'account', ID: 'user:abc' }) });
46
+ const seen: (string | null)[] = [];
47
+ auth.subscribe((uid) => seen.push(uid));
48
+
49
+ await auth.restoreSessionFromToken();
50
+
51
+ expect(seen).toContain('user:abc');
52
+ });
53
+
54
+ it('returns null when there is no token, and when the token carries no id', async () => {
55
+ expect(await makeAuth().auth.restoreSessionFromToken()).toBeNull();
56
+ const noId = makeAuth({ token: jwt({ AC: 'account' }) });
57
+ expect(await noId.auth.restoreSessionFromToken()).toBeNull();
58
+ expect(noId.auth.isAuthenticated).toBe(false);
59
+ });
60
+
61
+ it('survives a malformed token rather than throwing', async () => {
62
+ const { auth } = makeAuth({ token: 'not-a-jwt' });
63
+ expect(await auth.restoreSessionFromToken()).toBeNull();
64
+ });
65
+ });
66
+
67
+ describe('check() error handling', () => {
68
+ const token = jwt({ AC: 'account', ID: 'user:abc' });
69
+
70
+ it('KEEPS the cached session when the server is unreachable', async () => {
71
+ const { auth, persistence, store } = makeAuth({
72
+ token,
73
+ authenticate: vi.fn(async () => {
74
+ throw new Error('There was a problem with the underlying connection');
75
+ }),
76
+ });
77
+ await auth.restoreSessionFromToken();
78
+
79
+ await auth.check();
80
+
81
+ // A blip must not log the user out - that is what makes offline possible.
82
+ expect(auth.isAuthenticated).toBe(true);
83
+ expect(store.get('sp00ky_auth_token')).toBe(token);
84
+ expect(persistence.remove).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it('signs out for real when the server REJECTS the token', async () => {
88
+ const { auth, store } = makeAuth({
89
+ token,
90
+ authenticate: vi.fn(async () => {
91
+ throw new Error('There was a problem with the database: Invalid token');
92
+ }),
93
+ });
94
+ await auth.restoreSessionFromToken();
95
+
96
+ await auth.check();
97
+
98
+ expect(auth.isAuthenticated).toBe(false);
99
+ expect(store.get('sp00ky_auth_token')).toBeUndefined();
100
+ });
101
+ });
@@ -9,6 +9,7 @@ import type { Logger } from '../../services/logger/index';
9
9
  export * from './events/index';
10
10
  import { AuthEventTypes, createAuthEventSystem } from './events/index';
11
11
  import type { PersistenceClient } from '../../types';
12
+ import { classifySyncError } from '../../utils/error-classification';
12
13
 
13
14
  // Helper to pretty print types
14
15
  type Prettify<T> = {
@@ -36,28 +37,41 @@ type ExtractAccessParams<
36
37
  : never;
37
38
 
38
39
  /**
39
- * Read the `AC` (access-method name) claim from a SurrealDB record-access
40
- * JWT without verifying it we only need the claim, the server enforces the
41
- * token. Returns null on any malformed input. The in-browser SSP needs this
42
- * to resolve `$access` in table permission predicates (mirrors the session's
43
- * `$access` that the server's `fn::query::register` reads).
40
+ * Read the claims of a SurrealDB record-access JWT WITHOUT verifying it. The
41
+ * server still enforces the token on every request; this is only so the client
42
+ * can act on what it already holds before a round trip completes.
43
+ *
44
+ * `AC` is the access-method name — the in-browser SSP needs it to resolve
45
+ * `$access` in table permission predicates (mirrors the session's `$access`
46
+ * that the server's `fn::query::register` reads). `ID` is the `$auth.id` record
47
+ * id, which is what lets a warm boot restore a session locally.
48
+ *
49
+ * Returns nulls on any malformed input.
44
50
  */
45
- function decodeAccessFromToken(token: string): string | null {
51
+ function decodeTokenClaims(token: string): { access: string | null; userId: string | null } {
46
52
  try {
47
53
  const payload = token.split('.')[1];
48
- if (!payload) return null;
54
+ if (!payload) return { access: null, userId: null };
49
55
  let b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
50
56
  b64 += '='.repeat((4 - (b64.length % 4)) % 4);
51
57
  const json =
52
58
  typeof atob === 'function' ? atob(b64) : Buffer.from(b64, 'base64').toString('binary');
53
59
  const claims = JSON.parse(json) as Record<string, unknown>;
54
60
  const ac = claims.AC ?? claims.ac;
55
- return typeof ac === 'string' ? ac : null;
61
+ const id = claims.ID ?? claims.id;
62
+ return {
63
+ access: typeof ac === 'string' ? ac : null,
64
+ userId: typeof id === 'string' ? id : null,
65
+ };
56
66
  } catch {
57
- return null;
67
+ return { access: null, userId: null };
58
68
  }
59
69
  }
60
70
 
71
+ function decodeAccessFromToken(token: string): string | null {
72
+ return decodeTokenClaims(token).access;
73
+ }
74
+
61
75
  export class AuthService<S extends SchemaStructure> {
62
76
  // State
63
77
  public token: string | null = null;
@@ -115,6 +129,44 @@ export class AuthService<S extends SchemaStructure> {
115
129
  this.events.emit(AuthEventTypes.AuthStateChanged, userId);
116
130
  }
117
131
 
132
+ /**
133
+ * Restore a session from the locally cached JWT, with NO network.
134
+ *
135
+ * This is what makes a warm boot paint instantly and what makes an offline
136
+ * boot possible at all: the token is in local storage, and it already carries
137
+ * both the access method and the `$auth.id` record id. Everything the client
138
+ * needs to route queries (`setCurrentUserId`) and to satisfy `$auth`-gated
139
+ * permission predicates in the in-browser SSP (`setSessionAuth`) is therefore
140
+ * available before a socket exists.
141
+ *
142
+ * The session is OPTIMISTIC: the token is unverified here. `check()` runs
143
+ * afterwards in the background and downgrades to a real sign-out if the
144
+ * server rejects it. Nothing is trusted that the server has not also seen -
145
+ * the local store only ever holds rows the server previously sent.
146
+ *
147
+ * Returns the restored user id, or null when there is no usable token.
148
+ */
149
+ async restoreSessionFromToken(): Promise<string | null> {
150
+ const token = await this.persistenceClient.get<string>('sp00ky_auth_token');
151
+ if (!token) return null;
152
+ const { access, userId } = decodeTokenClaims(token);
153
+ if (!userId) return null;
154
+
155
+ this.token = token;
156
+ // Only the id: the full row is not in the token. It lands from the local
157
+ // cache when the app's own `user` query paints, and is replaced wholesale
158
+ // by `check()` once the server answers.
159
+ this.currentUser = { id: userId };
160
+ this.isAuthenticated = true;
161
+ this.access = access ?? this.defaultAccessName();
162
+ this.notifyListeners();
163
+ this.logger.debug(
164
+ { userId, Category: 'sp00ky-client::AuthService::restoreSessionFromToken' },
165
+ 'Session restored optimistically from cached token'
166
+ );
167
+ return userId;
168
+ }
169
+
118
170
  /**
119
171
  * Check for existing session and validate
120
172
  */
@@ -180,11 +232,23 @@ export class AuthService<S extends SchemaStructure> {
180
232
  }
181
233
  }
182
234
  } catch (error) {
183
- this.logger.error(
184
- { error, stack: (error as Error).stack, Category: 'sp00ky-client::AuthService::check' },
185
- 'Auth check failed'
186
- );
187
- await this.signOut();
235
+ // A REACHABILITY failure is not a rejected token. This catch used to call
236
+ // signOut() unconditionally, which deletes `sp00ky_auth_token` - so a
237
+ // blip on boot silently logged the user out, and an offline boot could
238
+ // never stay signed in. Only an application error (the server answered,
239
+ // and the answer was "no") ends the session.
240
+ if (classifySyncError(error) === 'network') {
241
+ this.logger.warn(
242
+ { error, Category: 'sp00ky-client::AuthService::check' },
243
+ 'Auth check unreachable; keeping the cached session and retrying later'
244
+ );
245
+ } else {
246
+ this.logger.error(
247
+ { error, stack: (error as Error).stack, Category: 'sp00ky-client::AuthService::check' },
248
+ 'Auth check failed'
249
+ );
250
+ await this.signOut();
251
+ }
188
252
  } finally {
189
253
  this.isLoading = false;
190
254
  }
@@ -151,17 +151,18 @@ export class CacheModule implements StreamUpdateReceiver {
151
151
  )
152
152
  );
153
153
 
154
- const params = populatedRecords.reduce(
155
- (acc, record, i) => {
156
- const { id, ...content } = record.record;
157
- return {
158
- ...acc,
159
- [`id${i}`]: id,
160
- [`content${i}`]: content,
161
- };
162
- },
163
- {} as Record<string, any>
164
- );
154
+ // Filled in place, NOT with a spread-per-iteration reduce: spreading the
155
+ // accumulator copies every key written so far on each record, which is
156
+ // O(n^2) in the batch size. On a cold start that batches thousands of
157
+ // rows (a game library, a player-name registry) it was the single
158
+ // biggest main-thread cost of the whole boot - ~36% of samples, seconds
159
+ // of blocking - for a loop that does no real work.
160
+ const params: Record<string, any> = {};
161
+ for (let i = 0; i < populatedRecords.length; i++) {
162
+ const { id, ...content } = populatedRecords[i].record;
163
+ params[`id${i}`] = id;
164
+ params[`content${i}`] = content;
165
+ }
165
166
 
166
167
  await this.local.execute(query, params, { epoch });
167
168
  }
@@ -473,6 +473,13 @@ export class Sp00kySync<S extends SchemaStructure> {
473
473
  if (this.isInit) throw new Error('Sp00kySync is already initialized');
474
474
  this.isInit = true;
475
475
  await this.scheduler.init({ loadOutbox: this.tabRole !== 'follower' });
476
+ // Boot is local-first now, so init() routinely runs BEFORE the socket is
477
+ // up. Treat that as "the socket we registered on is gone": otherwise the
478
+ // first `connected` takes the initial-connect branch, returns early, and
479
+ // never re-enqueues `register` for queries that registered while offline.
480
+ // They would still heal via the down-queue backoff, but slowly and only
481
+ // because every query happens to enqueue its own register.
482
+ if (this.remote.getStatus() !== 'connected') this.needsResubscribe = true;
476
483
  this.subscribeToReconnect();
477
484
  this.subscribeToConnectionState();
478
485
  void this.scheduler.syncUp();
@@ -204,6 +204,72 @@ export class StreamProcessorService {
204
204
  ): void {
205
205
  if (records.length === 0) return;
206
206
 
207
+ if (!this.processor) {
208
+ this.logger.warn(
209
+ { Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
210
+ 'Not initialized, skipping ingest'
211
+ );
212
+ return;
213
+ }
214
+
215
+ // One circuit step for the whole batch when the WASM build offers it. A
216
+ // step walks every registered view, so the per-record path pays that fixed
217
+ // cost N times: a cold sync landing a few thousand rows spent seconds of
218
+ // main-thread time almost entirely on step overhead. Older builds have no
219
+ // `ingest_many`, hence the loop below.
220
+ const bulkIngest = this.processor.ingest_many;
221
+ if (typeof bulkIngest === 'function') {
222
+ this.logger.debug(
223
+ { count: records.length, Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
224
+ 'Ingesting batch into ssp'
225
+ );
226
+ try {
227
+ const items = records.map((record) => ({
228
+ table: record.table,
229
+ op: record.op,
230
+ id: record.id,
231
+ record: this.normalizeValue(record.record),
232
+ }));
233
+ const t0 = performance.now();
234
+ const rawUpdates = bulkIngest.call(this.processor, items) ?? [];
235
+ const materializationTimeMs = performance.now() - t0;
236
+ this.logger.debug(
237
+ {
238
+ count: records.length,
239
+ rawUpdates: rawUpdates.length,
240
+ materializationTimeMs,
241
+ Category: 'sp00ky-client::StreamProcessorService::ingestMany',
242
+ },
243
+ 'Ingesting batch into ssp done'
244
+ );
245
+ if (rawUpdates.length > 0) {
246
+ // `op: 'CREATE'` for the same reason the coalesced flush uses it: the
247
+ // batch's update takes DataModule's immediate (non-debounced) path.
248
+ this.dispatchUpdates(
249
+ rawUpdates.map((u: WasmStreamUpdate) => ({
250
+ queryHash: u.query_id,
251
+ localArray: u.result_data,
252
+ op: 'CREATE' as const,
253
+ materializationTimeMs,
254
+ storeApplyMs: u.timing_store_apply_ms,
255
+ circuitStepMs: u.timing_circuit_step_ms,
256
+ transformMs: u.timing_transform_ms,
257
+ }))
258
+ );
259
+ }
260
+ } catch (e) {
261
+ // Same contract as the single-record path: report and move on rather
262
+ // than re-running the batch, which would double-apply whatever the
263
+ // failed step already committed to the store.
264
+ this.logger.error(
265
+ { error: e, count: records.length, Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
266
+ 'Ingesting batch into ssp failed'
267
+ );
268
+ }
269
+ this.markSnapshotDirty();
270
+ return;
271
+ }
272
+
207
273
  this.beginCoalescing();
208
274
  try {
209
275
  for (const record of records) {
@@ -133,4 +133,64 @@ describe('StreamProcessor ingestMany bulk insert', () => {
133
133
  svc.ingest('user', 'CREATE', 'user:1', { id: 'user:1', _00_rv: 1 });
134
134
  expect(receiver.received).toHaveLength(1);
135
135
  });
136
+
137
+ // A WASM build that exposes `ingest_many` takes one circuit step for the whole
138
+ // batch. The per-record loop above stays as the fallback for older builds.
139
+ describe('with a WASM build that supports bulk ingest', () => {
140
+ function makeBulkService() {
141
+ const svc = new StreamProcessorService(
142
+ {} as any,
143
+ {} as any,
144
+ { get: async () => undefined, set: async () => {} } as any,
145
+ makeLogger()
146
+ );
147
+ const calls: { items: any[] }[] = [];
148
+ const mockProcessor: Partial<WasmProcessor> = {
149
+ ingest: () => {
150
+ throw new Error('per-record ingest must not be used when ingest_many exists');
151
+ },
152
+ ingest_many: (items): WasmStreamUpdate[] => {
153
+ calls.push({ items });
154
+ return [
155
+ {
156
+ query_id: 'q1',
157
+ result_data: items.map((i) => [i.id, 1] as [string, number]),
158
+ timing_circuit_step_ms: 4,
159
+ } as WasmStreamUpdate,
160
+ ];
161
+ },
162
+ };
163
+ (svc as any).processor = mockProcessor;
164
+ return { svc, calls };
165
+ }
166
+
167
+ it('takes a single bulk call and dispatches one update per affected query', () => {
168
+ const { svc, calls } = makeBulkService();
169
+ svc.addReceiver(receiver);
170
+
171
+ svc.ingestMany([
172
+ { table: 'user', op: 'CREATE', id: 'user:1', record: { id: 'user:1', _00_rv: 1 } },
173
+ { table: 'user', op: 'CREATE', id: 'user:2', record: { id: 'user:2', _00_rv: 1 } },
174
+ { table: 'user', op: 'CREATE', id: 'user:3', record: { id: 'user:3', _00_rv: 1 } },
175
+ ]);
176
+
177
+ expect(calls).toHaveLength(1);
178
+ expect(calls[0].items.map((i) => i.id)).toEqual(['user:1', 'user:2', 'user:3']);
179
+ expect(receiver.received).toHaveLength(1);
180
+ expect(receiver.received[0].queryHash).toBe('q1');
181
+ expect(receiver.received[0].localArray).toHaveLength(3);
182
+ expect(receiver.received[0].op).toBe('CREATE');
183
+ expect(receiver.received[0].circuitStepMs).toBe(4);
184
+ });
185
+
186
+ it('never calls the bulk path for an empty batch', () => {
187
+ const { svc, calls } = makeBulkService();
188
+ svc.addReceiver(receiver);
189
+
190
+ svc.ingestMany([]);
191
+
192
+ expect(calls).toHaveLength(0);
193
+ expect(receiver.received).toHaveLength(0);
194
+ });
195
+ });
136
196
  });
@@ -34,6 +34,10 @@ export interface WasmIngestItem {
34
34
  // Interface matching the Sp00kyProcessor class from WASM
35
35
  export interface WasmProcessor {
36
36
  ingest(table: string, op: string, id: string, record: any): WasmStreamUpdate[];
37
+ // Bulk ingest: ONE circuit step for the whole array, returning the coalesced
38
+ // updates. Optional because a stale WASM build won't have it — callers guard
39
+ // with `typeof x === 'function'` and fall back to a loop over `ingest`.
40
+ ingest_many?(items: WasmIngestItem[]): WasmStreamUpdate[];
37
41
  register_view(config: WasmQueryConfig): WasmStreamUpdate | undefined;
38
42
  unregister_view(id: string): void;
39
43
  // Seed per-table `select` permission predicates ({ [table]: whereText }) so
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ // Structural guard, in the spirit of sp00ky.init-query.test.ts: the paint path
6
+ // must stay network-free. `init()` resolves as soon as the LOCAL store is
7
+ // usable, and every consumer gates its first render on that, so anything that
8
+ // reaches the network belongs in `initRemote()` instead.
9
+ //
10
+ // This is the invariant that makes a warm reload instant and an offline boot
11
+ // possible at all. It is easy to undo by adding one innocent-looking `await`,
12
+ // hence a test rather than a comment.
13
+ const source = readFileSync(join(__dirname, 'sp00ky.ts'), 'utf8');
14
+
15
+ function methodBody(name: string): string {
16
+ const re = new RegExp(`(?:private )?async ${name}\\([^)]*\\)[^{]*\\{[\\s\\S]*?\\n \\}`);
17
+ const m = re.exec(source);
18
+ if (!m) throw new Error(`could not locate ${name}()`);
19
+ return m[0];
20
+ }
21
+
22
+ describe('local-first boot', () => {
23
+ const init = methodBody('init');
24
+
25
+ it('init() never awaits the remote', () => {
26
+ expect(init).not.toMatch(/await this\.remote\./);
27
+ expect(init).not.toMatch(/await this\.auth\.init\(\)/);
28
+ });
29
+
30
+ it('init() hands the network half off without awaiting it', () => {
31
+ expect(init).toMatch(/void this\.initRemote\(\)/);
32
+ });
33
+
34
+ it('init() restores the session locally and marks the client ready', () => {
35
+ expect(init).toMatch(/await this\.auth\.restoreSessionFromToken\(\)/);
36
+ expect(init).toMatch(/this\.localReady = true/);
37
+ });
38
+
39
+ it('the query-id salt is minted locally, not fetched from the server', () => {
40
+ // No remaining CALL - the surviving mentions are comments explaining why.
41
+ expect(source).not.toMatch(/query[^\n]*RETURN <string>session::id\(\)/);
42
+ expect(init).toMatch(/this\.mintSessionSalt\(\)/);
43
+ });
44
+
45
+ it('permissions are seeded before anything can register a view', () => {
46
+ const perms = init.indexOf('setPermissions(');
47
+ const restore = init.indexOf('restoreSessionFromToken');
48
+ expect(perms).toBeGreaterThan(-1);
49
+ expect(perms).toBeLessThan(restore);
50
+ });
51
+
52
+ it('initRemote() tolerates an unreachable server and still supervises', () => {
53
+ const remote = methodBody('initRemote');
54
+ // connect is wrapped, not fatal
55
+ expect(remote).toMatch(/try\s*\{[\s\S]*await this\.remote\.connect\(\)[\s\S]*\}\s*catch/);
56
+ // and the supervisor starts regardless, or a boot-time failure never heals
57
+ const connectCatchEnd = remote.indexOf('connectionSupervisor.start()');
58
+ expect(connectCatchEnd).toBeGreaterThan(-1);
59
+ });
60
+ });
package/src/sp00ky.ts CHANGED
@@ -334,6 +334,17 @@ export class Sp00kyClient<S extends SchemaStructure> {
334
334
  private sync: Sp00kySync<S>;
335
335
  private devTools: DevToolsService;
336
336
  private crdtManager: CrdtManager;
337
+ /**
338
+ * True once the LOCAL half of boot is done and the client can serve reads
339
+ * from the local store. Distinct from being connected: `syncHealth` covers
340
+ * reaching the server and `storageHealth` covers whether the local store is
341
+ * durable, but neither says "usable". Consumers gate their first paint on
342
+ * this, which is what makes a warm boot instant and an offline boot possible.
343
+ */
344
+ private localReady = false;
345
+ // Principal the current query-id salt was minted for (`null` = signed out).
346
+ // Rotated only on a real auth flip: see the call sites in `init`.
347
+ private saltUserId: string | null = null;
337
348
  private featureFlags!: FeatureFlagModule<S>;
338
349
  private appReleases!: AppReleaseModule<S>;
339
350
  // Query hashes already preloaded this session — skip redundant one-shot
@@ -831,16 +842,6 @@ export class Sp00kyClient<S extends SchemaStructure> {
831
842
  this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Schema provisioned');
832
843
  }
833
844
 
834
- await this.remote.connect();
835
- // Start supervising only after the first connect succeeds, so a boot-time
836
- // failure surfaces as a thrown init() rather than being silently absorbed
837
- // into a background retry loop.
838
- this.connectionSupervisor.start();
839
- this.logger.debug(
840
- { Category: 'sp00ky-client::Sp00kyClient::init' },
841
- 'Remote database connected'
842
- );
843
-
844
845
  // Warm the blob cache in the background. Deliberately NOT awaited, and
845
846
  // deliberately after `remote.connect()`: this walks the OPFS directory to
846
847
  // rebuild the manifest, and awaiting it ahead of the socket delayed the
@@ -870,26 +871,31 @@ export class Sp00kyClient<S extends SchemaStructure> {
870
871
  'StreamProcessor initialized'
871
872
  );
872
873
 
873
- await this.auth.init();
874
- this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Auth initialized');
875
874
 
876
- // Salt query-id hashing with the SurrealDB session id so two browsers
877
- // for the same user don't collide on shared `_00_query` rows. The same
878
- // session id is the `session_id` key in `_00_cursor` rows, so the
879
- // CrdtManager needs it too.
880
- //
881
- // Deliberately NOT refreshed on reconnect, even though `session::id()`
882
- // does change with every WebSocket session. The salt keys `_00_query`
883
- // rows and local cache entries, so rotating it would invalidate every
884
- // query hash and force a full re-register plus a cache-key migration on
885
- // each blip. Those rows stay valid because the TTL heartbeat keeps them
886
- // alive, not because the session that created them is still open — so a
887
- // stable salt across reconnects is the correct behavior. Auth flips are
888
- // the one case that must rotate it (below): a sign-in is a different
889
- // principal, not the same session on a new socket.
890
- const sessionId = await this.fetchSessionId();
875
+ // Restore the session from the cached JWT, with no network. This is what
876
+ // lets the rest of the boot - and the app on top of it - proceed as a
877
+ // signed-in user before a socket exists. `initRemote()` verifies the
878
+ // token afterwards and signs out for real if the server rejects it.
879
+ const restoredUserId = await this.auth.restoreSessionFromToken();
880
+
881
+ // Salt for query-id hashing, minted locally (see `mintSessionSalt`). It
882
+ // is stable for the life of this client: the salt keys `_00_query` rows
883
+ // and local cache entries, so rotating it would invalidate every query
884
+ // hash and force a full re-register. Auth flips are the one case that
885
+ // must rotate it - a sign-in is a different principal.
886
+ const sessionId = this.mintSessionSalt();
887
+ this.saltUserId = restoredUserId;
891
888
  await this.dataModule.init(sessionId);
892
889
  this.crdtManager.setSessionId(sessionId);
890
+
891
+ // Route queries and satisfy `$auth`-gated permission predicates from the
892
+ // restored identity, BEFORE anything can register. Without this a query
893
+ // registering pre-verification would target the wrong
894
+ // `_00_query_user_<id>` table and register a permission-dead SSP view.
895
+ if (restoredUserId) {
896
+ this.dataModule.setCurrentUserId(restoredUserId);
897
+ this.streamProcessor.setSessionAuth(this.sessionAuthId(), this.auth.access);
898
+ }
893
899
  this.logger.debug(
894
900
  { sessionId, Category: 'sp00ky-client::Sp00kyClient::init' },
895
901
  'DataModule initialized'
@@ -920,10 +926,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
920
926
  // locally instead of being rejected. Set synchronously BEFORE the
921
927
  // first `await` (like `setCurrentUserId` above) so queries that
922
928
  // re-register on this auth flip see the fresh context, not a stale one.
923
- this.streamProcessor.setSessionAuth(
924
- this.auth.currentUser?.id ? encodeRecordId(this.auth.currentUser.id) : null,
925
- this.auth.access
926
- );
929
+ this.streamProcessor.setSessionAuth(this.sessionAuthId(), this.auth.access);
927
930
  // Record the target bucket synchronously (still before the first
928
931
  // `await`) so a reload mid-switch boots straight into the right store.
929
932
  writeBootBucketHint(bucketIdForUser(userId));
@@ -931,9 +934,25 @@ export class Sp00kyClient<S extends SchemaStructure> {
931
934
  // + latest-target-wins internally; no-op when the bucket already
932
935
  // matches (the boot-hint warm path).
933
936
  await this.ensureLocalBucket(userId);
934
- const next = await this.fetchSessionId();
935
- this.dataModule.setSessionId(next);
936
- this.crdtManager.setSessionId(next);
937
+ // Only rotate the salt when the PRINCIPAL actually changed: a sign-in or
938
+ // sign-out is a different principal and must not keep the old
939
+ // principal's query ids, but the first fire of this callback after boot
940
+ // carries the same user the salt was already minted for, and rotating
941
+ // there would invalidate every query hash for no change in value.
942
+ // Canonicalize with the SAME encoding the restore path used, NOT
943
+ // String(): after background verification this callback carries a
944
+ // RecordId, while a session restored from the token carries the plain
945
+ // "table:id" string. Comparing their raw stringifications made every
946
+ // warm boot look like a principal change, which rotated the salt and
947
+ // re-registered every mounted query - the list painted from cache and
948
+ // then emptied a second later.
949
+ const saltFor = this.sessionAuthId();
950
+ if (saltFor !== this.saltUserId) {
951
+ this.saltUserId = saltFor;
952
+ const next = this.mintSessionSalt();
953
+ this.dataModule.setSessionId(next);
954
+ this.crdtManager.setSessionId(next);
955
+ }
937
956
  try {
938
957
  await this.sync.setCurrentUserId(userId);
939
958
  } catch (e) {
@@ -959,10 +978,21 @@ export class Sp00kyClient<S extends SchemaStructure> {
959
978
  'AppReleaseModule initialized'
960
979
  );
961
980
 
981
+ // LOCAL BOOT IS DONE — the client can serve reads. Consumers gate their
982
+ // UI on this resolving, so everything above must stay network-free.
983
+ this.localReady = true;
962
984
  this.logger.info(
963
985
  { Category: 'sp00ky-client::Sp00kyClient::init' },
964
- 'Sp00kyClient initialization completed successfully'
986
+ 'Sp00kyClient local initialization completed; connecting in the background'
965
987
  );
988
+
989
+ // The network half, deliberately NOT awaited. Nothing above needed it:
990
+ // queries paint from the local store, `sync.init()` tolerates a closed
991
+ // socket, and the session was restored from the cached token. Awaiting
992
+ // this was the entire reason a warm reload sat on a loading screen for
993
+ // seconds over data the browser already had on disk - and the reason an
994
+ // offline boot never completed at all.
995
+ void this.initRemote();
966
996
  } catch (e) {
967
997
  this.logger.error(
968
998
  { error: e, Category: 'sp00ky-client::Sp00kyClient::init' },
@@ -972,6 +1002,54 @@ export class Sp00kyClient<S extends SchemaStructure> {
972
1002
  }
973
1003
  }
974
1004
 
1005
+ /**
1006
+ * The network half of boot: connect, verify the restored session, and let the
1007
+ * sync engine catch up. Runs in the background after `init()` has already
1008
+ * resolved, so nothing here is on the paint path.
1009
+ *
1010
+ * Every step is best-effort. A failure leaves the client in exactly the state
1011
+ * a warm offline boot is in - local reads working, writes queued in the
1012
+ * outbox - and the connection supervisor keeps retrying underneath.
1013
+ */
1014
+ private async initRemote(): Promise<void> {
1015
+ try {
1016
+ await this.remote.connect();
1017
+ this.logger.debug(
1018
+ { Category: 'sp00ky-client::Sp00kyClient::initRemote' },
1019
+ 'Remote database connected'
1020
+ );
1021
+ } catch (e) {
1022
+ // NOT fatal. This used to throw out of init() and leave the consuming app
1023
+ // on its loading screen forever with no network. The supervisor (started
1024
+ // unconditionally below) owns the retry from here.
1025
+ this.logger.warn(
1026
+ { err: e, Category: 'sp00ky-client::Sp00kyClient::initRemote' },
1027
+ 'Remote connect failed; running from the local store and retrying in the background'
1028
+ );
1029
+ }
1030
+
1031
+ // Started whether or not the first connect succeeded - it is the thing that
1032
+ // revives the socket, so gating it on a successful connect would mean a
1033
+ // boot-time failure never recovered.
1034
+ this.connectionSupervisor.start();
1035
+
1036
+ try {
1037
+ // Verifies the optimistically restored token against the server. On a
1038
+ // rejected token this signs out for real; on an unreachable server it
1039
+ // keeps the cached session (see AuthService.check).
1040
+ await this.auth.init();
1041
+ this.logger.debug(
1042
+ { Category: 'sp00ky-client::Sp00kyClient::initRemote' },
1043
+ 'Auth verified'
1044
+ );
1045
+ } catch (e) {
1046
+ this.logger.warn(
1047
+ { err: e, Category: 'sp00ky-client::Sp00kyClient::initRemote' },
1048
+ 'Auth verification failed; keeping the restored session'
1049
+ );
1050
+ }
1051
+ }
1052
+
975
1053
  // Serializes bucket switches from rapid auth flips; `pendingBucketTarget`
976
1054
  // makes intermediate targets collapse (A→anon→B never opens the anon bucket).
977
1055
  private bucketSwitchChain: Promise<void> = Promise.resolve();
@@ -1518,26 +1596,53 @@ export class Sp00kyClient<S extends SchemaStructure> {
1518
1596
  return this.dataModule.delete(table, id);
1519
1597
  }
1520
1598
 
1599
+ /**
1600
+ * Whether the local store is initialized and reads can be served. See the
1601
+ * `localReady` field: this is deliberately independent of connectivity.
1602
+ */
1603
+ isLocalReady(): boolean {
1604
+ return this.localReady;
1605
+ }
1606
+
1521
1607
  async useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T> {
1522
1608
  return fn(this.remote.getClient());
1523
1609
  }
1524
1610
 
1525
1611
  /**
1526
- * Fetch SurrealDB's `session::id()` as a string. Used as a salt for
1527
- * query-id hashing so two sessions for the same user get distinct
1528
- * `_00_query` rows. Returns empty string if the query fails (we still
1529
- * boot, just without session scoping for IDs).
1612
+ * Mint the salt used for query-id hashing, so two sessions registering the
1613
+ * same logical query get distinct `_00_query` rows.
1614
+ *
1615
+ * Generated LOCALLY, deliberately. This used to be `RETURN <string>session::id()`,
1616
+ * which cost a serial round trip on the critical boot path and resolved to
1617
+ * `''` offline. The value never needed to come from the server: the server
1618
+ * derives its own `clientId` inside `fn::query::register` and *ignores*
1619
+ * whatever the caller passed, and the permission rules that matter gate on
1620
+ * `auth_id = $auth.id` rather than the session (`_00_list_ref`). Session
1621
+ * scoping via `clientId = session::id()` was in fact removed upstream because
1622
+ * it broke a user with two tabs open. All this value has to be is unique per
1623
+ * browser session, which `randomUUID` gives us for free and offline.
1530
1624
  */
1531
- private async fetchSessionId(): Promise<string> {
1532
- try {
1533
- const [sid] = await this.remote.query<[string]>('RETURN <string>session::id()');
1534
- return typeof sid === 'string' ? sid : '';
1535
- } catch (e) {
1536
- this.logger.warn(
1537
- { error: e, Category: 'sp00ky-client::Sp00kyClient::fetchSessionId' },
1538
- 'Failed to fetch session::id() proceeding with empty salt'
1539
- );
1540
- return '';
1541
- }
1625
+ /**
1626
+ * The current principal as the `"table:id"` string the in-browser SSP wants
1627
+ * for `$auth.id`, or null when signed out.
1628
+ *
1629
+ * Tolerates BOTH shapes `currentUser.id` can take, which is the point:
1630
+ * a session restored from the cached token carries a plain string (the JWT's
1631
+ * `ID` claim), while one verified by the server carries a RecordId. Passing
1632
+ * the former to `encodeRecordId` reads `.table` off a string and throws
1633
+ * during boot.
1634
+ */
1635
+ private sessionAuthId(): string | null {
1636
+ const id = this.auth.currentUser?.id;
1637
+ if (!id) return null;
1638
+ return typeof id === 'string' ? id : encodeRecordId(id);
1639
+ }
1640
+
1641
+ private mintSessionSalt(): string {
1642
+ const c: Crypto | undefined =
1643
+ typeof globalThis !== 'undefined' ? (globalThis as { crypto?: Crypto }).crypto : undefined;
1644
+ if (c?.randomUUID) return c.randomUUID();
1645
+ // Older browsers / non-secure contexts: uniqueness is all that is required.
1646
+ return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
1542
1647
  }
1543
1648
  }