@spooky-sync/core 0.0.1-canary.129 → 0.0.1-canary.130

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
@@ -522,6 +522,13 @@ declare class DataModule<S extends SchemaStructure> {
522
522
  fetchedAt: number;
523
523
  rowCount: number;
524
524
  } | null>;
525
+ /**
526
+ * True when this query's preload marker exists and is younger than
527
+ * `maxAgeMs`. Used by instant-hydrate to skip its one-shot fetch for rows a
528
+ * recent `preload()` already persisted (the register lifecycle re-syncs them
529
+ * authoritatively). Missing or unreadable markers are treated as stale.
530
+ */
531
+ isPreloadFresh(hashKey: string, maxAgeMs: number): Promise<boolean>;
525
532
  /** Stamp the preload freshness marker after a successful snapshot fetch. */
526
533
  writePreloadMarker(hash: string, rowCount: number): Promise<void>;
527
534
  /** True while ≥1 live subscriber is watching this query (refcount guard). */
@@ -1213,6 +1220,7 @@ declare class Sp00kyClient<S extends SchemaStructure> {
1213
1220
  private crdtManager;
1214
1221
  private featureFlags;
1215
1222
  private preloadedHashes;
1223
+ private pendingQueryInits;
1216
1224
  private logger;
1217
1225
  auth: AuthService<S>;
1218
1226
  streamProcessor: StreamProcessorService;
@@ -1294,6 +1302,22 @@ declare class Sp00kyClient<S extends SchemaStructure> {
1294
1302
  deauthenticate(): Promise<void>;
1295
1303
  query<Table extends TableNames<S>>(table: Table, options: QueryOptions<TableModel<GetTable<S, Table>>, false>, ttl?: QueryTimeToLive): QueryBuilder<S, Table, Sp00kyQueryResultPromise>;
1296
1304
  private initQuery;
1305
+ /**
1306
+ * Background tail of {@link initQuery}: instant-hydrate (when the query is
1307
+ * cold AND wasn't freshly preloaded) followed by enqueuing the `register`
1308
+ * down-event. Never rejects — both halves catch and log, so `void`-ing the
1309
+ * returned promise can't produce an unhandled rejection.
1310
+ *
1311
+ * The fresh-preload skip: rows a recent `preload()` persisted are already in
1312
+ * the local cache and were part of the first paint; the register lifecycle
1313
+ * re-syncs them authoritatively, so the one-shot hydrate fetch would be a
1314
+ * pure duplicate round trip. "Fresh" = preloaded this session
1315
+ * (`preloadedHashes`) or a `_00_preload` marker younger than
1316
+ * HYDRATE_PRELOAD_FRESH. Both are per-bucket (the Set is cleared on bucket
1317
+ * switch, the marker table lives in the bucket), so neither can claim
1318
+ * freshness across auth contexts.
1319
+ */
1320
+ private finishQueryInit;
1297
1321
  /**
1298
1322
  * Smart, awaitable preload/prewarm into the LOCAL cache — without registering a
1299
1323
  * live view (NO `_00_query`, NO subscription, NO TTL heartbeat).
package/dist/index.js CHANGED
@@ -2724,8 +2724,10 @@ var DataModule = class {
2724
2724
  if (!queryState) return;
2725
2725
  queryState.hydrated = true;
2726
2726
  if (rows.length === 0) return;
2727
+ const epoch = this.local.epoch;
2727
2728
  const tableName = queryState.config.tableName;
2728
2729
  await this.buildAndSaveCacheBatch(tableName, rows);
2730
+ if (epoch !== this.local.epoch) return;
2729
2731
  queryState.config.remoteArray = rows.map((r) => [encodeRecordId(r.id), r._00_rv || 1]);
2730
2732
  queryState.records = await this.materializeRecords(queryState);
2731
2733
  const subscribers = this.subscriptions.get(hash);
@@ -2782,6 +2784,16 @@ var DataModule = class {
2782
2784
  return null;
2783
2785
  }
2784
2786
  }
2787
+ /**
2788
+ * True when this query's preload marker exists and is younger than
2789
+ * `maxAgeMs`. Used by instant-hydrate to skip its one-shot fetch for rows a
2790
+ * recent `preload()` already persisted (the register lifecycle re-syncs them
2791
+ * authoritatively). Missing or unreadable markers are treated as stale.
2792
+ */
2793
+ async isPreloadFresh(hashKey, maxAgeMs) {
2794
+ const marker = await this.getPreloadMarker(hashKey);
2795
+ return marker !== null && Date.now() - marker.fetchedAt <= maxAgeMs;
2796
+ }
2785
2797
  /** Stamp the preload freshness marker after a successful snapshot fetch. */
2786
2798
  async writePreloadMarker(hash, rowCount) {
2787
2799
  await this.local.upsert("_00_preload", hash, {
@@ -5118,8 +5130,8 @@ function parseBackendInfo(raw) {
5118
5130
 
5119
5131
  //#endregion
5120
5132
  //#region src/modules/devtools/index.ts
5121
- const CORE_VERSION = "0.0.1-canary.129";
5122
- const WASM_VERSION = "0.0.1-canary.129";
5133
+ const CORE_VERSION = "0.0.1-canary.130";
5134
+ const WASM_VERSION = "0.0.1-canary.130";
5123
5135
  const SURREAL_VERSION = "3.0.3";
5124
5136
  var DevToolsService = class {
5125
5137
  eventsHistory = [];
@@ -7054,6 +7066,7 @@ var BucketHandle = class {
7054
7066
  * callback switches to the user's bucket (cache + outbox intact).
7055
7067
  */
7056
7068
  const LAST_BUCKET_KEY = "sp00ky:last_bucket";
7069
+ const HYDRATE_PRELOAD_FRESH = "1h";
7057
7070
  function readBootBucketHint() {
7058
7071
  try {
7059
7072
  return typeof localStorage !== "undefined" ? localStorage.getItem(LAST_BUCKET_KEY) : null;
@@ -7078,6 +7091,7 @@ var Sp00kyClient = class {
7078
7091
  crdtManager;
7079
7092
  featureFlags;
7080
7093
  preloadedHashes = /* @__PURE__ */ new Set();
7094
+ pendingQueryInits = /* @__PURE__ */ new Map();
7081
7095
  logger;
7082
7096
  auth;
7083
7097
  streamProcessor;
@@ -7398,21 +7412,62 @@ var Sp00kyClient = class {
7398
7412
  if (!tableSchema) throw new Error(`Table ${table} not found`);
7399
7413
  const params = parseParams(tableSchema.columns, q.selectQuery.vars ?? {});
7400
7414
  const hash = await this.dataModule.query(table, q.selectQuery.query, params, ttl, q.selectQuery.plan);
7415
+ if (!this.pendingQueryInits.has(hash)) {
7416
+ const chain = this.finishQueryInit(hash, q, params).finally(() => {
7417
+ this.pendingQueryInits.delete(hash);
7418
+ });
7419
+ this.pendingQueryInits.set(hash, chain);
7420
+ }
7421
+ return hash;
7422
+ }
7423
+ /**
7424
+ * Background tail of {@link initQuery}: instant-hydrate (when the query is
7425
+ * cold AND wasn't freshly preloaded) followed by enqueuing the `register`
7426
+ * down-event. Never rejects — both halves catch and log, so `void`-ing the
7427
+ * returned promise can't produce an unhandled rejection.
7428
+ *
7429
+ * The fresh-preload skip: rows a recent `preload()` persisted are already in
7430
+ * the local cache and were part of the first paint; the register lifecycle
7431
+ * re-syncs them authoritatively, so the one-shot hydrate fetch would be a
7432
+ * pure duplicate round trip. "Fresh" = preloaded this session
7433
+ * (`preloadedHashes`) or a `_00_preload` marker younger than
7434
+ * HYDRATE_PRELOAD_FRESH. Both are per-bucket (the Set is cleared on bucket
7435
+ * switch, the marker table lives in the bucket), so neither can claim
7436
+ * freshness across auth contexts.
7437
+ */
7438
+ async finishQueryInit(hash, q, params) {
7401
7439
  if (this.config.instantHydrate !== false && this.dataModule.isCold(hash)) try {
7402
- const [rows] = await this.remote.query(q.selectQuery.query, params);
7403
- await this.dataModule.applyHydration(hash, rows ?? []);
7440
+ if (!(this.preloadedHashes.has(q.hash) || await this.dataModule.isPreloadFresh(String(q.hash), parseDuration(HYDRATE_PRELOAD_FRESH)))) {
7441
+ const epoch = this.local.epoch;
7442
+ const [rows] = await this.remote.query(q.selectQuery.query, params);
7443
+ if (epoch === this.local.epoch) await this.dataModule.applyHydration(hash, rows ?? []);
7444
+ } else this.logger.debug({
7445
+ hash,
7446
+ Category: "sp00ky-client::Sp00kyClient::instantHydrate"
7447
+ }, "Skipping instant hydrate; preload marker fresh");
7404
7448
  } catch (err) {
7405
- this.logger.warn({
7449
+ if (err instanceof StaleEpochError) this.logger.debug({
7450
+ hash,
7451
+ Category: "sp00ky-client::Sp00kyClient::instantHydrate"
7452
+ }, "Dropped instant hydrate from before a bucket switch");
7453
+ else this.logger.warn({
7406
7454
  err,
7407
7455
  hash,
7408
7456
  Category: "sp00ky-client::Sp00kyClient::instantHydrate"
7409
7457
  }, "Instant hydrate failed; proceeding with registration");
7410
7458
  }
7411
- await this.sync.enqueueDownEvent({
7412
- type: "register",
7413
- payload: { hash }
7414
- });
7415
- return hash;
7459
+ try {
7460
+ await this.sync.enqueueDownEvent({
7461
+ type: "register",
7462
+ payload: { hash }
7463
+ });
7464
+ } catch (err) {
7465
+ this.logger.error({
7466
+ err,
7467
+ hash,
7468
+ Category: "sp00ky-client::Sp00kyClient::initQuery"
7469
+ }, "Failed to enqueue register down-event");
7470
+ }
7416
7471
  }
7417
7472
  /**
7418
7473
  * Smart, awaitable preload/prewarm into the LOCAL cache — without registering a
package/dist/types.d.ts CHANGED
@@ -450,13 +450,16 @@ interface Sp00kyConfig<S extends SchemaStructure> {
450
450
  */
451
451
  refSyncIntervalMs?: number;
452
452
  /**
453
- * Instant-hydrate cold queries: when a query is registered with no local
454
- * data yet, first run its surql directly on the remote (one-shot) and display
455
- * the result immediately, THEN do the full realtime registration in the
456
- * background. The hydrated rows are ingested with their versions so the
457
- * registration's `syncRecords` skips re-pulling unchanged bodies. Cuts cold
458
- * first-paint from ~one full registration round-trip to ~one query.
459
- * Defaults to `true`; set `false` to keep the old wait-for-registration path.
453
+ * Instant-hydrate cold queries: when a query is registered with no server
454
+ * result yet, run its surql directly on the remote (one-shot) and display
455
+ * the result as soon as it lands, while the full realtime registration
456
+ * proceeds in the background. The hydrated rows are ingested with their
457
+ * versions so the registration's `syncRecords` skips re-pulling unchanged
458
+ * bodies. The fetch runs OFF the paint path — `useQuery` resolves and paints
459
+ * from the local cache immediately, and hydrate only fills in what's
460
+ * missing. Skipped entirely when the query was `preload()`ed recently (its
461
+ * rows are already local and fresh; the registration re-syncs them), so a
462
+ * preloaded screen costs zero duplicate fetches. Defaults to `true`.
460
463
  */
461
464
  instantHydrate?: boolean;
462
465
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.129",
3
+ "version": "0.0.1-canary.130",
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.129",
63
- "@spooky-sync/ssp-wasm": "0.0.1-canary.129",
62
+ "@spooky-sync/query-builder": "0.0.1-canary.130",
63
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.130",
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",
@@ -0,0 +1,150 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { DataModule } from './index';
4
+ import type { QueryState } from '../../types';
5
+
6
+ /**
7
+ * Tests for instant-hydrate's DataModule half: `applyHydration` (run-once,
8
+ * remoteArray priming, subscriber notify, and the epoch guard added when the
9
+ * hydrate fetch moved off the paint path into a background chain) and
10
+ * `isPreloadFresh` (the marker check that lets a freshly-preloaded query skip
11
+ * the duplicate one-shot fetch entirely).
12
+ */
13
+
14
+ function makeLogger(): any {
15
+ const noop = () => {};
16
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
17
+ logger.child = () => logger;
18
+ return logger;
19
+ }
20
+
21
+ function makeQueryState(hash: string): QueryState {
22
+ return {
23
+ config: {
24
+ id: new RecordId('_00_query', hash),
25
+ surql: 'SELECT * FROM user',
26
+ params: {},
27
+ localArray: [],
28
+ remoteArray: [],
29
+ ttl: '10m',
30
+ lastActiveAt: new Date(),
31
+ tableName: 'user',
32
+ },
33
+ records: [],
34
+ ttlTimer: null,
35
+ ttlDurationMs: 0,
36
+ updateCount: 0,
37
+ lastUpdatedAt: null,
38
+ materializationSamples: [],
39
+ lastIngestLatencyMs: null,
40
+ errorCount: 0,
41
+ status: 'fetching',
42
+ phaseSamples: {},
43
+ phaseLast: {},
44
+ registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
45
+ };
46
+ }
47
+
48
+ const schema = { tables: [{ name: 'user', columns: {} }] } as any;
49
+
50
+ function makeRow(id: string, rv = 1) {
51
+ return { id: new RecordId('user', id), name: id, _00_rv: rv };
52
+ }
53
+
54
+ describe('DataModule.applyHydration', () => {
55
+ const hash = 'h1';
56
+
57
+ function setup({ epochFlipsOnSave = false } = {}) {
58
+ let epoch = 1;
59
+ const saved: any[] = [];
60
+ const cache: any = {
61
+ saveBatch: async (batch: any[]) => {
62
+ saved.push(...batch);
63
+ if (epochFlipsOnSave) epoch = 2;
64
+ },
65
+ };
66
+ const local: any = {
67
+ get epoch() {
68
+ return epoch;
69
+ },
70
+ query: async () => [[makeRow('a')]],
71
+ };
72
+ const dm = new DataModule(cache, local, schema, makeLogger(), 100);
73
+ const state = makeQueryState(hash);
74
+ (dm as any).activeQueries.set(hash, state);
75
+ return { dm, state, saved };
76
+ }
77
+
78
+ it('primes remoteArray, materializes and notifies subscribers', async () => {
79
+ const { dm, state, saved } = setup();
80
+ const emissions: any[] = [];
81
+ dm.subscribe(hash, (records) => emissions.push(records));
82
+
83
+ await dm.applyHydration(hash, [makeRow('a', 3)]);
84
+
85
+ expect(state.hydrated).toBe(true);
86
+ expect(state.config.remoteArray).toEqual([['user:a', 3]]);
87
+ expect(saved.length).toBe(1);
88
+ expect(emissions.length).toBe(1);
89
+ expect(dm.isCold(hash)).toBe(false);
90
+ });
91
+
92
+ it('runs once even when the remote returns nothing (hydrated flag)', async () => {
93
+ const { dm, state, saved } = setup();
94
+ await dm.applyHydration(hash, []);
95
+ expect(state.hydrated).toBe(true);
96
+ expect(saved).toEqual([]);
97
+ expect(dm.isCold(hash)).toBe(false); // hydrated → no longer cold
98
+ });
99
+
100
+ it('is a no-op for an unknown query', async () => {
101
+ const { dm, saved } = setup();
102
+ await dm.applyHydration('nope', [makeRow('a')]);
103
+ expect(saved).toEqual([]);
104
+ });
105
+
106
+ it('bails before mutating state when the bucket epoch moves mid-persist', async () => {
107
+ const { dm, state } = setup({ epochFlipsOnSave: true });
108
+ const emissions: any[] = [];
109
+ dm.subscribe(hash, (records) => emissions.push(records));
110
+
111
+ await dm.applyHydration(hash, [makeRow('a', 3)]);
112
+
113
+ // Rows fetched under the previous auth context must not prime the new
114
+ // bucket's query state — the rebind's re-registration refills it.
115
+ expect(state.config.remoteArray).toEqual([]);
116
+ expect(state.records).toEqual([]);
117
+ expect(emissions).toEqual([]);
118
+ });
119
+ });
120
+
121
+ describe('DataModule.isPreloadFresh', () => {
122
+ const maxAgeMs = 60_000;
123
+
124
+ function makeDm(getById: (table: string, id: string) => Promise<any>) {
125
+ const local: any = { getById };
126
+ return new DataModule({} as any, local, schema, makeLogger(), 100);
127
+ }
128
+
129
+ it('true for a marker younger than maxAgeMs', async () => {
130
+ const dm = makeDm(async () => ({ fetchedAt: Date.now() - 1_000, rowCount: 5 }));
131
+ expect(await dm.isPreloadFresh('123', maxAgeMs)).toBe(true);
132
+ });
133
+
134
+ it('false for a marker older than maxAgeMs', async () => {
135
+ const dm = makeDm(async () => ({ fetchedAt: Date.now() - maxAgeMs - 1, rowCount: 5 }));
136
+ expect(await dm.isPreloadFresh('123', maxAgeMs)).toBe(false);
137
+ });
138
+
139
+ it('false when no marker exists', async () => {
140
+ const dm = makeDm(async () => null);
141
+ expect(await dm.isPreloadFresh('123', maxAgeMs)).toBe(false);
142
+ });
143
+
144
+ it('false when the marker read throws (treated as cold)', async () => {
145
+ const dm = makeDm(async () => {
146
+ throw new Error('boom');
147
+ });
148
+ expect(await dm.isPreloadFresh('123', maxAgeMs)).toBe(false);
149
+ });
150
+ });
@@ -794,8 +794,14 @@ export class DataModule<S extends SchemaStructure> {
794
794
  queryState.hydrated = true; // run-once, even when the remote returns nothing
795
795
  if (rows.length === 0) return;
796
796
 
797
+ const epoch = this.local.epoch;
797
798
  const tableName = queryState.config.tableName;
798
799
  await this.buildAndSaveCacheBatch(tableName, rows);
800
+ // Bucket switched while we persisted: these rows were fetched under the
801
+ // previous auth context — don't let them prime the new bucket's query
802
+ // state; the rebind's re-registration refills it. (saveBatch's own epoch
803
+ // fence usually catches this, but the switch can land between it and here.)
804
+ if (epoch !== this.local.epoch) return;
799
805
 
800
806
  // Prime remoteArray from the hydrated id+version pairs: `materializeRecords`
801
807
  // prefers it for windowed queries (correct window) and it feeds the version
@@ -881,6 +887,17 @@ export class DataModule<S extends SchemaStructure> {
881
887
  }
882
888
  }
883
889
 
890
+ /**
891
+ * True when this query's preload marker exists and is younger than
892
+ * `maxAgeMs`. Used by instant-hydrate to skip its one-shot fetch for rows a
893
+ * recent `preload()` already persisted (the register lifecycle re-syncs them
894
+ * authoritatively). Missing or unreadable markers are treated as stale.
895
+ */
896
+ async isPreloadFresh(hashKey: string, maxAgeMs: number): Promise<boolean> {
897
+ const marker = await this.getPreloadMarker(hashKey);
898
+ return marker !== null && Date.now() - marker.fetchedAt <= maxAgeMs;
899
+ }
900
+
884
901
  /** Stamp the preload freshness marker after a successful snapshot fetch. */
885
902
  async writePreloadMarker(hash: string, rowCount: number): Promise<void> {
886
903
  await this.local.upsert(
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { Sp00kyClient } from './sp00ky';
5
+
6
+ // Guards for the local-first paint contract of `Sp00kyClient.initQuery`:
7
+ // the hash must resolve (and the hook paint from local cache) without any
8
+ // network on the awaited path, while instant-hydrate + the `register`
9
+ // down-event run in a background chain that (a) keeps hydrate strictly
10
+ // before the enqueue, (b) skips the duplicate one-shot fetch for freshly
11
+ // preloaded queries, (c) never rejects, and (d) is shared by concurrent
12
+ // mounts of the same query.
13
+ //
14
+ // Structural half: like sp00ky.auth-order.test.ts, a regex over the source
15
+ // catches the ordering regressions a runtime mock can't cheaply cover
16
+ // (Sp00kyClient's constructor drags in SurrealDB + the WASM SSP).
17
+ // Behavioral half: `finishQueryInit`/`initQuery` only touch a handful of
18
+ // injected fields, so a bare `Object.create(Sp00kyClient.prototype)` with
19
+ // stubs exercises the real method bodies without the constructor.
20
+
21
+ describe('Sp00kyClient.initQuery structural invariants', () => {
22
+ const source = readFileSync(resolve(__dirname, 'sp00ky.ts'), 'utf-8');
23
+
24
+ const methodBody = (name: string): string => {
25
+ const match = source.match(new RegExp(`private async ${name}[\\s\\S]*?\\n \\}`));
26
+ expect(match, `expected a "private async ${name}" method in sp00ky.ts`).not.toBeNull();
27
+ // Strip line comments so prose mentioning awaits/calls can't trip the checks.
28
+ return match![0]
29
+ .split('\n')
30
+ .map((line) => line.replace(/\/\/.*$/, ''))
31
+ .join('\n');
32
+ };
33
+
34
+ it('initQuery returns the hash with no network on the awaited path', () => {
35
+ const body = methodBody('initQuery');
36
+ expect(body).toContain('return hash');
37
+ expect(body, 'initQuery must not await the remote (paint path is network-free)').not.toMatch(
38
+ /await this\.remote\./
39
+ );
40
+ expect(body, 'the register enqueue belongs to the background chain').not.toMatch(
41
+ /await this\.sync\.enqueueDownEvent/
42
+ );
43
+ });
44
+
45
+ it('finishQueryInit hydrates strictly before enqueuing register', () => {
46
+ const body = methodBody('finishQueryInit');
47
+ const fetchIdx = body.indexOf('this.remote.query');
48
+ const enqueueIdx = body.indexOf('this.sync.enqueueDownEvent');
49
+ expect(fetchIdx).toBeGreaterThanOrEqual(0);
50
+ expect(enqueueIdx).toBeGreaterThanOrEqual(0);
51
+ expect(
52
+ fetchIdx,
53
+ 'hydrate must settle before the register enqueue so a stale one-shot snapshot can never land after the authoritative _00_list_ref overwrite'
54
+ ).toBeLessThan(enqueueIdx);
55
+ });
56
+
57
+ it('finishQueryInit captures the bucket epoch before the remote fetch', () => {
58
+ const body = methodBody('finishQueryInit');
59
+ const epochIdx = body.indexOf('this.local.epoch');
60
+ const fetchIdx = body.indexOf('this.remote.query');
61
+ expect(epochIdx).toBeGreaterThanOrEqual(0);
62
+ expect(epochIdx, 'epoch must be read before the fetch to fence bucket switches').toBeLessThan(
63
+ fetchIdx
64
+ );
65
+ });
66
+ });
67
+
68
+ describe('Sp00kyClient.finishQueryInit behavior', () => {
69
+ const hash = 'sha-hash';
70
+ const q: any = { hash: 42, selectQuery: { query: 'SELECT * FROM user', vars: {} } };
71
+
72
+ let client: any;
73
+ let calls: string[];
74
+ let enqueued: any[];
75
+ let epoch: number;
76
+ let preloadFresh: boolean;
77
+ let cold: boolean;
78
+ let remoteImpl: () => Promise<any>;
79
+
80
+ beforeEach(() => {
81
+ calls = [];
82
+ enqueued = [];
83
+ epoch = 1;
84
+ preloadFresh = false;
85
+ cold = true;
86
+ remoteImpl = async () => {
87
+ calls.push('fetch');
88
+ return [[{ id: 'user:a' }]];
89
+ };
90
+
91
+ const noop = () => {};
92
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
93
+ logger.child = () => logger;
94
+
95
+ client = Object.create(Sp00kyClient.prototype);
96
+ Object.assign(client, {
97
+ config: { instantHydrate: true, schema: { tables: [{ name: 'user', columns: {} }] } },
98
+ logger,
99
+ preloadedHashes: new Set<number>(),
100
+ pendingQueryInits: new Map<string, Promise<void>>(),
101
+ local: {
102
+ get epoch() {
103
+ return epoch;
104
+ },
105
+ },
106
+ remote: { query: (...args: any[]) => remoteImpl() },
107
+ sync: {
108
+ enqueueDownEvent: (event: any) => {
109
+ calls.push('enqueue');
110
+ enqueued.push(event);
111
+ },
112
+ },
113
+ dataModule: {
114
+ isCold: () => cold,
115
+ isPreloadFresh: async () => preloadFresh,
116
+ applyHydration: async () => {
117
+ calls.push('hydrate');
118
+ },
119
+ query: async () => hash,
120
+ },
121
+ });
122
+ });
123
+
124
+ it('cold path: fetch → hydrate → enqueue, in order', async () => {
125
+ await client.finishQueryInit(hash, q, {});
126
+ expect(calls).toEqual(['fetch', 'hydrate', 'enqueue']);
127
+ expect(enqueued).toEqual([{ type: 'register', payload: { hash } }]);
128
+ });
129
+
130
+ it('session-preloaded query skips the fetch but still enqueues register', async () => {
131
+ client.preloadedHashes.add(q.hash);
132
+ await client.finishQueryInit(hash, q, {});
133
+ expect(calls).toEqual(['enqueue']);
134
+ });
135
+
136
+ it('fresh preload marker skips the fetch but still enqueues register', async () => {
137
+ preloadFresh = true;
138
+ await client.finishQueryInit(hash, q, {});
139
+ expect(calls).toEqual(['enqueue']);
140
+ });
141
+
142
+ it('warm query (not cold) goes straight to enqueue', async () => {
143
+ cold = false;
144
+ await client.finishQueryInit(hash, q, {});
145
+ expect(calls).toEqual(['enqueue']);
146
+ });
147
+
148
+ it('a rejected hydrate fetch still enqueues register and never rejects', async () => {
149
+ remoteImpl = async () => {
150
+ calls.push('fetch');
151
+ throw new Error('offline');
152
+ };
153
+ await expect(client.finishQueryInit(hash, q, {})).resolves.toBeUndefined();
154
+ expect(calls).toEqual(['fetch', 'enqueue']);
155
+ });
156
+
157
+ it('a bucket switch during the fetch skips applyHydration', async () => {
158
+ remoteImpl = async () => {
159
+ calls.push('fetch');
160
+ epoch = 2; // switch lands while the fetch is in flight
161
+ return [[{ id: 'user:a' }]];
162
+ };
163
+ await client.finishQueryInit(hash, q, {});
164
+ expect(calls).toEqual(['fetch', 'enqueue']);
165
+ });
166
+
167
+ it('instantHydrate: false disables the hydrate fetch entirely', async () => {
168
+ client.config.instantHydrate = false;
169
+ await client.finishQueryInit(hash, q, {});
170
+ expect(calls).toEqual(['enqueue']);
171
+ });
172
+
173
+ it('concurrent initQuery calls for the same hash share one background chain', async () => {
174
+ const [h1, h2] = await Promise.all([
175
+ client.initQuery('user', q, '10m'),
176
+ client.initQuery('user', q, '10m'),
177
+ ]);
178
+ expect(h1).toBe(hash);
179
+ expect(h2).toBe(hash);
180
+ // Let the shared chain drain.
181
+ await Promise.all([...client.pendingQueryInits.values()]);
182
+ expect(calls).toEqual(['fetch', 'hydrate', 'enqueue']);
183
+ expect(client.pendingQueryInits.size).toBe(0);
184
+ });
185
+ });
package/src/sp00ky.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  createLocalEngine,
16
16
  } from './services/database/index';
17
17
  import type { LocalStore } from './services/database/index';
18
+ import { StaleEpochError } from './services/database/index';
18
19
  import type { UpEvent } from './modules/sync/index';
19
20
  import { Sp00kySync } from './modules/sync/index';
20
21
  import type {
@@ -102,6 +103,12 @@ export class BucketHandle {
102
103
  */
103
104
  const LAST_BUCKET_KEY = 'sp00ky:last_bucket';
104
105
 
106
+ // Max age of a `_00_preload` marker for which instant-hydrate is skipped —
107
+ // a query preloaded this recently already painted from local rows and the
108
+ // register lifecycle re-syncs it, so the one-shot hydrate fetch would be a
109
+ // duplicate round trip. Matches `preload()`'s default `staleTime`.
110
+ const HYDRATE_PRELOAD_FRESH: QueryTimeToLive = '1h';
111
+
105
112
  function readBootBucketHint(): string | null {
106
113
  try {
107
114
  return typeof localStorage !== 'undefined' ? localStorage.getItem(LAST_BUCKET_KEY) : null;
@@ -134,6 +141,12 @@ export class Sp00kyClient<S extends SchemaStructure> {
134
141
  // fetches when the same preload query is requested again (e.g. a list row
135
142
  // re-rendering). Cleared on process/session end only.
136
143
  private preloadedHashes = new Set<number>();
144
+ // In-flight background init chains (instant-hydrate + register enqueue) keyed
145
+ // by registration hash. Concurrent mounts of the same query reuse the one
146
+ // chain instead of double-hydrating and double-enqueuing `register`.
147
+ // Sequential re-mounts intentionally start a fresh chain — the unconditional
148
+ // `register` re-enqueue is what freshens a warm preload on use.
149
+ private pendingQueryInits = new Map<string, Promise<void>>();
137
150
 
138
151
  private logger: ReturnType<typeof createLogger>;
139
152
  public auth: AuthService<S>;
@@ -705,31 +718,96 @@ export class Sp00kyClient<S extends SchemaStructure> {
705
718
  q.selectQuery.plan
706
719
  );
707
720
 
708
- // Instant-hydrate: for a cold query, fetch its rows one-shot from the remote
709
- // (its own surql, run directly like useRemote) and display them NOW; the full
710
- // realtime registration proceeds below. Hydrated rows carry their `_00_rv`
711
- // versions so the registration's syncRecords skips re-pulling unchanged bodies.
712
- // Best-effort: any failure (e.g. offline) just falls through to registration.
721
+ // Local-first paint: the hash is returned as soon as the LOCAL registration
722
+ // above completes `queryState.records` is already seeded from the local
723
+ // cache/SSP snapshot, so `useQuery` subscribes and paints from memory with
724
+ // zero network on the paint path. Instant-hydrate and the `register`
725
+ // down-event continue in a background chain (hydrate strictly before
726
+ // enqueue, so a stale one-shot snapshot can never land after the sync's
727
+ // authoritative `_00_list_ref` overwrite). Concurrent mounts of the same
728
+ // query share one chain; a sequential re-mount starts a fresh one so its
729
+ // `register` re-enqueue keeps freshening warm data on use.
730
+ if (!this.pendingQueryInits.has(hash)) {
731
+ const chain = this.finishQueryInit(hash, q, params).finally(() => {
732
+ this.pendingQueryInits.delete(hash);
733
+ });
734
+ this.pendingQueryInits.set(hash, chain);
735
+ }
736
+
737
+ return hash;
738
+ }
739
+
740
+ /**
741
+ * Background tail of {@link initQuery}: instant-hydrate (when the query is
742
+ * cold AND wasn't freshly preloaded) followed by enqueuing the `register`
743
+ * down-event. Never rejects — both halves catch and log, so `void`-ing the
744
+ * returned promise can't produce an unhandled rejection.
745
+ *
746
+ * The fresh-preload skip: rows a recent `preload()` persisted are already in
747
+ * the local cache and were part of the first paint; the register lifecycle
748
+ * re-syncs them authoritatively, so the one-shot hydrate fetch would be a
749
+ * pure duplicate round trip. "Fresh" = preloaded this session
750
+ * (`preloadedHashes`) or a `_00_preload` marker younger than
751
+ * HYDRATE_PRELOAD_FRESH. Both are per-bucket (the Set is cleared on bucket
752
+ * switch, the marker table lives in the bucket), so neither can claim
753
+ * freshness across auth contexts.
754
+ */
755
+ private async finishQueryInit(
756
+ hash: string,
757
+ q: InnerQuery<any, any, any>,
758
+ params: Record<string, any>
759
+ ): Promise<void> {
713
760
  if (this.config.instantHydrate !== false && this.dataModule.isCold(hash)) {
714
761
  try {
715
- const [rows] = await this.remote.query<[RecordWithId[]]>(q.selectQuery.query, params);
716
- await this.dataModule.applyHydration(hash, rows ?? []);
762
+ const preloadFresh =
763
+ this.preloadedHashes.has(q.hash) ||
764
+ (await this.dataModule.isPreloadFresh(
765
+ String(q.hash),
766
+ parseDuration(HYDRATE_PRELOAD_FRESH)
767
+ ));
768
+ if (!preloadFresh) {
769
+ // Fence against bucket switches: rows fetched under the previous
770
+ // auth context must not hydrate the new bucket's query state — the
771
+ // rebind's re-registration refills it from the right context.
772
+ const epoch = this.local.epoch;
773
+ const [rows] = await this.remote.query<[RecordWithId[]]>(q.selectQuery.query, params);
774
+ if (epoch === this.local.epoch) {
775
+ await this.dataModule.applyHydration(hash, rows ?? []);
776
+ }
777
+ } else {
778
+ this.logger.debug(
779
+ { hash, Category: 'sp00ky-client::Sp00kyClient::instantHydrate' },
780
+ 'Skipping instant hydrate; preload marker fresh'
781
+ );
782
+ }
717
783
  } catch (err) {
718
- this.logger.warn(
719
- { err, hash, Category: 'sp00ky-client::Sp00kyClient::instantHydrate' },
720
- 'Instant hydrate failed; proceeding with registration'
721
- );
784
+ if (err instanceof StaleEpochError) {
785
+ this.logger.debug(
786
+ { hash, Category: 'sp00ky-client::Sp00kyClient::instantHydrate' },
787
+ 'Dropped instant hydrate from before a bucket switch'
788
+ );
789
+ } else {
790
+ this.logger.warn(
791
+ { err, hash, Category: 'sp00ky-client::Sp00kyClient::instantHydrate' },
792
+ 'Instant hydrate failed; proceeding with registration'
793
+ );
794
+ }
722
795
  }
723
796
  }
724
797
 
725
- await this.sync.enqueueDownEvent({
726
- type: 'register',
727
- payload: {
728
- hash,
729
- },
730
- });
731
-
732
- return hash;
798
+ try {
799
+ await this.sync.enqueueDownEvent({
800
+ type: 'register',
801
+ payload: {
802
+ hash,
803
+ },
804
+ });
805
+ } catch (err) {
806
+ this.logger.error(
807
+ { err, hash, Category: 'sp00ky-client::Sp00kyClient::initQuery' },
808
+ 'Failed to enqueue register down-event'
809
+ );
810
+ }
733
811
  }
734
812
 
735
813
  /**
package/src/types.ts CHANGED
@@ -163,13 +163,16 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
163
163
  */
164
164
  refSyncIntervalMs?: number;
165
165
  /**
166
- * Instant-hydrate cold queries: when a query is registered with no local
167
- * data yet, first run its surql directly on the remote (one-shot) and display
168
- * the result immediately, THEN do the full realtime registration in the
169
- * background. The hydrated rows are ingested with their versions so the
170
- * registration's `syncRecords` skips re-pulling unchanged bodies. Cuts cold
171
- * first-paint from ~one full registration round-trip to ~one query.
172
- * Defaults to `true`; set `false` to keep the old wait-for-registration path.
166
+ * Instant-hydrate cold queries: when a query is registered with no server
167
+ * result yet, run its surql directly on the remote (one-shot) and display
168
+ * the result as soon as it lands, while the full realtime registration
169
+ * proceeds in the background. The hydrated rows are ingested with their
170
+ * versions so the registration's `syncRecords` skips re-pulling unchanged
171
+ * bodies. The fetch runs OFF the paint path — `useQuery` resolves and paints
172
+ * from the local cache immediately, and hydrate only fills in what's
173
+ * missing. Skipped entirely when the query was `preload()`ed recently (its
174
+ * rows are already local and fresh; the registration re-syncs them), so a
175
+ * preloaded screen costs zero duplicate fetches. Defaults to `true`.
173
176
  */
174
177
  instantHydrate?: boolean;
175
178
  /**