@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 +8 -20
- package/dist/index.js +41 -45
- package/dist/types.d.ts +10 -10
- package/package.json +3 -3
- package/src/modules/data/data.hydration.test.ts +13 -21
- package/src/modules/data/index.ts +0 -11
- package/src/services/database/sqlite-cache-engine.test.ts +89 -1
- package/src/services/database/sqlite-cache-engine.ts +41 -11
- package/src/sp00ky.init-query.test.ts +16 -18
- package/src/sp00ky.ts +16 -40
- package/src/types.ts +10 -10
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 (
|
|
1307
|
-
*
|
|
1308
|
-
* down-event. Never rejects — both halves catch and
|
|
1309
|
-
* returned promise can't produce an unhandled
|
|
1310
|
-
*
|
|
1311
|
-
*
|
|
1312
|
-
*
|
|
1313
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
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.
|
|
5145
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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 (
|
|
7436
|
-
*
|
|
7437
|
-
* down-event. Never rejects — both halves catch and
|
|
7438
|
-
* returned promise can't produce an unhandled
|
|
7439
|
-
*
|
|
7440
|
-
*
|
|
7441
|
-
*
|
|
7442
|
-
*
|
|
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
|
|
7451
|
-
|
|
7452
|
-
|
|
7453
|
-
|
|
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
|
-
*
|
|
454
|
-
* result yet,
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
* versions so the registration's `syncRecords` skips re-pulling
|
|
458
|
-
* bodies.
|
|
459
|
-
* from the local cache immediately
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
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.
|
|
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.
|
|
63
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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`
|
|
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
|
|
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.
|
|
122
|
-
|
|
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('
|
|
130
|
-
const dm = makeDm(async () => ({ fetchedAt:
|
|
131
|
-
expect(await dm.
|
|
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('
|
|
140
|
-
|
|
141
|
-
|
|
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('
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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.
|
|
211
|
-
|
|
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
|
|
9
|
-
// down-event run in a background chain that (a) keeps hydrate
|
|
10
|
-
// before the enqueue, (b)
|
|
11
|
-
//
|
|
12
|
-
// mounts of the
|
|
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('
|
|
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
|
|
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 (
|
|
742
|
-
*
|
|
743
|
-
* down-event. Never rejects — both halves catch and
|
|
744
|
-
* returned promise can't produce an unhandled
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
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
|
|
749
|
+
if (this.config.instantHydrate === true && this.dataModule.isCold(hash)) {
|
|
761
750
|
try {
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
*
|
|
167
|
-
* result yet,
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* versions so the registration's `syncRecords` skips re-pulling
|
|
171
|
-
* bodies.
|
|
172
|
-
* from the local cache immediately
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
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
|
/**
|