@spooky-sync/core 0.0.1-canary.113 → 0.0.1-canary.117

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
@@ -1,7 +1,7 @@
1
- import { A as SyncHealthStatus, B as EventDefinition, C as RunOptions, D as StoreType, E as Sp00kyQueryResultPromise, F as SealedQuery, I as DatabaseEventSystem, L as DatabaseEventTypes, M as UpdateOptions, N as UpEvent, O as SyncHealth, P as LocalStore, R as Logger$1, S as RegistrationTimings, T as Sp00kyQueryResult, V as EventSystem, _ as QueryTimeToLive, a as MutationCallback, b as RecordVersionArray, c as PersistenceClient, d as QueryConfig, f as QueryConfigRecord, g as QueryStatusCallback, h as QueryStatus, i as MATERIALIZATION_SAMPLE_WINDOW, j as TimingPhase, k as SyncHealthConfig, l as PhaseStat, m as QueryState, n as EventSubscriptionOptions, o as MutationEvent, p as QueryHash, r as Level, s as MutationEventType, t as DebounceOptions, u as PinoTransmit, v as QueryTimings, w as Sp00kyConfig, x as RecordVersionDiff, y as QueryUpdateCallback, z as SyncEventSystem } from "./types.js";
1
+ import { A as SyncHealth, B as Logger$1, C as RecordVersionDiff, D as Sp00kyQueryResult, E as Sp00kyConfig, F as UpEvent, H as EventDefinition, I as LocalStore, L as SealedQuery, M as SyncHealthStatus, N as TimingPhase, O as Sp00kyQueryResultPromise, P as UpdateOptions, R as DatabaseEventSystem, S as RecordVersionArray, T as RunOptions, U as EventSystem, V as SyncEventSystem, _ as QueryStatus, a as MutationCallback, b as QueryTimings, c as PersistenceClient, d as PreloadOptions, f as PreloadRefresh, g as QueryState, h as QueryHash, i as MATERIALIZATION_SAMPLE_WINDOW, j as SyncHealthConfig, k as StoreType, l as PhaseStat, m as QueryConfigRecord, n as EventSubscriptionOptions, o as MutationEvent, p as QueryConfig, r as Level, s as MutationEventType, t as DebounceOptions, u as PinoTransmit, v as QueryStatusCallback, w as RegistrationTimings, x as QueryUpdateCallback, y as QueryTimeToLive, z as DatabaseEventTypes } from "./types.js";
2
2
  import * as surrealdb0 from "surrealdb";
3
3
  import { Duration, RecordId, Surreal as Surreal$1, SurrealTransaction } from "surrealdb";
4
- import { AccessDefinition, BackendNames, BackendRoutes, BucketNames, ColumnSchema, GetTable, QueryBuilder, QueryOptions, QueryPlan, RoutePayload, SchemaStructure, TableModel, TableNames, TypeNameToTypeMap } from "@spooky-sync/query-builder";
4
+ import { AccessDefinition, BackendNames, BackendRoutes, BucketNames, ColumnSchema, FinalQuery, GetTable, QueryBuilder, QueryOptions, QueryPlan, RoutePayload, SchemaStructure, TableModel, TableNames, TypeNameToTypeMap } from "@spooky-sync/query-builder";
5
5
  import { Logger } from "pino";
6
6
  import { LoroDoc } from "loro-crdt";
7
7
 
@@ -484,6 +484,37 @@ declare class DataModule<S extends SchemaStructure> {
484
484
  * local-circuit issue). Runs at most once per query (the `hydrated` flag).
485
485
  */
486
486
  applyHydration(hash: string, rows: RecordWithId[]): Promise<void>;
487
+ /**
488
+ * Build the cache batch for a set of one-shot rows and persist it to the
489
+ * local DB + in-browser SSP. Maps each row to a `CREATE` op on its own table
490
+ * and extracts EMBEDDED related children (any nesting depth) as their own
491
+ * records — a `.related()` query returns its children embedded, and a later
492
+ * correlated re-materialization needs them present as standalone rows.
493
+ * Shared by `applyHydration` (live registration) and `persistSnapshot`
494
+ * (preload).
495
+ */
496
+ private buildAndSaveCacheBatch;
497
+ /**
498
+ * Preload/prewarm: persist one-shot rows (and their embedded related children)
499
+ * into the local cache WITHOUT registering a query — no `activeQueries` entry,
500
+ * no `_00_query` view, no TTL heartbeat. The rows live in the local DB as
501
+ * ordinary bodies (never GC'd on their own) so a later `useQuery` seeds its
502
+ * first paint from them instantly, then registers a live view to freshen.
503
+ */
504
+ persistSnapshot(tableName: string, rows: RecordWithId[]): Promise<void>;
505
+ /**
506
+ * Read the durable preload freshness marker for a query hash, or null if this
507
+ * query was never preloaded in the current bucket. Co-located with the cached
508
+ * rows (per-bucket `_00_preload` table) so a bucket switch that clears the
509
+ * data also clears the marker — a stale marker can't claim "warm" when the
510
+ * rows are gone. Any read error is treated as cold.
511
+ */
512
+ getPreloadMarker(hash: string): Promise<{
513
+ fetchedAt: number;
514
+ rowCount: number;
515
+ } | null>;
516
+ /** Stamp the preload freshness marker after a successful snapshot fetch. */
517
+ writePreloadMarker(hash: string, rowCount: number): Promise<void>;
487
518
  /** True while ≥1 live subscriber is watching this query (refcount guard). */
488
519
  hasSubscribers(hash: string): boolean;
