@spooky-sync/core 0.0.1-canary.130 → 0.0.1-canary.132
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 +37 -47
- 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 +9 -14
- package/src/services/database/surreal-cache-engine.ts +26 -9
- 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
|
@@ -1283,25 +1283,36 @@ var SurrealCacheEngine = class extends LocalDatabaseService {
|
|
|
1283
1283
|
}
|
|
1284
1284
|
return result;
|
|
1285
1285
|
}
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1286
|
+
/**
|
|
1287
|
+
* Coerce the contract's `Id` (a RecordId, a stable `table:id` string, or a
|
|
1288
|
+
* bare id + the verb's `table` param) to a real RecordId. SurrealDB binds
|
|
1289
|
+
* `$__id` verbatim: a plain string makes `FROM ONLY $__id` "select" the
|
|
1290
|
+
* string itself (a truthy non-row) and `UPSERT $__id` an InternalError, so
|
|
1291
|
+
* string ids silently broke every id-verb on this engine.
|
|
1292
|
+
*/
|
|
1293
|
+
toRecordId(table, id) {
|
|
1294
|
+
if (typeof id !== "string") return id;
|
|
1295
|
+
return new RecordId(table, id.startsWith(`${table}:`) ? id.slice(table.length + 1) : id);
|
|
1296
|
+
}
|
|
1297
|
+
async getById(table, id) {
|
|
1298
|
+
const [row] = await this.query("SELECT * FROM ONLY $__id;", { __id: this.toRecordId(table, id) });
|
|
1299
|
+
return row && typeof row === "object" ? row : null;
|
|
1289
1300
|
}
|
|
1290
|
-
async upsert(
|
|
1301
|
+
async upsert(table, id, data, mode) {
|
|
1291
1302
|
const sql = mode === "merge" ? surql.upsertMerge("__id", "__data") : surql.upsert("__id", "__data");
|
|
1292
1303
|
await this.query(surql.seal(sql), {
|
|
1293
|
-
__id: id,
|
|
1304
|
+
__id: this.toRecordId(table, id),
|
|
1294
1305
|
__data: data
|
|
1295
1306
|
});
|
|
1296
1307
|
}
|
|
1297
|
-
async patch(
|
|
1308
|
+
async patch(table, id, patches) {
|
|
1298
1309
|
await this.query(surql.seal("UPDATE ONLY $__id PATCH $__patches"), {
|
|
1299
|
-
__id: id,
|
|
1310
|
+
__id: this.toRecordId(table, id),
|
|
1300
1311
|
__patches: patches
|
|
1301
1312
|
});
|
|
1302
1313
|
}
|
|
1303
|
-
async delete(
|
|
1304
|
-
await this.query(surql.seal(surql.delete("__id")), { __id: id });
|
|
1314
|
+
async delete(table, id) {
|
|
1315
|
+
await this.query(surql.seal(surql.delete("__id")), { __id: this.toRecordId(table, id) });
|
|
1305
1316
|
}
|
|
1306
1317
|
/**
|
|
1307
1318
|
* Serialized (not strictly atomic) transaction: verbs run in order on the
|
|
@@ -2774,8 +2785,8 @@ var DataModule = class {
|
|
|
2774
2785
|
*/
|
|
2775
2786
|
async getPreloadMarker(hash) {
|
|
2776
2787
|
try {
|
|
2777
|
-
const row = await this.local.getById("_00_preload", hash);
|
|
2778
|
-
if (!row) return null;
|
|
2788
|
+
const row = await this.local.getById("_00_preload", new RecordId("_00_preload", hash));
|
|
2789
|
+
if (!row || typeof row !== "object") return null;
|
|
2779
2790
|
return {
|
|
2780
2791
|
fetchedAt: Number(row.fetchedAt) || 0,
|
|
2781
2792
|
rowCount: Number(row.rowCount) || 0
|
|
@@ -2784,19 +2795,9 @@ var DataModule = class {
|
|
|
2784
2795
|
return null;
|
|
2785
2796
|
}
|
|
2786
2797
|
}
|
|
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
|
-
}
|
|
2797
2798
|
/** Stamp the preload freshness marker after a successful snapshot fetch. */
|
|
2798
2799
|
async writePreloadMarker(hash, rowCount) {
|
|
2799
|
-
await this.local.upsert("_00_preload", hash, {
|
|
2800
|
+
await this.local.upsert("_00_preload", new RecordId("_00_preload", hash), {
|
|
2800
2801
|
fetchedAt: Date.now(),
|
|
2801
2802
|
rowCount
|
|
2802
2803
|
}, "replace");
|
|
@@ -5130,8 +5131,8 @@ function parseBackendInfo(raw) {
|
|
|
5130
5131
|
|
|
5131
5132
|
//#endregion
|
|
5132
5133
|
//#region src/modules/devtools/index.ts
|
|
5133
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
5134
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
5134
|
+
const CORE_VERSION = "0.0.1-canary.132";
|
|
5135
|
+
const WASM_VERSION = "0.0.1-canary.132";
|
|
5135
5136
|
const SURREAL_VERSION = "3.0.3";
|
|
5136
5137
|
var DevToolsService = class {
|
|
5137
5138
|
eventsHistory = [];
|
|
@@ -7066,7 +7067,6 @@ var BucketHandle = class {
|
|
|
7066
7067
|
* callback switches to the user's bucket (cache + outbox intact).
|
|
7067
7068
|
*/
|
|
7068
7069
|
const LAST_BUCKET_KEY = "sp00ky:last_bucket";
|
|
7069
|
-
const HYDRATE_PRELOAD_FRESH = "1h";
|
|
7070
7070
|
function readBootBucketHint() {
|
|
7071
7071
|
try {
|
|
7072
7072
|
return typeof localStorage !== "undefined" ? localStorage.getItem(LAST_BUCKET_KEY) : null;
|
|
@@ -7421,30 +7421,20 @@ var Sp00kyClient = class {
|
|
|
7421
7421
|
return hash;
|
|
7422
7422
|
}
|
|
7423
7423
|
/**
|
|
7424
|
-
* Background tail of {@link initQuery}: instant-hydrate (
|
|
7425
|
-
*
|
|
7426
|
-
* down-event. Never rejects — both halves catch and
|
|
7427
|
-
* returned promise can't produce an unhandled
|
|
7428
|
-
*
|
|
7429
|
-
*
|
|
7430
|
-
*
|
|
7431
|
-
*
|
|
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.
|
|
7424
|
+
* Background tail of {@link initQuery}: instant-hydrate (opt-in via
|
|
7425
|
+
* `config.instantHydrate`, and only when the query is cold) followed by
|
|
7426
|
+
* enqueuing the `register` down-event. Never rejects — both halves catch and
|
|
7427
|
+
* log, so `void`-ing the returned promise can't produce an unhandled
|
|
7428
|
+
* rejection. By default (hydrate off) the register lifecycle is the single
|
|
7429
|
+
* freshness path; the one-shot fetch is an optimization apps enable
|
|
7430
|
+
* explicitly, and it runs regardless of preload state — cache-first delivery
|
|
7431
|
+
* never depends on WHY rows are cached.
|
|
7437
7432
|
*/
|
|
7438
7433
|
async finishQueryInit(hash, q, params) {
|
|
7439
|
-
if (this.config.instantHydrate
|
|
7440
|
-
|
|
7441
|
-
|
|
7442
|
-
|
|
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");
|
|
7434
|
+
if (this.config.instantHydrate === true && this.dataModule.isCold(hash)) try {
|
|
7435
|
+
const epoch = this.local.epoch;
|
|
7436
|
+
const [rows] = await this.remote.query(q.selectQuery.query, params);
|
|
7437
|
+
if (epoch === this.local.epoch) await this.dataModule.applyHydration(hash, rows ?? []);
|
|
7448
7438
|
} catch (err) {
|
|
7449
7439
|
if (err instanceof StaleEpochError) this.logger.debug({
|
|
7450
7440
|
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.132",
|
|
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.132",
|
|
63
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.132",
|
|
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
|
});
|
|
@@ -876,8 +876,11 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
876
876
|
hash: string
|
|
877
877
|
): Promise<{ fetchedAt: number; rowCount: number } | null> {
|
|
878
878
|
try {
|
|
879
|
-
|
|
880
|
-
|
|
879
|
+
// Pass a real RecordId: a bare string id hits the SurrealDB engine's
|
|
880
|
+
// `FROM ONLY $__id` as a plain string, which "selects" the string itself
|
|
881
|
+
// (a truthy non-row) instead of the record — misread as a warm marker.
|
|
882
|
+
const row = await this.local.getById('_00_preload', new RecordId('_00_preload', hash));
|
|
883
|
+
if (!row || typeof row !== 'object') return null;
|
|
881
884
|
return {
|
|
882
885
|
fetchedAt: Number((row as any).fetchedAt) || 0,
|
|
883
886
|
rowCount: Number((row as any).rowCount) || 0,
|
|
@@ -887,22 +890,14 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
887
890
|
}
|
|
888
891
|
}
|
|
889
892
|
|
|
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
|
-
|
|
901
893
|
/** Stamp the preload freshness marker after a successful snapshot fetch. */
|
|
902
894
|
async writePreloadMarker(hash: string, rowCount: number): Promise<void> {
|
|
895
|
+
// RecordId, not a bare string: the SurrealDB engine binds the id verbatim,
|
|
896
|
+
// and `UPSERT <string>` is an InternalError — the marker silently never
|
|
897
|
+
// landed (the write is awaited inside preload's best-effort catch).
|
|
903
898
|
await this.local.upsert(
|
|
904
899
|
'_00_preload',
|
|
905
|
-
hash,
|
|
900
|
+
new RecordId('_00_preload', hash),
|
|
906
901
|
{ fetchedAt: Date.now(), rowCount },
|
|
907
902
|
'replace'
|
|
908
903
|
);
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
} from './cache-engine';
|
|
16
16
|
import { stableKey } from './relation-resolver';
|
|
17
17
|
import { surql } from '../../utils/surql';
|
|
18
|
+
import { RecordId } from 'surrealdb';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Default local cache backend: the in-browser SurrealDB-WASM store. Implemented
|
|
@@ -83,25 +84,41 @@ export class SurrealCacheEngine extends LocalDatabaseService implements LocalCac
|
|
|
83
84
|
return result;
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Coerce the contract's `Id` (a RecordId, a stable `table:id` string, or a
|
|
89
|
+
* bare id + the verb's `table` param) to a real RecordId. SurrealDB binds
|
|
90
|
+
* `$__id` verbatim: a plain string makes `FROM ONLY $__id` "select" the
|
|
91
|
+
* string itself (a truthy non-row) and `UPSERT $__id` an InternalError, so
|
|
92
|
+
* string ids silently broke every id-verb on this engine.
|
|
93
|
+
*/
|
|
94
|
+
private toRecordId(table: string, id: Id): unknown {
|
|
95
|
+
if (typeof id !== 'string') return id;
|
|
96
|
+
const raw = id.startsWith(`${table}:`) ? id.slice(table.length + 1) : id;
|
|
97
|
+
return new RecordId(table, raw);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async getById(table: string, id: Id): Promise<Row | null> {
|
|
101
|
+
const [row] = await this.query<[Row | null]>('SELECT * FROM ONLY $__id;', {
|
|
102
|
+
__id: this.toRecordId(table, id),
|
|
103
|
+
});
|
|
104
|
+
// `FROM ONLY <non-record>` echoes the value back; only a real row counts.
|
|
105
|
+
return row && typeof row === 'object' ? row : null;
|
|
89
106
|
}
|
|
90
107
|
|
|
91
|
-
async upsert(
|
|
108
|
+
async upsert(table: string, id: Id, data: Row, mode: 'replace' | 'merge'): Promise<void> {
|
|
92
109
|
const sql = mode === 'merge' ? surql.upsertMerge('__id', '__data') : surql.upsert('__id', '__data');
|
|
93
|
-
await this.query(surql.seal(sql), { __id: id, __data: data });
|
|
110
|
+
await this.query(surql.seal(sql), { __id: this.toRecordId(table, id), __data: data });
|
|
94
111
|
}
|
|
95
112
|
|
|
96
|
-
async patch(
|
|
113
|
+
async patch(table: string, id: Id, patches: unknown[]): Promise<void> {
|
|
97
114
|
await this.query(surql.seal('UPDATE ONLY $__id PATCH $__patches'), {
|
|
98
|
-
__id: id,
|
|
115
|
+
__id: this.toRecordId(table, id),
|
|
99
116
|
__patches: patches,
|
|
100
117
|
});
|
|
101
118
|
}
|
|
102
119
|
|
|
103
|
-
async delete(
|
|
104
|
-
await this.query(surql.seal(surql.delete('__id')), { __id: id });
|
|
120
|
+
async delete(table: string, id: Id): Promise<void> {
|
|
121
|
+
await this.query(surql.seal(surql.delete('__id')), { __id: this.toRecordId(table, id) });
|
|
105
122
|
}
|
|
106
123
|
|
|
107
124
|
/**
|
|
@@ -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
|
/**
|