@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 +24 -0
- package/dist/index.js +65 -10
- package/dist/types.d.ts +10 -7
- package/package.json +3 -3
- package/src/modules/data/data.hydration.test.ts +150 -0
- package/src/modules/data/index.ts +17 -0
- package/src/sp00ky.init-query.test.ts +185 -0
- package/src/sp00ky.ts +97 -19
- package/src/types.ts +10 -7
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.
|
|
5122
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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
|
-
|
|
7403
|
-
|
|
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.
|
|
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
|
-
|
|
7412
|
-
|
|
7413
|
-
|
|
7414
|
-
|
|
7415
|
-
|
|
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
|
|
454
|
-
*
|
|
455
|
-
* the result
|
|
456
|
-
* background. The hydrated rows are ingested with their
|
|
457
|
-
* registration's `syncRecords` skips re-pulling unchanged
|
|
458
|
-
*
|
|
459
|
-
*
|
|
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.
|
|
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.
|
|
63
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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
|
-
//
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
//
|
|
712
|
-
//
|
|
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
|
|
716
|
-
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
|
167
|
-
*
|
|
168
|
-
* the result
|
|
169
|
-
* background. The hydrated rows are ingested with their
|
|
170
|
-
* registration's `syncRecords` skips re-pulling unchanged
|
|
171
|
-
*
|
|
172
|
-
*
|
|
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
|
/**
|