489
520
  /**
@@ -1172,6 +1203,7 @@ declare class Sp00kyClient<S extends SchemaStructure> {
1172
1203
  private devTools;
1173
1204
  private crdtManager;
1174
1205
  private featureFlags;
1206
+ private preloadedHashes;
1175
1207
  private logger;
1176
1208
  auth: AuthService<S>;
1177
1209
  streamProcessor: StreamProcessorService;
@@ -1253,6 +1285,30 @@ declare class Sp00kyClient<S extends SchemaStructure> {
1253
1285
  deauthenticate(): Promise<void>;
1254
1286
  query<Table extends TableNames<S>>(table: Table, options: QueryOptions<TableModel<GetTable<S, Table>>, false>, ttl?: QueryTimeToLive): QueryBuilder<S, Table, Sp00kyQueryResultPromise>;
1255
1287
  private initQuery;
1288
+ /**
1289
+ * Smart, awaitable preload/prewarm into the LOCAL cache — without registering a
1290
+ * live view (NO `_00_query`, NO subscription, NO TTL heartbeat).
1291
+ *
1292
+ * Cache-aware via a durable per-bucket freshness marker (`_00_preload`):
1293
+ * - COLD (never preloaded in this bucket): fetch the query one-shot from the
1294
+ * remote, persist the rows (+ embedded `.related()` children), stamp the
1295
+ * marker — and AWAIT it. This is the "smart waiting" first load: callers can
1296
+ * `await db.preload(...)` to hold the UI until the data is ready.
1297
+ * - WARM (marker present): return instantly — NEVER blocks. `refresh` decides
1298
+ * whether to also kick a one-time silent refetch (see {@link PreloadOptions}).
1299
+ * Default `onUse` does nothing; the data freshens when the real `useQuery`
1300
+ * mounts and registers its live view.
1301
+ *
1302
+ * Best-effort: any fetch failure (offline, etc.) is a no-op warn (no marker
1303
+ * written, so it's retried next load). Deduped per session by query hash.
1304
+ */
1305
+ preload(finalQuery: FinalQuery<S, any, any, any, any, any>, options?: PreloadOptions): Promise<void>;
1306
+ /**
1307
+ * One-shot remote fetch + local persist for a preload query. Returns the row
1308
+ * count on success, or -1 on failure (best-effort: logged, never thrown) so
1309
+ * the caller skips stamping the freshness marker and retries next load.
1310
+ */
1311
+ private fetchAndPersist;
1256
1312
  queryRaw(sql: string, params: Record<string, any>, ttl: QueryTimeToLive): Promise<string>;
