@spooky-sync/core 0.0.1-canary.202 → 0.0.1-canary.204

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.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as renderWhereSql, c as resolveRelations, i as renderOrderSql, l as stableKey, o as reviveRow, r as project, s as serializeRow, t as PROMOTION_OPEN_OPTIONS } from "./sqlite-open.js";
1
+ import { a as renderOrderSql, c as serializeRow, i as projectedDataSql, l as resolveRelations, o as renderWhereSql, r as project, s as reviveRow, t as PROMOTION_OPEN_OPTIONS, u as stableKey } from "./sqlite-open.js";
2
2
  import { DateTime, Duration, RecordId, Surreal, Uuid, applyDiagnostics, createRemoteEngines } from "surrealdb";
3
3
  import { QueryBuilder, RecordId as RecordId$1, cyrb53 } from "@spooky-sync/query-builder";
4
4
  import pino from "pino";
@@ -2658,16 +2658,15 @@ var SqliteCacheEngine = class {
2658
2658
  }
2659
2659
  await this.ensureTable(plan.table);
2660
2660
  const bind = [];
2661
- let sql = `SELECT data FROM "${plan.table}"`;
2661
+ let sql = `SELECT ${plan.select ? projectedDataSql(plan.select, bind) : "data"} FROM "${plan.table}"`;
2662
2662
  if (plan.where && plan.where.length > 0) sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
2663
2663
  if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
2664
2664
  else sql += ` ORDER BY id`;
2665
2665
  if (plan.limit !== void 0) sql += ` LIMIT ${Number(plan.limit)}`;
2666
2666
  if (plan.offset !== void 0) sql += ` OFFSET ${Number(plan.offset)}`;
2667
2667
  const rows = await this.execRows(sql, bind);
2668
- const projected = plan.select ? rows.map((r) => project(r, plan.select)) : rows;
2669
- await resolveRelations(projected, plan.relations, this);
2670
- return projected;
2668
+ await resolveRelations(rows, plan.relations, this);
2669
+ return rows;
2671
2670
  }
