@spooky-sync/core 0.0.1-canary.131 → 0.0.1-canary.133

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,13 +522,6 @@ 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>;
532
525
  /** Stamp the preload freshness marker after a successful snapshot fetch. */
533
526
  writePreloadMarker(hash: string, rowCount: number): Promise<void>;
534
527
  /** True while ≥1 live subscriber is watching this query (refcount guard). */
@@ -1303,19 +1296,14 @@ declare class Sp00kyClient<S extends SchemaStructure> {
1303
1296
  query<Table extends TableNames<S>>(table: Table, options: QueryOptions<TableModel<GetTable<S, Table>>, false>, ttl?: QueryTimeToLive): QueryBuilder<S, Table, Sp00kyQueryResultPromise>;
1304
1297
  private initQuery;
1305
1298
  /**
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.
1299
+ * Background tail of {@link initQuery}: instant-hydrate (opt-in via
1300
+ * `config.instantHydrate`, and only when the query is cold) followed by
1301
+ * enqueuing the `register` down-event. Never rejects — both halves catch and
1302
+ * log, so `void`-ing the returned promise can't produce an unhandled
1303
+ * rejection. By default (hydrate off) the register lifecycle is the single
1304
+ * freshness path; the one-shot fetch is an optimization apps enable
1305
+ * explicitly, and it runs regardless of preload state cache-first delivery
1306
+ * never depends on WHY rows are cached.
1319
1307
  */
1320
1308
  private finishQueryInit;
1321
1309
  /**
package/dist/index.js CHANGED
@@ -1693,9 +1693,14 @@ var SqliteCacheEngine = class {
1693
1693
  });
1694
1694
  });
1695
1695
  }
1696
- async connect(bucketId) {
1696
+ /**
1697
+ * Spawn the worker and open `bucketId`'s DB. Uses {@link rawCall} (NOT
1698
+ * {@link call}) so it can run as the body of an already-queued opQueue entry
1699
+ * without re-queuing onto itself. Callers must run it through the opQueue.
1700
+ */
1701
+ async openInternal(bucketId) {
1697
1702
  this.worker = this.spawnWorker();
1698
- const { persisted } = await this.call("open", {
1703
+ const { persisted } = await this.rawCall("open", {
1699
1704
  dbName: bucketId,
1700
1705
  useOpfs: this.useOpfs
1701
1706
  });
@@ -1707,16 +1712,28 @@ var SqliteCacheEngine = class {
1707
1712
  Category: "sp00ky-client::SqliteCacheEngine::connect"
1708
1713
  }, persisted ? "SQLite OPFS store opened" : "SQLite in-memory store opened (no OPFS)");
1709
1714
  }
1715
+ /** Enqueue `fn` as a single serialized opQueue entry (mirrors {@link call}'s
1716
+ * chaining) so it can't interleave with reads/writes at the worker. */
1717
+ enqueue(fn) {
1718
+ const result = this.opQueue.then(fn, fn);
1719
+ this.opQueue = result.then(() => void 0, () => void 0);
1720
+ return result;
1721
+ }
1722
+ async connect(bucketId) {
1723
+ await this.enqueue(() => this.openInternal(bucketId));
1724
+ }
1710
1725
  async switchBucket(bucketId) {
1711
1726
  this.storeEpoch++;
1712
- if (this.worker) {
1713
- try {
1714
- await this.call("close");
1715
- } catch {}
1716
- this.worker.terminate();
1717
- this.worker = null;
1718
- }
1719
- await this.connect(bucketId);
1727
+ await this.enqueue(async () => {
1728
+ if (this.worker) {
1729
+ try {
1730
+ await this.rawCall("close");
1731
+ } catch {}
1732
+ this.worker.terminate();
1733
+ this.worker = null;
1734
+ }
1735
+ await this.openInternal(bucketId);
1736
+ });
1720
1737
  }
1721
1738
  async close() {
1722
1739
  if (!this.worker) return;
@@ -2795,16 +2812,6 @@ var DataModule = class {
2795
2812
  return null;
2796
2813
  }
2797
2814
  }
2798
- /**
2799
- * True when this query's preload marker exists and is younger than
2800
- * `maxAgeMs`. Used by instant-hydrate to skip its one-shot fetch for rows a
2801
- * recent `preload()` already persisted (the register lifecycle re-syncs them
2802
- * authoritatively). Missing or unreadable markers are treated as stale.
2803
- */
2804
- async isPreloadFresh(hashKey, maxAgeMs) {
2805
- const marker = await this.getPreloadMarker(hashKey);
2806
- return marker !== null && Date.now() - marker.fetchedAt <= maxAgeMs;
2807
- }
2808
2815
  /** Stamp the preload freshness marker after a successful snapshot fetch. */
2809
2816
  async writePreloadMarker(hash, rowCount) {
2810
2817
  await this.local.upsert("_00_preload", new RecordId("_00_preload", hash), {
@@ -5141,8 +5148,8 @@ function parseBackendInfo(raw) {
5141
5148
 
5142
5149
  //#endregion
5143
5150
  //#region src/modules/devtools/index.ts
5144
- const CORE_VERSION = "0.0.1-canary.131";
5145
- const WASM_VERSION = "0.0.1-canary.131";
5151
+ const CORE_VERSION = "0.0.1-canary.133";
5152
+ const WASM_VERSION = "0.0.1-canary.133";
5146
5153
  const SURREAL_VERSION = "3.0.3";
5147
5154
  var DevToolsService = class {
5148
5155
  eventsHistory = [];
@@ -7077,7 +7084,6 @@ var BucketHandle = class {
7077
7084
  * callback switches to the user's bucket (cache + outbox intact).
7078
7085
  */
7079
7086
  const LAST_BUCKET_KEY = "sp00ky:last_bucket";
7080
- const HYDRATE_PRELOAD_FRESH = "1h";
7081
7087
  function readBootBucketHint() {
7082
7088
  try {
7083
7089
  return typeof localStorage !== "undefined" ? localStorage.getItem(LAST_BUCKET_KEY) : null;
@@ -7432,30 +7438,20 @@ var Sp00kyClient = class {
7432
7438
  return hash;
7433
7439
  }
7434
7440
  /**
7435
- * Background tail of {@link initQuery}: instant-hydrate (when the query is
7436
- * cold AND wasn't freshly preloaded) followed by enqueuing the `register`
7437
- * down-event. Never rejects — both halves catch and log, so `void`-ing the
7438
- * returned promise can't produce an unhandled rejection.
7439
- *
7440
- * The fresh-preload skip: rows a recent `preload()` persisted are already in
7441
- * the local cache and were part of the first paint; the register lifecycle
7442
- * re-syncs them authoritatively, so the one-shot hydrate fetch would be a
7443
- * pure duplicate round trip. "Fresh" = preloaded this session
7444
- * (`preloadedHashes`) or a `_00_preload` marker younger than
7445
- * HYDRATE_PRELOAD_FRESH. Both are per-bucket (the Set is cleared on bucket
7446
- * switch, the marker table lives in the bucket), so neither can claim
7447
- * freshness across auth contexts.
7441
+ * Background tail of {@link initQuery}: instant-hydrate (opt-in via
7442
+ * `config.instantHydrate`, and only when the query is cold) followed by
7443
+ * enqueuing the `register` down-event. Never rejects — both halves catch and
7444
+ * log, so `void`-ing the returned promise can't produce an unhandled
7445
+ * rejection. By default (hydrate off) the register lifecycle is the single
7446
+ * freshness path; the one-shot fetch is an optimization apps enable
7447
+ * explicitly, and it runs regardless of preload state cache-first delivery
7448
+ * never depends on WHY rows are cached.
7448
7449
  */
7449
7450
  async finishQueryInit(hash, q, params) {
7450
- if (this.config.instantHydrate !== false && this.dataModule.isCold(hash)) try {
7451
- if (!(this.preloadedHashes.has(q.hash) || await this.dataModule.isPreloadFresh(String(q.hash), parseDuration(HYDRATE_PRELOAD_FRESH)))) {
7452
- const epoch = this.local.epoch;
7453
- const [rows] = await this.remote.query(q.selectQuery.query, params);
7454
- if (epoch === this.local.epoch) await this.dataModule.applyHydration(hash, rows ?? []);
7455
- } else this.logger.debug({
7456
- hash,
7457
- Category: "sp00ky-client::Sp00kyClient::instantHydrate"
7458
- }, "Skipping instant hydrate; preload marker fresh");
7451
+ if (this.config.instantHydrate === true && this.dataModule.isCold(hash)) try {
7452
+ const epoch = this.local.epoch;
7453
+ const [rows] = await this.remote.query(q.selectQuery.query, params);
7454
+ if (epoch === this.local.epoch) await this.dataModule.applyHydration(hash, rows ?? []);
7459
7455
  } catch (err) {
7460
7456
  if (err instanceof StaleEpochError) this.logger.debug({
7461
7457
  hash,
package/dist/types.d.ts CHANGED
@@ -450,16 +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 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`.
453
+ * OPT-IN instant-hydrate for cold queries: when enabled and a query is
454
+ * registered with no server result yet, its surql also runs directly on the
455
+ * remote (one-shot, in the background, OFF the paint path) so rows can land
456
+ * before the full register lifecycle completes. Hydrated rows carry their
457
+ * `_00_rv` versions so the registration's `syncRecords` skips re-pulling
458
+ * unchanged bodies. Regardless of this flag, `useQuery` always resolves and
459
+ * paints from the local cache immediately however the rows got there
460
+ * (preload, prior sync). Default `false`: the register lifecycle
461
+ * (`fn::query::register` `_00_list_ref` record sync) is the single
462
+ * freshness path and no duplicate one-shot fetches are made.
463
463
  */
464
464
  instantHydrate?: boolean;
465
465
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.131",
3
+ "version": "0.0.1-canary.133",
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.131",
63
- "@spooky-sync/ssp-wasm": "0.0.1-canary.131",
62
+ "@spooky-sync/query-builder": "0.0.1-canary.133",
63
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.133",
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",
@@ -4,11 +4,9 @@ import { DataModule } from './index';
4
4
  import type { QueryState } from '../../types';
5
5
 
6
6
  /**
7
- * Tests for instant-hydrate's DataModule half: `applyHydration` (run-once,
7
+ * Tests for instant-hydrate's DataModule half: `applyHydration` run-once,
8
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).
9
+ * hydrate fetch moved off the paint path into a background chain.
12
10
  */
13
11
 
14
12
  function makeLogger(): any {
@@ -118,33 +116,27 @@ describe('DataModule.applyHydration', () => {
118
116
  });
119
117
  });
120
118
 
121
- describe('DataModule.isPreloadFresh', () => {
122
- const maxAgeMs = 60_000;
123
-
124
- function makeDm(getById: (table: string, id: string) => Promise<any>) {
119
+ describe('DataModule.getPreloadMarker', () => {
120
+ function makeDm(getById: (table: string, id: unknown) => Promise<any>) {
125
121
  const local: any = { getById };
126
122
  return new DataModule({} as any, local, schema, makeLogger(), 100);
127
123
  }
128
124
 
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);
125
+ it('returns the marker fields for a real row', async () => {
126
+ const dm = makeDm(async () => ({ fetchedAt: 123, rowCount: 5 }));
127
+ expect(await dm.getPreloadMarker('h')).toEqual({ fetchedAt: 123, rowCount: 5 });
137
128
  });
138
129
 
139
- it('false when no marker exists', async () => {
140
- const dm = makeDm(async () => null);
141
- expect(await dm.isPreloadFresh('123', maxAgeMs)).toBe(false);
130
+ it('returns null on a miss and on a non-object echo (SurrealDB string quirk)', async () => {
131
+ expect(await makeDm(async () => null).getPreloadMarker('h')).toBeNull();
132
+ // `FROM ONLY <string>` used to echo the id string back — must not read as warm.
133
+ expect(await makeDm(async () => 'h' as any).getPreloadMarker('h')).toBeNull();
142
134
  });
143
135
 
144
- it('false when the marker read throws (treated as cold)', async () => {
136
+ it('returns null when the read throws', async () => {
145
137
  const dm = makeDm(async () => {
146
138
  throw new Error('boom');
147
139
  });
148
- expect(await dm.isPreloadFresh('123', maxAgeMs)).toBe(false);
140
+ expect(await dm.getPreloadMarker('h')).toBeNull();
149
141
  });
150
142
  });
@@ -890,17 +890,6 @@ export class DataModule<S extends SchemaStructure> {
890
890
  }
891
891
  }
892
892
 
893
- /**
894
- * True when this query's preload marker exists and is younger than
895
- * `maxAgeMs`. Used by instant-hydrate to skip its one-shot fetch for rows a
896
- * recent `preload()` already persisted (the register lifecycle re-syncs them
897
- * authoritatively). Missing or unreadable markers are treated as stale.
898
- */
899
- async isPreloadFresh(hashKey: string, maxAgeMs: number): Promise<boolean> {
900
- const marker = await this.getPreloadMarker(hashKey);
901
- return marker !== null && Date.now() - marker.fetchedAt <= maxAgeMs;
902
- }
903
-
904
893
  /** Stamp the preload freshness marker after a successful snapshot fetch. */
905
894
  async writePreloadMarker(hash: string, rowCount: number): Promise<void> {
906
895
  // RecordId, not a bare string: the SurrealDB engine binds the id verbatim,
@@ -1,10 +1,98 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { RecordId } from 'surrealdb';
3
- import { pureWriteOpResult } from './sqlite-cache-engine';
3
+ import { pureWriteOpResult, SqliteCacheEngine } from './sqlite-cache-engine';
4
4
  import { translateSurql } from './surql-translate';
5
5
  import type { SqlOp } from './surql-translate';
6
6
  import { surql } from '../../utils/surql';
7
7
 
8
+ function makeLogger(): any {
9
+ const noop = () => {};
10
+ const l: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
11
+ l.child = () => l;
12
+ return l;
13
+ }
14
+
15
+ /**
16
+ * A fake Worker that models the real SQLite worker's open/closed lifecycle:
17
+ * `open` opens the DB, `close` closes it, and `exec`/`run` REJECT with
18
+ * "sqlite: DB not open" when the DB isn't currently open — exactly the failure
19
+ * the fix targets. Records every message `type` (across worker generations) so
20
+ * tests can also inspect dispatch order.
21
+ */
22
+ class FakeWorker {
23
+ onmessage: ((ev: any) => void) | null = null;
24
+ onerror: any = null;
25
+ onmessageerror: any = null;
26
+ private dbOpen = false;
27
+ constructor(private log: string[]) {}
28
+ postMessage(msg: any) {
29
+ this.log.push(msg.type);
30
+ Promise.resolve().then(() => {
31
+ let ok = true;
32
+ let error: string | undefined;
33
+ let rest: Record<string, unknown> = {};
34
+ if (msg.type === 'open') {
35
+ this.dbOpen = true;
36
+ rest = { persisted: true };
37
+ } else if (msg.type === 'close') {
38
+ this.dbOpen = false;
39
+ } else if (msg.type === 'exec') {
40
+ if (!this.dbOpen) { ok = false; error = 'sqlite: DB not open'; }
41
+ else rest = { rows: [] };
42
+ } else if (msg.type === 'run' || msg.type === 'batch') {
43
+ if (!this.dbOpen) { ok = false; error = 'sqlite: DB not open'; }
44
+ }
45
+ this.onmessage?.({ data: { id: msg.id, ok, error, ...rest } });
46
+ });
47
+ }
48
+ terminate() {}
49
+ }
50
+
51
+ // Regression: switchBucket must run close → reopen as a single serialized
52
+ // opQueue entry. Otherwise a read/write enqueued during an auth/bucket change
53
+ // dispatches to the just-closed worker → "sqlite: DB not open" (the crash on
54
+ // sign-in). The invariant: no `exec` may appear between a `close` and the next
55
+ // `open` in the message stream.
56
+ describe('SqliteCacheEngine.switchBucket serialization', () => {
57
+ it('never dispatches an op to a closed DB during a bucket switch', async () => {
58
+ const log: string[] = [];
59
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
60
+ // Replicate the real spawnWorker's onmessage wiring (resolve/reject pending).
61
+ (engine as any).spawnWorker = () => {
62
+ const w = new FakeWorker(log);
63
+ w.onmessage = (ev: any) => {
64
+ const { id, ok, error, ...rest } = ev.data ?? {};
65
+ const p = (engine as any).pending.get(id);
66
+ if (!p) return;
67
+ (engine as any).pending.delete(id);
68
+ if (ok) p.resolve(rest);
69
+ else p.reject(new Error(error));
70
+ };
71
+ return w as unknown as Worker;
72
+ };
73
+
74
+ await engine.connect('anon');
75
+ expect(log).toContain('open');
76
+
77
+ // Fire a switch and a concurrent read (exactly what the sign-in query
78
+ // re-registration does). With the old non-atomic switch the read's `exec`
79
+ // dispatched to the closed/terminated worker and REJECTED ("sqlite: DB not
80
+ // open" / "not connected") — the uncaught crash. The atomic switch makes the
81
+ // read wait for the reopen and resolve against the new bucket.
82
+ const switching = engine.switchBucket('user:abc');
83
+ const reading = engine.getById('_00_query', 'h1');
84
+ await expect(Promise.all([switching, reading])).resolves.toBeDefined();
85
+ await expect(reading).resolves.toBeNull(); // missing row → null, not a throw
86
+
87
+ // And the close→reopen ran with no op wedged between them.
88
+ const closeIdx = log.indexOf('close');
89
+ const openAfter = log.indexOf('open', closeIdx + 1);
90
+ expect(openAfter).toBeGreaterThan(closeIdx);
91
+ expect(log.slice(closeIdx + 1, openAfter)).not.toContain('exec');
92
+ expect(engine.currentBucketId).toBe('user:abc');
93
+ });
94
+ });
95
+
8
96
  // `pureWriteOpResult` is the single source of truth for what a pure-write op
9
97
  // contributes to a query's per-statement results. The batched fast path in
10
98
  // `query()` and the per-op `execOp` path BOTH route through it, so a caller that
@@ -185,9 +185,14 @@ export class SqliteCacheEngine implements LocalStore {
185
185
  });
186
186
  }
187
187
 
188
- async connect(bucketId: string): Promise<void> {
188
+ /**
189
+ * Spawn the worker and open `bucketId`'s DB. Uses {@link rawCall} (NOT
190
+ * {@link call}) so it can run as the body of an already-queued opQueue entry
191
+ * without re-queuing onto itself. Callers must run it through the opQueue.
192
+ */
193
+ private async openInternal(bucketId: string): Promise<void> {
189
194
  this.worker = this.spawnWorker();
190
- const { persisted } = await this.call<{ persisted: boolean }>('open', {
195
+ const { persisted } = await this.rawCall<{ persisted: boolean }>('open', {
191
196
  dbName: bucketId,
192
197
  useOpfs: this.useOpfs,
193
198
  });
@@ -199,18 +204,43 @@ export class SqliteCacheEngine implements LocalStore {
199
204
  );
200
205
  }
201
206
 
207
+ /** Enqueue `fn` as a single serialized opQueue entry (mirrors {@link call}'s
208
+ * chaining) so it can't interleave with reads/writes at the worker. */
209
+ private enqueue<T>(fn: () => Promise<T>): Promise<T> {
210
+ const result = this.opQueue.then(fn, fn);
211
+ this.opQueue = result.then(
212
+ () => undefined,
213
+ () => undefined
214
+ );
215
+ return result;
216
+ }
217
+
218
+ async connect(bucketId: string): Promise<void> {
219
+ // Serialize through the opQueue so an op racing boot can't dispatch to a
220
+ // half-open worker.
221
+ await this.enqueue(() => this.openInternal(bucketId));
222
+ }
223
+
202
224
  async switchBucket(bucketId: string): Promise<void> {
203
225
  this.storeEpoch++;
204
- if (this.worker) {
205
- try {
206
- await this.call('close');
207
- } catch {
208
- /* ignore */
226
+ // Run close → terminate → reopen as ONE opQueue entry. Previously `close`
227
+ // and the new `open` were separate entries, so a query's `exec` (e.g. the
228
+ // query re-registration fired by an auth/bucket change) could slot in
229
+ // between and dispatch to the just-closed worker → "sqlite: DB not open".
230
+ // As a single entry, every other op runs fully before the close or after
231
+ // the reopen — never against a closed DB.
232
+ await this.enqueue(async () => {
233
+ if (this.worker) {
234
+ try {
235
+ await this.rawCall('close');
236
+ } catch {
237
+ /* ignore */
238
+ }
239
+ this.worker.terminate();
240
+ this.worker = null;
209
241
  }
210
- this.worker.terminate();
211
- this.worker = null;
212
- }
213
- await this.connect(bucketId);
242
+ await this.openInternal(bucketId);
243
+ });
214
244
  }
215
245
 
216
246
  async close(): Promise<void> {
@@ -5,11 +5,12 @@ import { Sp00kyClient } from './sp00ky';
5
5
 
6
6
  // Guards for the local-first paint contract of `Sp00kyClient.initQuery`:
7
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.
8
+ // network on the awaited path, while the opt-in instant-hydrate + the
9
+ // `register` down-event run in a background chain that (a) keeps hydrate
10
+ // strictly before the enqueue, (b) hydrates only when `instantHydrate:
11
+ // true` (default off the register lifecycle is the single freshness
12
+ // path), (c) never rejects, and (d) is shared by concurrent mounts of the
13
+ // same query.
13
14
  //
14
15
  // Structural half: like sp00ky.auth-order.test.ts, a regex over the source
15
16
  // catches the ordering regressions a runtime mock can't cheaply cover
@@ -73,7 +74,6 @@ describe('Sp00kyClient.finishQueryInit behavior', () => {
73
74
  let calls: string[];
74
75
  let enqueued: any[];
75
76
  let epoch: number;
76
- let preloadFresh: boolean;
77
77
  let cold: boolean;
78
78
  let remoteImpl: () => Promise<any>;
79
79
 
@@ -81,7 +81,6 @@ describe('Sp00kyClient.finishQueryInit behavior', () => {
81
81
  calls = [];
82
82
  enqueued = [];
83
83
  epoch = 1;
84
- preloadFresh = false;
85
84
  cold = true;
86
85
  remoteImpl = async () => {
87
86
  calls.push('fetch');
@@ -112,7 +111,6 @@ describe('Sp00kyClient.finishQueryInit behavior', () => {
112
111
  },
113
112
  dataModule: {
114
113
  isCold: () => cold,
115
- isPreloadFresh: async () => preloadFresh,
116
114
  applyHydration: async () => {
117
115
  calls.push('hydrate');
118
116
  },
@@ -121,22 +119,16 @@ describe('Sp00kyClient.finishQueryInit behavior', () => {
121
119
  });
122
120
  });
123
121
 
124
- it('cold path: fetch → hydrate → enqueue, in order', async () => {
122
+ it('cold path with hydrate enabled: fetch → hydrate → enqueue, in order', async () => {
125
123
  await client.finishQueryInit(hash, q, {});
126
124
  expect(calls).toEqual(['fetch', 'hydrate', 'enqueue']);
127
125
  expect(enqueued).toEqual([{ type: 'register', payload: { hash } }]);
128
126
  });
129
127
 
130
- it('session-preloaded query skips the fetch but still enqueues register', async () => {
128
+ it('a preloaded query still hydrates when enabled cache-first never depends on WHY rows are cached', async () => {
131
129
  client.preloadedHashes.add(q.hash);
132
130
  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']);
131
+ expect(calls).toEqual(['fetch', 'hydrate', 'enqueue']);
140
132
  });
141
133
 
142
134
  it('warm query (not cold) goes straight to enqueue', async () => {
@@ -164,7 +156,13 @@ describe('Sp00kyClient.finishQueryInit behavior', () => {
164
156
  expect(calls).toEqual(['fetch', 'enqueue']);
165
157
  });
166
158
 
167
- it('instantHydrate: false disables the hydrate fetch entirely', async () => {
159
+ it('default (instantHydrate unset) does not hydrate register lifecycle is the only freshness path', async () => {
160
+ delete client.config.instantHydrate;
161
+ await client.finishQueryInit(hash, q, {});
162
+ expect(calls).toEqual(['enqueue']);
163
+ });
164
+
165
+ it('instantHydrate: false does not hydrate', async () => {
168
166
  client.config.instantHydrate = false;
169
167
  await client.finishQueryInit(hash, q, {});
170
168
  expect(calls).toEqual(['enqueue']);
package/src/sp00ky.ts CHANGED
@@ -103,12 +103,6 @@ export class BucketHandle {
103
103
  */
104
104
  const LAST_BUCKET_KEY = 'sp00ky:last_bucket';
105
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
-
112
106
  function readBootBucketHint(): string | null {
113
107
  try {
114
108
  return typeof localStorage !== 'undefined' ? localStorage.getItem(LAST_BUCKET_KEY) : null;
@@ -738,47 +732,29 @@ export class Sp00kyClient<S extends SchemaStructure> {
738
732
  }
739
733
 
740
734
  /**
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.
735
+ * Background tail of {@link initQuery}: instant-hydrate (opt-in via
736
+ * `config.instantHydrate`, and only when the query is cold) followed by
737
+ * enqueuing the `register` down-event. Never rejects — both halves catch and
738
+ * log, so `void`-ing the returned promise can't produce an unhandled
739
+ * rejection. By default (hydrate off) the register lifecycle is the single
740
+ * freshness path; the one-shot fetch is an optimization apps enable
741
+ * explicitly, and it runs regardless of preload state cache-first delivery
742
+ * never depends on WHY rows are cached.
754
743
  */
755
744
  private async finishQueryInit(
756
745
  hash: string,
757
746
  q: InnerQuery<any, any, any>,
758
747
  params: Record<string, any>
759
748
  ): Promise<void> {
760
- if (this.config.instantHydrate !== false && this.dataModule.isCold(hash)) {
749
+ if (this.config.instantHydrate === true && this.dataModule.isCold(hash)) {
761
750
  try {
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
- );
751
+ // Fence against bucket switches: rows fetched under the previous
752
+ // auth context must not hydrate the new bucket's query state — the
753
+ // rebind's re-registration refills it from the right context.
754
+ const epoch = this.local.epoch;
755
+ const [rows] = await this.remote.query<[RecordWithId[]]>(q.selectQuery.query, params);
756
+ if (epoch === this.local.epoch) {
757
+ await this.dataModule.applyHydration(hash, rows ?? []);
782
758
  }
783
759
  } catch (err) {
784
760
  if (err instanceof StaleEpochError) {
package/src/types.ts CHANGED
@@ -163,16 +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 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`.
166
+ * OPT-IN instant-hydrate for cold queries: when enabled and a query is
167
+ * registered with no server result yet, its surql also runs directly on the
168
+ * remote (one-shot, in the background, OFF the paint path) so rows can land
169
+ * before the full register lifecycle completes. Hydrated rows carry their
170
+ * `_00_rv` versions so the registration's `syncRecords` skips re-pulling
171
+ * unchanged bodies. Regardless of this flag, `useQuery` always resolves and
172
+ * paints from the local cache immediately however the rows got there
173
+ * (preload, prior sync). Default `false`: the register lifecycle
174
+ * (`fn::query::register` `_00_list_ref` record sync) is the single
175
+ * freshness path and no duplicate one-shot fetches are made.
176
176
  */
177
177
  instantHydrate?: boolean;
178
178
  /**