1257
1313
  subscribe(queryHash: string, callback: (records: Record<string, any>[]) => void, options?: {
1258
1314
  immediate?: boolean;
@@ -1318,4 +1374,4 @@ declare function textToHtml(text: string): string;
1318
1374
  */
1319
1375
 
1320
1376
  //#endregion
1321
- export { AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagSnapshot, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, UpdateOptions, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };
1377
+ export { AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagSnapshot, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, UpdateOptions, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };
package/dist/index.js CHANGED
@@ -947,7 +947,7 @@ var LocalMigrator = class {
947
947
  return;
948
948
  }
949
949
  await this.recreateDatabase(database);
950
- const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n ";
950
+ const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_preload SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n ";
951
951
  const statements = this.splitStatements(fullSchema);
952
952
  for (let i = 0; i < statements.length; i++) {
953
953
  const statement = statements[i];
@@ -1616,7 +1616,7 @@ var SqliteCacheEngine = class {
1616
1616
  return () => {};
1617
1617
  }
1618
1618
  spawnWorker() {
1619
- const worker = new Worker(new URL("./sqlite-worker.js", import.meta.url), { type: "module" });
1619
+ const worker = new Worker(new URL("./sqlite-worker.ts", import.meta.url), { type: "module" });
1620
1620
  worker.onmessage = (ev) => {
1621
1621
  const { id, ok, error, ...rest } = ev.data ?? {};
1622
1622
  const p = this.pending.get(id);
@@ -2708,6 +2708,22 @@ var DataModule = class {
2708
2708
  queryState.hydrated = true;
2709
2709
  if (rows.length === 0) return;
2710
2710
  const tableName = queryState.config.tableName;
2711
+ await this.buildAndSaveCacheBatch(tableName, rows);
2712
+ queryState.config.remoteArray = rows.map((r) => [encodeRecordId(r.id), r._00_rv || 1]);
2713
+ queryState.records = await this.materializeRecords(queryState);
2714
+ const subscribers = this.subscriptions.get(hash);
2715
+ if (subscribers) for (const cb of subscribers) cb(queryState.records);
2716
+ }
2717
+ /**
2718
+ * Build the cache batch for a set of one-shot rows and persist it to the
2719
+ * local DB + in-browser SSP. Maps each row to a `CREATE` op on its own table
2720
+ * and extracts EMBEDDED related children (any nesting depth) as their own
2721
+ * records — a `.related()` query returns its children embedded, and a later
2722
+ * correlated re-materialization needs them present as standalone rows.
2723
+ * Shared by `applyHydration` (live registration) and `persistSnapshot`
2724
+ * (preload).
2725
+ */
2726
+ async buildAndSaveCacheBatch(tableName, rows) {
2711
2727
  const batch = rows.map((record) => ({
2712
2728
  table: tableName,
2713
2729
  op: "CREATE",
@@ -2717,10 +2733,43 @@ var DataModule = class {
2717
2733
  const seen = new Set(rows.map((r) => encodeRecordId(r.id)));
2718
2734
  for (const record of rows) this.collectEmbeddedChildren(record, batch, seen);
2719
2735
  await this.cache.saveBatch(batch);
2720
- queryState.config.remoteArray = rows.map((r) => [encodeRecordId(r.id), r._00_rv || 1]);
2721
- queryState.records = await this.materializeRecords(queryState);
2722
- const subscribers = this.subscriptions.get(hash);
2723
- if (subscribers) for (const cb of subscribers) cb(queryState.records);
2736
+ }
2737
+ /**
2738
+ * Preload/prewarm: persist one-shot rows (and their embedded related children)
2739
+ * into the local cache WITHOUT registering a query — no `activeQueries` entry,
2740
+ * no `_00_query` view, no TTL heartbeat. The rows live in the local DB as
2741
+ * ordinary bodies (never GC'd on their own) so a later `useQuery` seeds its
2742
+ * first paint from them instantly, then registers a live view to freshen.
2743
+ */
2744
+ async persistSnapshot(tableName, rows) {
2745
+ if (rows.length === 0) return;
2746
+ await this.buildAndSaveCacheBatch(tableName, rows);
2747
+ }
2748
+ /**
2749
+ * Read the durable preload freshness marker for a query hash, or null if this
2750
+ * query was never preloaded in the current bucket. Co-located with the cached
2751
+ * rows (per-bucket `_00_preload` table) so a bucket switch that clears the
2752
+ * data also clears the marker — a stale marker can't claim "warm" when the
2753
+ * rows are gone. Any read error is treated as cold.
2754
+ */
2755
+ async getPreloadMarker(hash) {
2756
+ try {
2757
+ const row = await this.local.getById("_00_preload", hash);
2758
+ if (!row) return null;
2759
+ return {
2760
+ fetchedAt: Number(row.fetchedAt) || 0,
2761
+ rowCount: Number(row.rowCount) || 0
2762
+ };
2763
+ } catch {
2764
+ return null;
2765
+ }
2766
+ }
2767
+ /** Stamp the preload freshness marker after a successful snapshot fetch. */
2768
+ async writePreloadMarker(hash, rowCount) {
2769
+ await this.local.upsert("_00_preload", hash, {
2770
+ fetchedAt: Date.now(),
2771
+ rowCount
2772
+ }, "replace");
2724
2773
  }
2725
2774
  /** True while ≥1 live subscriber is watching this query (refcount guard). */
2726
2775
  hasSubscribers(hash) {
@@ -5051,8 +5100,8 @@ function parseBackendInfo(raw) {
5051
5100
 
5052
5101
  //#endregion
5053
5102
  //#region src/modules/devtools/index.ts
5054
- const CORE_VERSION = "0.0.1-canary.113";
5055
- const WASM_VERSION = "0.0.1-canary.113";
5103
+ const CORE_VERSION = "0.0.1-canary.117";
5104
+ const WASM_VERSION = "0.0.1-canary.117";
5056
5105
  const SURREAL_VERSION = "3.0.3";
5057
5106
  var DevToolsService = class {
5058
5107
  eventsHistory = [];
@@ -5080,7 +5129,8 @@ var DevToolsService = class {
5080
5129
  } else if (type === "SP00KY_DEVTOOLS_DISCONNECT") this.enabled = false;
5081
5130
  });
5082
5131
  this.authService.eventSystem.subscribe(AuthEventTypes.AuthStateChanged, () => {
5083
- this.notifyDevTools();
5132
+ if (this.authService.isAuthenticated && this.backendInfo.versions.ssp === UNAVAILABLE) this.refreshBackendVersions();
5133
+ else this.notifyDevTools();
5084
5134
  });
5085
5135
  this.refreshBackendVersions();
5086
5136
  this.logger.debug({ Category: "sp00ky-client::DevToolsService::init" }, "Service initialized");
@@ -7009,6 +7059,7 @@ var Sp00kyClient = class {
7009
7059
  devTools;
7010
7060
  crdtManager;
7011
7061
  featureFlags;
7062
+ preloadedHashes = /* @__PURE__ */ new Set();
7012
7063
  logger;
7013
7064
  auth;
7014
7065
  streamProcessor;
@@ -7206,13 +7257,18 @@ var Sp00kyClient = class {
7206
7257
  ensureLocalBucket(userId) {
7207
7258
  const target = bucketIdForUser(userId);
7208
7259
  this.pendingBucketTarget = target;
7260
+ const release = this.local.currentBucketId !== target ? this.local.beginSwitch() : null;
7209
7261
  this.bucketSwitchChain = this.bucketSwitchChain.then(async () => {
7210
- if (this.pendingBucketTarget !== target) return;
7211
- if (this.local.currentBucketId === target) return;
7212
- await this.doSwitchBucket(target);
7262
+ if (this.pendingBucketTarget !== target || this.local.currentBucketId === target) {
7263
+ release?.();
7264
+ return;
7265
+ }
7266
+ await this.doSwitchBucket(target, release);
7213
7267
  });
7214
7268
  const result = this.bucketSwitchChain;
7215
- this.bucketSwitchChain = this.bucketSwitchChain.catch(() => {});
7269
+ this.bucketSwitchChain = this.bucketSwitchChain.catch(() => {
7270
+ release?.();
7271
+ });
7216
7272
  return result;
7217
7273
  }
7218
7274
  /**
@@ -7235,7 +7291,7 @@ var Sp00kyClient = class {
7235
7291
  * re-homed keeping their hashes, sync resumed on the new bucket's own
7236
7292
  * outbox, and every query re-registered remotely to refill from the server.
7237
7293
  */
7238
- async doSwitchBucket(target) {
7294
+ async doSwitchBucket(target, gateRelease) {
7239
7295
  this.logger.info({
7240
7296
  target,
7241
7297
  from: this.local.currentBucketId,
@@ -7244,7 +7300,7 @@ var Sp00kyClient = class {
7244
7300
  await this.sync.prepareBucketSwitch();
7245
7301
  this.dataModule.quiesce();
7246
7302
  this.crdtManager.closeAll({ flush: false });
7247
- const reopen = this.local.beginSwitch();
7303
+ const reopen = gateRelease ?? this.local.beginSwitch();
7248
7304
  try {
7249
7305
  await this.local.switchStore(target);
7250
7306
  if (this.local.usesSurqlSchema) await this.migrator.provision(this.config.schemaSurql);
@@ -7339,6 +7395,71 @@ var Sp00kyClient = class {
7339
7395
  });
7340
7396
  return hash;
7341
7397
  }
7398
+ /**
7399
+ * Smart, awaitable preload/prewarm into the LOCAL cache — without registering a
7400
+ * live view (NO `_00_query`, NO subscription, NO TTL heartbeat).
7401
+ *
7402
+ * Cache-aware via a durable per-bucket freshness marker (`_00_preload`):
7403
+ * - COLD (never preloaded in this bucket): fetch the query one-shot from the
7404
+ * remote, persist the rows (+ embedded `.related()` children), stamp the
7405
+ * marker — and AWAIT it. This is the "smart waiting" first load: callers can
7406
+ * `await db.preload(...)` to hold the UI until the data is ready.
7407
+ * - WARM (marker present): return instantly — NEVER blocks. `refresh` decides
7408
+ * whether to also kick a one-time silent refetch (see {@link PreloadOptions}).
7409
+ * Default `onUse` does nothing; the data freshens when the real `useQuery`
7410
+ * mounts and registers its live view.
7411
+ *
7412
+ * Best-effort: any fetch failure (offline, etc.) is a no-op warn (no marker
7413
+ * written, so it's retried next load). Deduped per session by query hash.
7414
+ */
7415
+ async preload(finalQuery, options) {
7416
+ const q = finalQuery.innerQuery;
7417
+ if (this.preloadedHashes.has(q.hash)) return;
7418
+ const tableName = q.tableName;
7419
+ const tableSchema = this.config.schema.tables.find((t) => t.name === tableName);
7420
+ if (!tableSchema) throw new Error(`Table ${tableName} not found`);
7421
+ const params = parseParams(tableSchema.columns, q.selectQuery.vars ?? {});
7422
+ const hashKey = String(q.hash);
7423
+ const marker = await this.dataModule.getPreloadMarker(hashKey);
7424
+ if (!marker) {
7425
+ const rowCount = await this.fetchAndPersist(q, tableName, params);
7426
+ if (rowCount >= 0) {
7427
+ await this.dataModule.writePreloadMarker(hashKey, rowCount);
7428
+ this.preloadedHashes.add(q.hash);
7429
+ }
7430
+ return;
7431
+ }
7432
+ this.preloadedHashes.add(q.hash);
7433
+ const refresh = options?.refresh ?? "onUse";
7434
+ if (refresh === "onUse") return;
7435
+ if (refresh === "stale") {
7436
+ const maxAgeMs = parseDuration(options?.staleTime ?? "1h");
7437
+ if (Date.now() - marker.fetchedAt <= maxAgeMs) return;
7438
+ }
7439
+ this.fetchAndPersist(q, tableName, params).then((rowCount) => {
7440
+ if (rowCount >= 0) return this.dataModule.writePreloadMarker(hashKey, rowCount);
7441
+ });
7442
+ }
7443
+ /**
7444
+ * One-shot remote fetch + local persist for a preload query. Returns the row
7445
+ * count on success, or -1 on failure (best-effort: logged, never thrown) so
7446
+ * the caller skips stamping the freshness marker and retries next load.
7447
+ */
7448
+ async fetchAndPersist(q, tableName, params) {
7449
+ try {
7450
+ const [rows] = await this.remote.query(q.selectQuery.query, params);
7451
+ const list = rows ?? [];
7452
+ await this.dataModule.persistSnapshot(tableName, list);
7453
+ return list.length;
7454
+ } catch (err) {
7455
+ this.logger.warn({
7456
+ err,
7457
+ hash: q.hash,
7458
+ Category: "sp00ky-client::Sp00kyClient::preload"
7459
+ }, "Preload fetch failed; data will be fetched on demand");
7460
+ return -1;
7461
+ }
7462
+ }
7342
7463
  async queryRaw(sql, params, ttl) {
7343
7464
  const tableName = sql.split("FROM ")[1].split(" ")[0];
7344
7465
  return this.dataModule.query(tableName, sql, params, ttl);
package/dist/types.d.ts CHANGED
@@ -360,6 +360,22 @@ interface PersistenceClient {
360
360
  * Format: number + unit (m=minutes, h=hours, d=days).
361
361
  */
362
362
  type QueryTimeToLive = '1m' | '5m' | '10m' | '15m' | '20m' | '25m' | '30m' | '1h' | '2h' | '3h' | '4h' | '5h' | '6h' | '7h' | '8h' | '9h' | '10h' | '11h' | '12h' | '1d';
363
+ /**
364
+ * Refresh behavior for `preload` when the data is already cached locally (warm).
365
+ * The FIRST load (cold) always fetches + blocks regardless.
366
+ * - `onUse` (default): do nothing when warm — the data freshens on use, when the
367
+ * real `useQuery` mounts and registers its live view. No network on load.
368
+ * - `background`: return instantly, but kick a one-time silent refetch.
369
+ * - `stale`: like `background`, but only if the cached copy is older than
370
+ * `staleTime`.
371
+ */
372
+ type PreloadRefresh = 'onUse' | 'background' | 'stale';
373
+ interface PreloadOptions {
374
+ /** How to refresh when the query is already cached locally. Default `onUse`. */
375
+ refresh?: PreloadRefresh;
376
+ /** For `refresh: 'stale'` — max age before a warm copy is refetched. Default `1h`. */
377
+ staleTime?: QueryTimeToLive;
378
+ }
363
379
  /**
364
380
  * Result object returned when a query is registered or executed.
365
381
  */
@@ -724,4 +740,4 @@ interface DebounceOptions {
724
740
  delay?: number;
725
741
  }
726
742
  //#endregion
727
- export { SyncHealthStatus as A, EventDefinition as B, RunOptions as C, StoreType as D, Sp00kyQueryResultPromise as E, SealedQuery as F, DatabaseEventSystem as I, DatabaseEventTypes as L, UpdateOptions as M, UpEvent as N, SyncHealth as O, LocalStore as P, Logger$1 as R, RegistrationTimings as S, Sp00kyQueryResult as T, EventSystem as V, QueryTimeToLive as _, MutationCallback as a, RecordVersionArray as b, PersistenceClient as c, QueryConfig as d, QueryConfigRecord as f, QueryStatusCallback as g, QueryStatus as h, MATERIALIZATION_SAMPLE_WINDOW as i, TimingPhase as j, SyncHealthConfig as k, PhaseStat as l, QueryState as m, EventSubscriptionOptions as n, MutationEvent as o, QueryHash as p, Level$1 as r, MutationEventType as s, DebounceOptions as t, PinoTransmit as u, QueryTimings as v, Sp00kyConfig as w, RecordVersionDiff as x, QueryUpdateCallback as y, SyncEventSystem as z };
743
+ export { SyncHealth as A, Logger$1 as B, RecordVersionDiff as C, Sp00kyQueryResult as D, Sp00kyConfig as E, UpEvent as F, EventDefinition as H, LocalStore as I, SealedQuery as L, SyncHealthStatus as M, TimingPhase as N, Sp00kyQueryResultPromise as O, UpdateOptions as P, DatabaseEventSystem as R, RecordVersionArray as S, RunOptions as T, EventSystem as U, SyncEventSystem as V, QueryStatus as _, MutationCallback as a, QueryTimings as b, PersistenceClient as c, PreloadOptions as d, PreloadRefresh as f, QueryState as g, QueryHash as h, MATERIALIZATION_SAMPLE_WINDOW as i, SyncHealthConfig as j, StoreType as k, PhaseStat as l, QueryConfigRecord as m, EventSubscriptionOptions as n, MutationEvent as o, QueryConfig as p, Level$1 as r, MutationEventType as s, DebounceOptions as t, PinoTransmit as u, QueryStatusCallback as v, RegistrationTimings as w, QueryUpdateCallback as x, QueryTimeToLive as y, DatabaseEventTypes as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.113",
3
+ "version": "0.0.1-canary.117",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -59,8 +59,8 @@
59
59
  }
60
60
  },
61
61
  "dependencies": {
62
- "@spooky-sync/query-builder": "0.0.1-canary.113",
63
- "@spooky-sync/ssp-wasm": "0.0.1-canary.113",
62
+ "@spooky-sync/query-builder": "0.0.1-canary.117",
63
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.117",
64
64
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
65
65
  "@surrealdb/wasm": "^3.0.3",
66
66
  "fast-json-patch": "^3.1.1",
@@ -763,6 +763,35 @@ export class DataModule<S extends SchemaStructure> {
763
763
  if (rows.length === 0) return;
764
764
 
765
765
  const tableName = queryState.config.tableName;
766
+ await this.buildAndSaveCacheBatch(tableName, rows);
767
+
768
+ // Prime remoteArray from the hydrated id+version pairs: `materializeRecords`
769
+ // prefers it for windowed queries (correct window) and it feeds the version
770
+ // dedup. Registration later overwrites it with the authoritative `_00_list_ref`.
771
+ queryState.config.remoteArray = rows.map(
772
+ (r) => [encodeRecordId(r.id), (r._00_rv as number) || 1] as [string, number]
773
+ );
774
+
775
+ queryState.records = await this.materializeRecords(queryState);
776
+ const subscribers = this.subscriptions.get(hash);
777
+ if (subscribers) {
778
+ for (const cb of subscribers) cb(queryState.records);
779
+ }
780
+ }
781
+
782
+ /**
783
+ * Build the cache batch for a set of one-shot rows and persist it to the
784
+ * local DB + in-browser SSP. Maps each row to a `CREATE` op on its own table
785
+ * and extracts EMBEDDED related children (any nesting depth) as their own
786
+ * records — a `.related()` query returns its children embedded, and a later
787
+ * correlated re-materialization needs them present as standalone rows.
788
+ * Shared by `applyHydration` (live registration) and `persistSnapshot`
789
+ * (preload).
790
+ */
791
+ private async buildAndSaveCacheBatch(
792
+ tableName: string,
793
+ rows: RecordWithId[]
794
+ ): Promise<void> {
766
795
  const batch: CacheRecord[] = rows.map((record) => ({
767
796
  table: tableName,
768
797
  op: 'CREATE' as const,
@@ -770,35 +799,58 @@ export class DataModule<S extends SchemaStructure> {
770
799
  version: (record._00_rv as number) || 1,
771
800
  }));
772
801
 
773
- // Flicker-free related data on cold first paint: a `.related()` query's
774
- // rows arrive with their child rows EMBEDDED (the correlated subquery,
775
- // e.g. `(SELECT * FROM comment WHERE game=$parent.id) AS comments`). The
776
- // primary batch above persists only the parent table, so the immediate
777
- // `materializeRecords` below would re-run the correlated surql against a
778
- // local DB with no child rows and overwrite the embedded result with
779
- // empties. Extract those embedded children (any nesting depth) and
780
- // persist them as their own records so the re-materialization finds them.
781
802
  const seen = new Set<string>(rows.map((r) => encodeRecordId(r.id)));
782
803
  for (const record of rows) {
783
804
  this.collectEmbeddedChildren(record, batch, seen);
784
805
  }
785
806
 
786
807
  await this.cache.saveBatch(batch);
808
+ }
787
809
 
788
- // Prime remoteArray from the hydrated id+version pairs: `materializeRecords`
789
- // prefers it for windowed queries (correct window) and it feeds the version
790
- // dedup. Registration later overwrites it with the authoritative `_00_list_ref`.
791
- queryState.config.remoteArray = rows.map(
792
- (r) => [encodeRecordId(r.id), (r._00_rv as number) || 1] as [string, number]
793
- );
810
+ /**
811
+ * Preload/prewarm: persist one-shot rows (and their embedded related children)
812
+ * into the local cache WITHOUT registering a query — no `activeQueries` entry,
813
+ * no `_00_query` view, no TTL heartbeat. The rows live in the local DB as
814
+ * ordinary bodies (never GC'd on their own) so a later `useQuery` seeds its
815
+ * first paint from them instantly, then registers a live view to freshen.
816
+ */
817
+ async persistSnapshot(tableName: string, rows: RecordWithId[]): Promise<void> {
818
+ if (rows.length === 0) return;
819
+ await this.buildAndSaveCacheBatch(tableName, rows);
820
+ }
794
821
 
795
- queryState.records = await this.materializeRecords(queryState);
796
- const subscribers = this.subscriptions.get(hash);
797
- if (subscribers) {
798
- for (const cb of subscribers) cb(queryState.records);
822
+ /**
823
+ * Read the durable preload freshness marker for a query hash, or null if this
824
+ * query was never preloaded in the current bucket. Co-located with the cached
825
+ * rows (per-bucket `_00_preload` table) so a bucket switch that clears the
826
+ * data also clears the marker — a stale marker can't claim "warm" when the
827
+ * rows are gone. Any read error is treated as cold.
828
+ */
829
+ async getPreloadMarker(
830
+ hash: string
831
+ ): Promise<{ fetchedAt: number; rowCount: number } | null> {
832
+ try {
833
+ const row = await this.local.getById('_00_preload', hash);
834
+ if (!row) return null;
835
+ return {
836
+ fetchedAt: Number((row as any).fetchedAt) || 0,
837
+ rowCount: Number((row as any).rowCount) || 0,
838
+ };
839
+ } catch {
840
+ return null;
799
841
  }
800
842
  }
801
843
 
844
+ /** Stamp the preload freshness marker after a successful snapshot fetch. */
845
+ async writePreloadMarker(hash: string, rowCount: number): Promise<void> {
846
+ await this.local.upsert(
847
+ '_00_preload',
848
+ hash,
849
+ { fetchedAt: Date.now(), rowCount },
850
+ 'replace'
851
+ );
852
+ }
853
+
802
854
  /** True while ≥1 live subscriber is watching this query (refcount guard). */
803
855
  hasSubscribers(hash: string): boolean {
804
856
  return (this.subscriptions.get(hash)?.size ?? 0) > 0;
@@ -20,6 +20,7 @@ import {
20
20
  type BackendInfo,
21
21
  emptyBackendInfo,
22
22
  parseBackendInfo,
23
+ UNAVAILABLE,
23
24
  } from './versions';
24
25
 
25
26
  // Real bundled frontend versions, injected at build time by tsdown's
@@ -82,9 +83,17 @@ export class DevToolsService implements StreamUpdateReceiver {
82
83
  });
83
84
  }
84
85
 
85
- // Subscribe to auth events
86
+ // Subscribe to auth events. The initial fire-and-forget version fetch (below)
87
+ // races the remote connection; on the free plan the remote DB (SurrealDB
88
+ // Cloud) has no guest access, so `fn::spooky::info()` is only callable once
89
+ // signed in. Re-fetch when auth resolves — until the versions actually land —
90
+ // instead of leaving them 'unavailable' forever.
86
91
  this.authService.eventSystem.subscribe(AuthEventTypes.AuthStateChanged, () => {
87
- this.notifyDevTools();
92
+ if (this.authService.isAuthenticated && this.backendInfo.versions.ssp === UNAVAILABLE) {
93
+ void this.refreshBackendVersions();
94
+ } else {
95
+ this.notifyDevTools();
96
+ }
88
97
  });
89
98
 
90
99
  // Fire-and-forget backend version discovery; re-push state when it lands.
@@ -45,6 +45,7 @@ export class LocalMigrator {
45
45
  const systemSchema = `
46
46
  DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
47
47
  DEFINE TABLE IF NOT EXISTS _00_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
48
+ DEFINE TABLE IF NOT EXISTS _00_preload SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
48
49
  DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
49
50
  DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
50
51
  `;
@@ -107,7 +107,7 @@ export class SqliteCacheEngine implements LocalStore {
107
107
 
108
108
  private spawnWorker(): Worker {
109
109
  // The worker is a separate module entry; the bundler rewrites this URL.
110
- const worker = new Worker(new URL('./sqlite-worker.js', import.meta.url), { type: 'module' });
110
+ const worker = new Worker(new URL('./sqlite-worker.ts', import.meta.url), { type: 'module' });
111
111
  worker.onmessage = (ev: MessageEvent) => {
112
112
  const { id, ok, error, ...rest } = ev.data ?? {};
113
113
  const p = this.pending.get(id);
package/src/sp00ky.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  QueryStatusCallback,
6
6
  Sp00kyQueryResultPromise,
7
7
  PersistenceClient,
8
+ PreloadOptions,
8
9
  UpdateOptions,
9
10
  RunOptions,
10
11
  SyncHealth} from './types';
@@ -17,6 +18,7 @@ import type { LocalStore } from './services/database/index';
17
18
  import type { UpEvent } from './modules/sync/index';
18
19
  import { Sp00kySync } from './modules/sync/index';
19
20
  import type {
21
+ FinalQuery,
20
22
  GetTable,
21
23
  InnerQuery,
22
24
  QueryOptions,
@@ -44,7 +46,7 @@ import { FeatureFlagModule, FeatureFlagHandle } from './modules/feature-flag/ind
44
46
  import type { FeatureFlagOptions } from './modules/feature-flag/index';
45
47
  import { LocalStoragePersistenceClient } from './services/persistence/localstorage';
46
48
  import { ANON_USER_ID, bucketIdForUser } from './modules/ref-tables';
47
- import { parseParams, encodeRecordId } from './utils/index';
49
+ import { parseParams, encodeRecordId, parseDuration } from './utils/index';
48
50
  import { SurrealDBPersistenceClient } from './services/persistence/surrealdb';
49
51
  import { ResilientPersistenceClient } from './services/persistence/resilient';
50
52
 
@@ -128,6 +130,10 @@ export class Sp00kyClient<S extends SchemaStructure> {
128
130
  private devTools: DevToolsService;
129
131
  private crdtManager: CrdtManager;
130
132
  private featureFlags!: FeatureFlagModule<S>;
133
+ // Query hashes already preloaded this session — skip redundant one-shot
134
+ // fetches when the same preload query is requested again (e.g. a list row
135
+ // re-rendering). Cleared on process/session end only.
136
+ private preloadedHashes = new Set<number>();
131
137
 
132
138
  private logger: ReturnType<typeof createLogger>;
133
139
  public auth: AuthService<S>;
@@ -510,15 +516,32 @@ export class Sp00kyClient<S extends SchemaStructure> {
510
516
  private ensureLocalBucket(userId: string | null): Promise<void> {
511
517
  const target = bucketIdForUser(userId);
512
518
  this.pendingBucketTarget = target;
519
+ // Close the query gate SYNCHRONOUSLY the instant a switch is pending — the
520
+ // AuthProvider's own auth subscriber fires right after this (same tick) and
521
+ // enables queries, and `doSwitchBucket` only runs a microtask later on the
522
+ // chain. Without closing the gate here, that query is issued through the
523
+ // still-open gate and is in-flight on the local wasm engine when
524
+ // `switchStore` closes the client — which wedges the engine (every
525
+ // subsequent query, including provisioning, hangs → no view ever registers).
526
+ // No-op when already on the target bucket.
527
+ const needsSwitch = this.local.currentBucketId !== target;
528
+ const release = needsSwitch ? this.local.beginSwitch() : null;
513
529
  this.bucketSwitchChain = this.bucketSwitchChain.then(async () => {
514
- if (this.pendingBucketTarget !== target) return; // superseded by a newer flip
515
- if (this.local.currentBucketId === target) return;
516
- await this.doSwitchBucket(target);
530
+ // Superseded by a newer flip, or already on target: reopen the gate we
531
+ // closed above and skip the switch.
532
+ if (this.pendingBucketTarget !== target || this.local.currentBucketId === target) {
533
+ release?.();
534
+ return;
535
+ }
536
+ await this.doSwitchBucket(target, release);
517
537
  });
518
538
  // Isolate chain failures per-caller: a failed switch must not poison every
519
- // future switch. The caller (auth listener) logs it.
539
+ // future switch. The caller (auth listener) logs it. Reopen the gate on
540
+ // failure so the client never gets stuck closed.
520
541
  const result = this.bucketSwitchChain;
521
- this.bucketSwitchChain = this.bucketSwitchChain.catch(() => {});
542
+ this.bucketSwitchChain = this.bucketSwitchChain.catch(() => {
543
+ release?.();
544
+ });
522
545
  return result;
523
546
  }
524
547
 
@@ -542,7 +565,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
542
565
  * re-homed keeping their hashes, sync resumed on the new bucket's own
543
566
  * outbox, and every query re-registered remotely to refill from the server.
544
567
  */
545
- private async doSwitchBucket(target: string): Promise<void> {
568
+ private async doSwitchBucket(target: string, gateRelease?: (() => void) | null): Promise<void> {
546
569
  this.logger.info(
547
570
  { target, from: this.local.currentBucketId, Category: 'sp00ky-client::Sp00kyClient::doSwitchBucket' },
548
571
  'Switching local bucket'
@@ -552,7 +575,10 @@ export class Sp00kyClient<S extends SchemaStructure> {
552
575
  this.dataModule.quiesce();
553
576
  this.crdtManager.closeAll({ flush: false });
554
577
 
555
- const reopen = this.local.beginSwitch();
578
+ // Reuse the gate the caller (`ensureLocalBucket`) closed synchronously; only
579
+ // open our own if called without one (keeps the gate continuously closed
580
+ // from the auth flip through the swap — no window for a racing query).
581
+ const reopen = gateRelease ?? this.local.beginSwitch();
556
582
  try {
557
583
  await this.local.switchStore(target);
558
584
  if (this.local.usesSurqlSchema) {
@@ -701,6 +727,90 @@ export class Sp00kyClient<S extends SchemaStructure> {
701
727
  return hash;
702
728
  }
703
729
 
730
+ /**
731
+ * Smart, awaitable preload/prewarm into the LOCAL cache — without registering a
732
+ * live view (NO `_00_query`, NO subscription, NO TTL heartbeat).
733
+ *
734
+ * Cache-aware via a durable per-bucket freshness marker (`_00_preload`):
735
+ * - COLD (never preloaded in this bucket): fetch the query one-shot from the
736
+ * remote, persist the rows (+ embedded `.related()` children), stamp the
737
+ * marker — and AWAIT it. This is the "smart waiting" first load: callers can
738
+ * `await db.preload(...)` to hold the UI until the data is ready.
739
+ * - WARM (marker present): return instantly — NEVER blocks. `refresh` decides
740
+ * whether to also kick a one-time silent refetch (see {@link PreloadOptions}).
741
+ * Default `onUse` does nothing; the data freshens when the real `useQuery`
742
+ * mounts and registers its live view.
743
+ *
744
+ * Best-effort: any fetch failure (offline, etc.) is a no-op warn (no marker
745
+ * written, so it's retried next load). Deduped per session by query hash.
746
+ */
747
+ async preload(
748
+ finalQuery: FinalQuery<S, any, any, any, any, any>,
749
+ options?: PreloadOptions
750
+ ): Promise<void> {
751
+ const q = finalQuery.innerQuery;
752
+ if (this.preloadedHashes.has(q.hash)) return;
753
+
754
+ const tableName = q.tableName;
755
+ const tableSchema = this.config.schema.tables.find((t) => t.name === tableName);
756
+ if (!tableSchema) {
757
+ throw new Error(`Table ${tableName} not found`);
758
+ }
759
+ const params = parseParams(tableSchema.columns, q.selectQuery.vars ?? {});
760
+ const hashKey = String(q.hash);
761
+
762
+ const marker = await this.dataModule.getPreloadMarker(hashKey);
763
+
764
+ // COLD → fetch + persist + stamp, awaited so the caller can block on it.
765
+ if (!marker) {
766
+ const rowCount = await this.fetchAndPersist(q, tableName, params);
767
+ if (rowCount >= 0) {
768
+ await this.dataModule.writePreloadMarker(hashKey, rowCount);
769
+ this.preloadedHashes.add(q.hash);
770
+ }
771
+ return;
772
+ }
773
+
774
+ // WARM → never block. Mark handled for this session, then optionally refresh.
775
+ this.preloadedHashes.add(q.hash);
776
+ const refresh = options?.refresh ?? 'onUse';
777
+ if (refresh === 'onUse') return;
778
+
779
+ if (refresh === 'stale') {
780
+ const maxAgeMs = parseDuration(options?.staleTime ?? '1h');
781
+ if (Date.now() - marker.fetchedAt <= maxAgeMs) return; // still fresh
782
+ }
783
+
784
+ // `background`, or `stale` past its staleTime → one-time silent refetch.
785
+ void this.fetchAndPersist(q, tableName, params).then((rowCount) => {
786
+ if (rowCount >= 0) return this.dataModule.writePreloadMarker(hashKey, rowCount);
787
+ });
788
+ }
789
+
790
+ /**
791
+ * One-shot remote fetch + local persist for a preload query. Returns the row
792
+ * count on success, or -1 on failure (best-effort: logged, never thrown) so
793
+ * the caller skips stamping the freshness marker and retries next load.
794
+ */
795
+ private async fetchAndPersist(
796
+ q: InnerQuery<any, any, any>,
797
+ tableName: string,
798
+ params: Record<string, any>
799
+ ): Promise<number> {
800
+ try {
801
+ const [rows] = await this.remote.query<[RecordWithId[]]>(q.selectQuery.query, params);
802
+ const list = rows ?? [];
803
+ await this.dataModule.persistSnapshot(tableName, list);
804
+ return list.length;
805
+ } catch (err) {
806
+ this.logger.warn(
807
+ { err, hash: q.hash, Category: 'sp00ky-client::Sp00kyClient::preload' },
808
+ 'Preload fetch failed; data will be fetched on demand'
809
+ );
810
+ return -1;
811
+ }
812
+ }
813
+
704
814
  async queryRaw(sql: string, params: Record<string, any>, ttl: QueryTimeToLive) {
705
815
  const tableName = sql.split('FROM ')[1].split(' ')[0];
706
816
  return this.dataModule.query(tableName, sql, params, ttl);
package/src/types.ts CHANGED
@@ -68,6 +68,24 @@ export type QueryTimeToLive =
68
68
  | '12h'
69
69
  | '1d';
70
70
 
71
+ /**
72
+ * Refresh behavior for `preload` when the data is already cached locally (warm).
73
+ * The FIRST load (cold) always fetches + blocks regardless.
74
+ * - `onUse` (default): do nothing when warm — the data freshens on use, when the
75
+ * real `useQuery` mounts and registers its live view. No network on load.
76
+ * - `background`: return instantly, but kick a one-time silent refetch.
77
+ * - `stale`: like `background`, but only if the cached copy is older than
78
+ * `staleTime`.
79
+ */
80
+ export type PreloadRefresh = 'onUse' | 'background' | 'stale';
81
+
82
+ export interface PreloadOptions {
83
+ /** How to refresh when the query is already cached locally. Default `onUse`. */
84
+ refresh?: PreloadRefresh;
85
+ /** For `refresh: 'stale'` — max age before a warm copy is refetched. Default `1h`. */
86
+ staleTime?: QueryTimeToLive;
87
+ }
88
+
71
89
  /**
72
90
  * Result object returned when a query is registered or executed.
73
91
  */