2672
2671
  async fetchRelation(req) {
2673
2672
  getStats().relationFetches++;
@@ -2686,14 +2685,18 @@ var SqliteCacheEngine = class {
2686
2685
  if (ids.length === 0) return [];
2687
2686
  await this.ensureTable(table);
2688
2687
  const keys = ids.map(stableKey);
2689
- let sql = `SELECT data FROM "${table}" WHERE id IN (${keys.map(() => "?").join(", ")})`;
2688
+ const placeholders = keys.map(() => "?").join(", ");
2689
+ const bind = [];
2690
+ const dataCol = opts?.select ? projectedDataSql(opts.select, bind) : "data";
2691
+ bind.push(...keys);
2692
+ let sql = `SELECT ${dataCol} FROM "${table}" WHERE id IN (${placeholders})`;
2690
2693
  if (opts?.orderBy && opts.orderBy.length > 0) sql += renderOrderSql(opts.orderBy);
2691
- let rows = await this.execRows(sql, keys);
2694
+ let rows = await this.execRows(sql, bind);
2692
2695
  if (!opts?.orderBy || opts.orderBy.length === 0) {
2693
2696
  const pos = new Map(keys.map((k, i) => [k, i]));
2694
2697
  rows = rows.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
2695
2698
  }
2696
- return opts?.select ? rows.map((r) => project(r, opts.select)) : rows;
2699
+ return rows;
2697
2700
  }
2698
2701
  async getById(table, id) {
2699
2702
  await this.ensureTable(table);
@@ -3586,6 +3589,15 @@ var DataModule = class DataModule {
3586
3589
  }
3587
3590
  settledWrites = /* @__PURE__ */ new Map();
3588
3591
  settledDeletes = /* @__PURE__ */ new Map();
3592
+ pendingIds = null;
3593
+ pendingIdsAt = 0;
3594
+ pendingIdsInflight = null;
3595
+ static PENDING_IDS_TTL_MS = 250;
3596
+ /** Drop the cached outbox ids. Cheap; call it on anything that could change
3597
+ * `_00_pending_mutations`. */
3598
+ invalidatePendingIds() {
3599
+ this.pendingIds = null;
3600
+ }
3589
3601
  /**
3590
3602
  * Grace period for a settled write. Long enough to cover an SSP round trip
3591
3603
  * that is running slowly (seconds, not milliseconds, when the edge path is
@@ -3604,6 +3616,7 @@ var DataModule = class DataModule {
3604
3616
  * disappear immediately, which is what makes this safe.
3605
3617
  */
3606
3618
  noteWriteSettled(recordId, mutationType) {
3619
+ this.invalidatePendingIds();
3607
3620
  const until = Date.now() + DataModule.SETTLED_WRITE_GRACE_MS;
3608
3621
  if (mutationType === "delete") this.settledDeletes.set(recordId, until);
3609
3622
  else this.settledWrites.set(recordId, until);
@@ -3989,6 +4002,24 @@ var DataModule = class DataModule {
3989
4002
  * briefly hide an optimistic write but never resurrects a deleted row.
3990
4003
  */
3991
4004
  async getPendingRecordIds() {
4005
+ const now = Date.now();
4006
+ const cached = this.pendingIds;
4007
+ if (cached && now - this.pendingIdsAt < DataModule.PENDING_IDS_TTL_MS) return {
4008
+ writes: new Set(cached.writes),
4009
+ deletes: new Set(cached.deletes)
4010
+ };
4011
+ if (!this.pendingIdsInflight) this.pendingIdsInflight = this.readPendingRecordIds().finally(() => {
4012
+ this.pendingIdsInflight = null;
4013
+ });
4014
+ const fresh = await this.pendingIdsInflight;
4015
+ return {
4016
+ writes: new Set(fresh.writes),
4017
+ deletes: new Set(fresh.deletes)
4018
+ };
4019
+ }
4020
+ /** The uncached read. Also the reload path after an invalidation, so the ids
4021
+ * still survive a reload exactly as before. */
4022
+ async readPendingRecordIds() {
3992
4023
  const writes = /* @__PURE__ */ new Set();
3993
4024
  const deletes = /* @__PURE__ */ new Set();
3994
4025
  try {
@@ -4004,7 +4035,16 @@ var DataModule = class DataModule {
4004
4035
  err,
4005
4036
  Category: "sp00ky-client::DataModule::getPendingRecordIds"
4006
4037
  }, "Failed to read pending mutations; optimistic writes may be briefly hidden");
4038
+ return {
4039
+ writes,
4040
+ deletes
4041
+ };
4007
4042
  }
4043
+ this.pendingIds = {
4044
+ writes,
4045
+ deletes
4046
+ };
4047
+ this.pendingIdsAt = Date.now();
4008
4048
  return {
4009
4049
  writes,
4010
4050
  deletes
@@ -4321,6 +4361,7 @@ var DataModule = class DataModule {
4321
4361
  record: target,
4322
4362
  tableName
4323
4363
  };
4364
+ this.invalidatePendingIds();
4324
4365
  for (const callback of this.mutationCallbacks) callback([mutationEvent]);
4325
4366
  this.logger.debug({
4326
4367
  id,
@@ -4374,6 +4415,7 @@ var DataModule = class DataModule {
4374
4415
  beforeRecord: beforeRecord || void 0,
4375
4416
  options: pushEventOptions
4376
4417
  };
4418
+ this.invalidatePendingIds();
4377
4419
  for (const callback of this.mutationCallbacks) callback([mutationEvent]);
4378
4420
  this.logger.debug({
4379
4421
  id,
@@ -4419,6 +4461,7 @@ var DataModule = class DataModule {
4419
4461
  mutation_id: mutationId,
4420
4462
  record_id: rid
4421
4463
  };
4464
+ this.invalidatePendingIds();
4422
4465
  for (const callback of this.mutationCallbacks) callback([mutationEvent]);
4423
4466
  this.logger.debug({
4424
4467
  id,
@@ -5071,9 +5114,43 @@ var DownQueue = class {
5071
5114
  }
5072
5115
  async next(fn) {
5073
5116
  const event = this.queue.shift();
5074
- if (event) try {
5117
+ if (!event) return;
5118
+ const error = await this.run(event, fn);
5119
+ if (error !== void 0) throw error;
5120
+ }
5121
+ /**
5122
+ * The next event whose hash is NOT already being processed, or `undefined`
5123
+ * when every remaining event is blocked on a busy hash.
5124
+ *
5125
+ * Ordering only ever mattered PER HASH — a `cleanup` must not overtake the
5126
+ * `register` for the same query — but the queue enforced it globally, so one
5127
+ * registration RPC at a time was the ceiling for the whole client. An event
5128
+ * for a busy hash keeps its place here (it is skipped, not reordered), so
5129
+ * per-hash ordering is preserved exactly while independent hashes proceed
5130
+ * concurrently.
5131
+ */
5132
+ takeNext(busy) {
5133
+ for (let i = 0; i < this.queue.length; i++) {
5134
+ const event = this.queue[i];
5135
+ if (busy.has(event.payload.hash)) continue;
5136
+ this.queue.splice(i, 1);
5137
+ return event;
5138
+ }
5139
+ }
5140
+ /**
5141
+ * Process one event, applying the re-head / rotate failure policy.
5142
+ *
5143
+ * NEVER rejects: it RETURNS the error instead (`undefined` on success). A
5144
+ * concurrent drain has other work in flight when one event fails, and a
5145
+ * rejection would either take that work down with it or have to be caught at
5146
+ * every call site. `next` reinstates the throwing contract for the serial
5147
+ * callers that still want it.
5148
+ */
5149
+ async run(event, fn) {
5150
+ try {
5075
5151
  await fn(event);
5076
5152
  this.failures.delete(event);
5153
+ return;
5077
5154
  } catch (error) {
5078
5155
  const attempts = (this.failures.get(event) ?? 0) + 1;
5079
5156
  this.failures.set(event, attempts);
@@ -5087,7 +5164,7 @@ var DownQueue = class {
5087
5164
  rotated: starvingOthers,
5088
5165
  Category: "sp00ky-client::DownQueue::next"
5089
5166
  }, "Failed to process query");
5090
- throw error;
5167
+ return error;
5091
5168
  }
5092
5169
  }
5093
5170
  };
@@ -5452,6 +5529,17 @@ var SyncEngine = class {
5452
5529
  */
5453
5530
  /** Backoff for re-draining a queue that halted on an error. */
5454
5531
  const RETRY_BASE_MS = 500;
5532
+ /**
5533
+ * How many down events may be in flight at once.
5534
+ *
5535
+ * The down queue was strictly serial: one `register`/`sync`/`cleanup` RPC at a
5536
+ * time for the WHOLE client. Measured in production that drained at roughly one
5537
+ * event per 8.8s, so a list that registers a query per scrolled-to window took
5538
+ * minutes to fill — the rows were already cached, only the registration lagged.
5539
+ * Ordering is a per-hash requirement, not a global one (see `takeNext`), so
5540
+ * independent hashes can go in parallel. Bounded to stay polite to the SSP.
5541
+ */
5542
+ const MAX_CONCURRENT_DOWN = 4;
5455
5543
  const RETRY_MAX_MS = 15e3;
5456
5544
  var SyncScheduler = class {
5457
5545
  isSyncingUp = false;
@@ -5593,14 +5681,35 @@ var SyncScheduler = class {
5593
5681
  }
5594
5682
  this.isSyncingDown = true;
5595
5683
  let processedAny = false;
5684
+ const busy = /* @__PURE__ */ new Set();
5685
+ const inFlight = /* @__PURE__ */ new Set();
5686
+ let failure;
5596
5687
  try {
5597
- while (this.downQueue.size > 0 && !this.paused) {
5598
- if (this.upQueue.size > 0) break;
5599
- await this.downQueue.next(this.onProcessDown);
5600
- processedAny = true;
5688
+ for (;;) {
5689
+ if (this.paused) break;
5690
+ const yieldToUp = this.upQueue.size > 0;
5691
+ while (failure === void 0 && !yieldToUp && inFlight.size < MAX_CONCURRENT_DOWN) {
5692
+ const event = this.downQueue.takeNext(busy);
5693
+ if (!event) break;
5694
+ const hash = event.payload.hash;
5695
+ busy.add(hash);
5696
+ processedAny = true;
5697
+ const task = this.downQueue.run(event, this.onProcessDown).then((error) => {
5698
+ if (error !== void 0 && failure === void 0) failure = error;
5699
+ }).finally(() => {
5700
+ busy.delete(hash);
5701
+ inFlight.delete(task);
5702
+ });
5703
+ inFlight.add(task);
5704
+ }
5705
+ if (inFlight.size === 0) break;
5706
+ await Promise.race(inFlight);
5707
+ if (yieldToUp && inFlight.size === 0) break;
5601
5708
  }
5709
+ if (failure !== void 0) throw failure;
5602
5710
  if (processedAny) this.onSyncOutcome?.(true);
5603
5711
  this.downRetryAttempt = 0;
5712
+ if (this.downQueue.size > 0) this.scheduleDownRetry(RETRY_BASE_MS);
5604
5713
  } catch (error) {
5605
5714
  this.onSyncOutcome?.(false, error);
5606
5715
  this.scheduleDownRetry();
@@ -5691,6 +5800,7 @@ var Sp00kySync = class Sp00kySync {
5691
5800
  degradeAfterFailures;
5692
5801
  /** Per-push RPC deadline; see {@link withPushTimeout}. */
5693
5802
  pushTimeoutMs;
5803
+ downTimeoutMs;
5694
5804
  consecutiveSyncFailures = 0;
5695
5805
  syncHealthStatus = "healthy";
5696
5806
  lastSyncErrorKind;
@@ -5862,6 +5972,7 @@ var Sp00kySync = class Sp00kySync {
5862
5972
  this.anonLiveEnabled = options?.anonymousLiveQueries ?? false;
5863
5973
  this.degradeAfterFailures = Math.max(0, options?.degradeAfterConsecutiveFailures ?? 3);
5864
5974
  this.pushTimeoutMs = Math.max(0, options?.pushTimeoutMs ?? 3e4);
5975
+ this.downTimeoutMs = Math.max(0, options?.downTimeoutMs ?? 3e4);
5865
5976
  this.connectionSupervisor = options?.connectionSupervisor;
5866
5977
  }
5867
5978
  /**
@@ -5873,6 +5984,7 @@ var Sp00kySync = class Sp00kySync {
5873
5984
  if (this.isInit) throw new Error("Sp00kySync is already initialized");
5874
5985
  this.isInit = true;
5875
5986
  await this.scheduler.init({ loadOutbox: this.tabRole !== "follower" });
5987
+ if (this.remote.getStatus() !== "connected") this.needsResubscribe = true;
5876
5988
  this.subscribeToReconnect();
5877
5989
  this.subscribeToConnectionState();
5878
5990
  this.scheduler.syncUp();
@@ -6543,6 +6655,12 @@ var Sp00kySync = class Sp00kySync {
6543
6655
  event,
6544
6656
  Category: "sp00ky-client::Sp00kySync::processDownEvent"
6545
6657
  }, "Processing down event");
6658
+ return this.withDownTimeout(this.runDownEvent(event), `${event.type} ${event.payload.hash}`);
6659
+ }
6660
+ withDownTimeout(promise, label) {
6661
+ return withTimeout(promise, this.downTimeoutMs, `Down event timed out after ${this.downTimeoutMs}ms (${label})`);
6662
+ }
6663
+ async runDownEvent(event) {
6546
6664
  switch (event.type) {
6547
6665
  case "register": return this.registerQuery(event.payload.hash);
6548
6666
  case "sync": return this.syncQuery(event.payload.hash);
@@ -7093,8 +7211,8 @@ function selfAllowlistedVariant(flag, userId) {
7093
7211
 
7094
7212
  //#endregion
7095
7213
  //#region src/modules/devtools/index.ts
7096
- const CORE_VERSION = "0.0.1-canary.202";
7097
- const WASM_VERSION = "0.0.1-canary.202";
7214
+ const CORE_VERSION = "0.0.1-canary.204";
7215
+ const WASM_VERSION = "0.0.1-canary.204";
7098
7216
  const SURREAL_VERSION = "3.0.3";
7099
7217
  var DevToolsService = class DevToolsService {
7100
7218
  eventsHistory = [];
@@ -7579,26 +7697,44 @@ var DevToolsService = class DevToolsService {
7579
7697
  //#endregion
7580
7698
  //#region src/modules/auth/index.ts
7581
7699
  /**
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).
7700
+ * Read the claims of a SurrealDB record-access JWT WITHOUT verifying it. The
7701
+ * server still enforces the token on every request; this is only so the client
7702
+ * can act on what it already holds before a round trip completes.
7703
+ *
7704
+ * `AC` is the access-method name — the in-browser SSP needs it to resolve
7705
+ * `$access` in table permission predicates (mirrors the session's `$access`
7706
+ * that the server's `fn::query::register` reads). `ID` is the `$auth.id` record
7707
+ * id, which is what lets a warm boot restore a session locally.
7708
+ *
7709
+ * Returns nulls on any malformed input.
7587
7710
  */
7588
- function decodeAccessFromToken(token) {
7711
+ function decodeTokenClaims(token) {
7589
7712
  try {
7590
7713
  const payload = token.split(".")[1];
7591
- if (!payload) return null;
7714
+ if (!payload) return {
7715
+ access: null,
7716
+ userId: null
7717
+ };
7592
7718
  let b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
7593
7719
  b64 += "=".repeat((4 - b64.length % 4) % 4);
7594
7720
  const json = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("binary");
7595
7721
  const claims = JSON.parse(json);
7596
7722
  const ac = claims.AC ?? claims.ac;
7597
- return typeof ac === "string" ? ac : null;
7723
+ const id = claims.ID ?? claims.id;
7724
+ return {
7725
+ access: typeof ac === "string" ? ac : null,
7726
+ userId: typeof id === "string" ? id : null
7727
+ };
7598
7728
  } catch {
7599
- return null;
7729
+ return {
7730
+ access: null,
7731
+ userId: null
7732
+ };
7600
7733
  }
7601
7734
  }
7735
+ function decodeAccessFromToken(token) {
7736
+ return decodeTokenClaims(token).access;
7737
+ }
7602
7738
  var AuthService = class {
7603
7739
  token = null;
7604
7740
  currentUser = null;
@@ -7645,6 +7781,39 @@ var AuthService = class {
7645
7781
  this.events.emit(AuthEventTypes.AuthStateChanged, userId);
7646
7782
  }
7647
7783
  /**
7784
+ * Restore a session from the locally cached JWT, with NO network.
7785
+ *
7786
+ * This is what makes a warm boot paint instantly and what makes an offline
7787
+ * boot possible at all: the token is in local storage, and it already carries
7788
+ * both the access method and the `$auth.id` record id. Everything the client
7789
+ * needs to route queries (`setCurrentUserId`) and to satisfy `$auth`-gated
7790
+ * permission predicates in the in-browser SSP (`setSessionAuth`) is therefore
7791
+ * available before a socket exists.
7792
+ *
7793
+ * The session is OPTIMISTIC: the token is unverified here. `check()` runs
7794
+ * afterwards in the background and downgrades to a real sign-out if the
7795
+ * server rejects it. Nothing is trusted that the server has not also seen -
7796
+ * the local store only ever holds rows the server previously sent.
7797
+ *
7798
+ * Returns the restored user id, or null when there is no usable token.
7799
+ */
7800
+ async restoreSessionFromToken() {
7801
+ const token = await this.persistenceClient.get("sp00ky_auth_token");
7802
+ if (!token) return null;
7803
+ const { access, userId } = decodeTokenClaims(token);
7804
+ if (!userId) return null;
7805
+ this.token = token;
7806
+ this.currentUser = { id: userId };
7807
+ this.isAuthenticated = true;
7808
+ this.access = access ?? this.defaultAccessName();
7809
+ this.notifyListeners();
7810
+ this.logger.debug({
7811
+ userId,
7812
+ Category: "sp00ky-client::AuthService::restoreSessionFromToken"
7813
+ }, "Session restored optimistically from cached token");
7814
+ return userId;
7815
+ }
7816
+ /**
7648
7817
  * Check for existing session and validate
7649
7818
  */
7650
7819
  async check(accessToken) {
@@ -7685,12 +7854,18 @@ var AuthService = class {
7685
7854
  }
7686
7855
  }
7687
7856
  } catch (error) {
7688
- this.logger.error({
7857
+ if (classifySyncError(error) === "network") this.logger.warn({
7689
7858
  error,
7690
- stack: error.stack,
7691
7859
  Category: "sp00ky-client::AuthService::check"
7692
- }, "Auth check failed");
7693
- await this.signOut();
7860
+ }, "Auth check unreachable; keeping the cached session and retrying later");
7861
+ else {
7862
+ this.logger.error({
7863
+ error,
7864
+ stack: error.stack,
7865
+ Category: "sp00ky-client::AuthService::check"
7866
+ }, "Auth check failed");
7867
+ await this.signOut();
7868
+ }
7694
7869
  } finally {
7695
7870
  this.isLoading = false;
7696
7871
  }
@@ -7836,6 +8011,51 @@ var StreamProcessorService = class {
7836
8011
  */
7837
8012
  ingestMany(records) {
7838
8013
  if (records.length === 0) return;
8014
+ if (!this.processor) {
8015
+ this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::ingestMany" }, "Not initialized, skipping ingest");
8016
+ return;
8017
+ }
8018
+ const bulkIngest = this.processor.ingest_many;
8019
+ if (typeof bulkIngest === "function") {
8020
+ this.logger.debug({
8021
+ count: records.length,
8022
+ Category: "sp00ky-client::StreamProcessorService::ingestMany"
8023
+ }, "Ingesting batch into ssp");
8024
+ try {
8025
+ const items = records.map((record) => ({
8026
+ table: record.table,
8027
+ op: record.op,
8028
+ id: record.id,
8029
+ record: this.normalizeValue(record.record)
8030
+ }));
8031
+ const t0 = performance.now();
8032
+ const rawUpdates = bulkIngest.call(this.processor, items) ?? [];
8033
+ const materializationTimeMs = performance.now() - t0;
8034
+ this.logger.debug({
8035
+ count: records.length,
8036
+ rawUpdates: rawUpdates.length,
8037
+ materializationTimeMs,
8038
+ Category: "sp00ky-client::StreamProcessorService::ingestMany"
8039
+ }, "Ingesting batch into ssp done");
8040
+ if (rawUpdates.length > 0) this.dispatchUpdates(rawUpdates.map((u) => ({
8041
+ queryHash: u.query_id,
8042
+ localArray: u.result_data,
8043
+ op: "CREATE",
8044
+ materializationTimeMs,
8045
+ storeApplyMs: u.timing_store_apply_ms,
8046
+ circuitStepMs: u.timing_circuit_step_ms,
8047
+ transformMs: u.timing_transform_ms
8048
+ })));
8049
+ } catch (e) {
8050
+ this.logger.error({
8051
+ error: e,
8052
+ count: records.length,
8053
+ Category: "sp00ky-client::StreamProcessorService::ingestMany"
8054
+ }, "Ingesting batch into ssp failed");
8055
+ }
8056
+ this.markSnapshotDirty();
8057
+ return;
8058
+ }
7839
8059
  this.beginCoalescing();
7840
8060
  try {
7841
8061
  for (const record of records) this.ingest(record.table, record.op, record.id, record.record);
@@ -8363,14 +8583,12 @@ var CacheModule = class {
8363
8583
  const query = surql.seal(surql.tx(populatedRecords.map((_, i) => {
8364
8584
  return surql.upsertMerge(`id${i}`, `content${i}`);
8365
8585
  })));
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
- }, {});
8586
+ const params = {};
8587
+ for (let i = 0; i < populatedRecords.length; i++) {
8588
+ const { id, ...content } = populatedRecords[i].record;
8589
+ params[`id${i}`] = id;
8590
+ params[`content${i}`] = content;
8591
+ }
8374
8592
  await this.local.execute(query, params, { epoch });
8375
8593
  }
8376
8594
  if (this.local.epoch !== epoch) throw new StaleEpochError();
@@ -11506,6 +11724,15 @@ var Sp00kyClient = class {
11506
11724
  sync;
11507
11725
  devTools;
11508
11726
  crdtManager;
11727
+ /**
11728
+ * True once the LOCAL half of boot is done and the client can serve reads
11729
+ * from the local store. Distinct from being connected: `syncHealth` covers
11730
+ * reaching the server and `storageHealth` covers whether the local store is
11731
+ * durable, but neither says "usable". Consumers gate their first paint on
11732
+ * this, which is what makes a warm boot instant and an offline boot possible.
11733
+ */
11734
+ localReady = false;
11735
+ saltUserId = null;
11509
11736
  featureFlags;
11510
11737
  appReleases;
11511
11738
  preloadedHashes = /* @__PURE__ */ new Set();
@@ -11608,6 +11835,7 @@ var Sp00kyClient = class {
11608
11835
  anonymousLiveQueries: this.config.enableAnonymousLiveQueries,
11609
11836
  degradeAfterConsecutiveFailures: this.config.syncHealth === false ? 0 : this.config.syncHealth?.degradeAfterConsecutiveFailures ?? 3,
11610
11837
  pushTimeoutMs: this.config.pushTimeoutMs,
11838
+ downTimeoutMs: this.config.downTimeoutMs,
11611
11839
  connectionSupervisor: this.connectionSupervisor
11612
11840
  });
11613
11841
  this.featureFlags = new FeatureFlagModule({
@@ -11662,7 +11890,7 @@ var Sp00kyClient = class {
11662
11890
  return new TabsCoordinator({
11663
11891
  tabId,
11664
11892
  fingerprint: computeTabsFingerprint({
11665
- coreVersion: "0.0.1-canary.202",
11893
+ coreVersion: "0.0.1-canary.204",
11666
11894
  schemaHash: hash53(this.config.schemaSurql),
11667
11895
  endpoint: this.config.database.endpoint ?? "",
11668
11896
  namespace: this.config.database.namespace,
@@ -11805,9 +12033,6 @@ var Sp00kyClient = class {
11805
12033
  await this.migrator.provision(this.config.schemaSurql);
11806
12034
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Schema provisioned");
11807
12035
  }
11808
- await this.remote.connect();
11809
- this.connectionSupervisor.start();
11810
- this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Remote database connected");
11811
12036
  (async () => {
11812
12037
  try {
11813
12038
  this.blobs.setMaxBytes(await resolveBlobBudget(this.config.blobCache?.maxBytes));
@@ -11823,23 +12048,31 @@ var Sp00kyClient = class {
11823
12048
  await this.streamProcessor.init();
11824
12049
  this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
11825
12050
  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();
12051
+ const restoredUserId = await this.auth.restoreSessionFromToken();
12052
+ const sessionId = this.mintSessionSalt();
12053
+ this.saltUserId = restoredUserId;
11829
12054
  await this.dataModule.init(sessionId);
11830
12055
  this.crdtManager.setSessionId(sessionId);
12056
+ if (restoredUserId) {
12057
+ this.dataModule.setCurrentUserId(restoredUserId);
12058
+ this.streamProcessor.setSessionAuth(this.sessionAuthId(), this.auth.access);
12059
+ }
11831
12060
  this.logger.debug({
11832
12061
  sessionId,
11833
12062
  Category: "sp00ky-client::Sp00kyClient::init"
11834
12063
  }, "DataModule initialized");
11835
12064
  this.auth.subscribe(async (userId) => {
11836
12065
  this.dataModule.setCurrentUserId(userId);
11837
- this.streamProcessor.setSessionAuth(this.auth.currentUser?.id ? encodeRecordId(this.auth.currentUser.id) : null, this.auth.access);
12066
+ this.streamProcessor.setSessionAuth(this.sessionAuthId(), this.auth.access);
11838
12067
  writeBootBucketHint(bucketIdForUser(userId));
11839
12068
  await this.ensureLocalBucket(userId);
11840
- const next = await this.fetchSessionId();
11841
- this.dataModule.setSessionId(next);
11842
- this.crdtManager.setSessionId(next);
12069
+ const saltFor = this.sessionAuthId();
12070
+ if (saltFor !== this.saltUserId) {
12071
+ this.saltUserId = saltFor;
12072
+ const next = this.mintSessionSalt();
12073
+ this.dataModule.setSessionId(next);
12074
+ this.crdtManager.setSessionId(next);
12075
+ }
11843
12076
  try {
11844
12077
  await this.sync.setCurrentUserId(userId);
11845
12078
  } catch (e) {
@@ -11855,7 +12088,9 @@ var Sp00kyClient = class {
11855
12088
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "FeatureFlagModule initialized");
11856
12089
  this.appReleases.init();
11857
12090
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "AppReleaseModule initialized");
11858
- this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization completed successfully");
12091
+ this.localReady = true;
12092
+ this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient local initialization completed; connecting in the background");
12093
+ this.initRemote();
11859
12094
  } catch (e) {
11860
12095
  this.logger.error({
11861
12096
  error: e,
@@ -11864,6 +12099,36 @@ var Sp00kyClient = class {
11864
12099
  throw e;
11865
12100
  }
11866
12101
  }
12102
+ /**
12103
+ * The network half of boot: connect, verify the restored session, and let the
12104
+ * sync engine catch up. Runs in the background after `init()` has already
12105
+ * resolved, so nothing here is on the paint path.
12106
+ *
12107
+ * Every step is best-effort. A failure leaves the client in exactly the state
12108
+ * a warm offline boot is in - local reads working, writes queued in the
12109
+ * outbox - and the connection supervisor keeps retrying underneath.
12110
+ */
12111
+ async initRemote() {
12112
+ try {
12113
+ await this.remote.connect();
12114
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::initRemote" }, "Remote database connected");
12115
+ } catch (e) {
12116
+ this.logger.warn({
12117
+ err: e,
12118
+ Category: "sp00ky-client::Sp00kyClient::initRemote"
12119
+ }, "Remote connect failed; running from the local store and retrying in the background");
12120
+ }
12121
+ this.connectionSupervisor.start();
12122
+ try {
12123
+ await this.auth.init();
12124
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::initRemote" }, "Auth verified");
12125
+ } catch (e) {
12126
+ this.logger.warn({
12127
+ err: e,
12128
+ Category: "sp00ky-client::Sp00kyClient::initRemote"
12129
+ }, "Auth verification failed; keeping the restored session");
12130
+ }
12131
+ }
11867
12132
  bucketSwitchChain = Promise.resolve();
11868
12133
  pendingBucketTarget = null;
11869
12134
  /**
@@ -12238,26 +12503,49 @@ var Sp00kyClient = class {
12238
12503
  delete(table, id) {
12239
12504
  return this.dataModule.delete(table, id);
12240
12505
  }
12506
+ /**
12507
+ * Whether the local store is initialized and reads can be served. See the
12508
+ * `localReady` field: this is deliberately independent of connectivity.
12509
+ */
12510
+ isLocalReady() {
12511
+ return this.localReady;
12512
+ }
12241
12513
  async useRemote(fn) {
12242
12514
  return fn(this.remote.getClient());
12243
12515
  }
12244
12516
  /**
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
- }
12517
+ * Mint the salt used for query-id hashing, so two sessions registering the
12518
+ * same logical query get distinct `_00_query` rows.
12519
+ *
12520
+ * Generated LOCALLY, deliberately. This used to be `RETURN <string>session::id()`,
12521
+ * which cost a serial round trip on the critical boot path and resolved to
12522
+ * `''` offline. The value never needed to come from the server: the server
12523
+ * derives its own `clientId` inside `fn::query::register` and *ignores*
12524
+ * whatever the caller passed, and the permission rules that matter gate on
12525
+ * `auth_id = $auth.id` rather than the session (`_00_list_ref`). Session
12526
+ * scoping via `clientId = session::id()` was in fact removed upstream because
12527
+ * it broke a user with two tabs open. All this value has to be is unique per
12528
+ * browser session, which `randomUUID` gives us for free and offline.
12529
+ */
12530
+ /**
12531
+ * The current principal as the `"table:id"` string the in-browser SSP wants
12532
+ * for `$auth.id`, or null when signed out.
12533
+ *
12534
+ * Tolerates BOTH shapes `currentUser.id` can take, which is the point:
12535
+ * a session restored from the cached token carries a plain string (the JWT's
12536
+ * `ID` claim), while one verified by the server carries a RecordId. Passing
12537
+ * the former to `encodeRecordId` reads `.table` off a string and throws
12538
+ * during boot.
12539
+ */
12540
+ sessionAuthId() {
12541
+ const id = this.auth.currentUser?.id;
12542
+ if (!id) return null;
12543
+ return typeof id === "string" ? id : encodeRecordId(id);
12544
+ }
12545
+ mintSessionSalt() {
12546
+ const c = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
12547
+ if (c?.randomUUID) return c.randomUUID();
12548
+ return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
12261
12549
  }
12262
12550
  };
12263
12551