@spooky-sync/core 0.0.1-canary.114 → 0.0.1-canary.118
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 +59 -3
- package/dist/index.js +136 -15
- package/dist/types.d.ts +17 -1
- package/package.json +3 -3
- package/src/modules/data/index.ts +70 -18
- package/src/modules/devtools/index.ts +11 -2
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/services/database/local-migrator.ts +1 -0
- package/src/services/database/plan-render.test.ts +1 -1
- package/src/services/database/plan-render.ts +6 -1
- package/src/services/database/sqlite-cache-engine.ts +4 -1
- package/src/sp00ky.ts +118 -8
- package/src/types.ts +18 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { A as SyncHealth, B as Logger$1, C as RecordVersionDiff, D as Sp00kyQueryResult, E as Sp00kyConfig, F as UpEvent, H as EventDefinition, I as LocalStore, L as SealedQuery, M as SyncHealthStatus, N as TimingPhase, O as Sp00kyQueryResultPromise, P as UpdateOptions, R as DatabaseEventSystem, S as RecordVersionArray, T as RunOptions, U as EventSystem, V as SyncEventSystem, _ as QueryStatus, a as MutationCallback, b as QueryTimings, c as PersistenceClient, d as PreloadOptions, f as PreloadRefresh, g as QueryState, h as QueryHash, i as MATERIALIZATION_SAMPLE_WINDOW, j as SyncHealthConfig, k as StoreType, l as PhaseStat, m as QueryConfigRecord, n as EventSubscriptionOptions, o as MutationEvent, p as QueryConfig, r as Level, s as MutationEventType, t as DebounceOptions, u as PinoTransmit, v as QueryStatusCallback, w as RegistrationTimings, x as QueryUpdateCallback, y as QueryTimeToLive, z as DatabaseEventTypes } from "./types.js";
|
|
2
2
|
import * as surrealdb0 from "surrealdb";
|
|
3
3
|
import { Duration, RecordId, Surreal as Surreal$1, SurrealTransaction } from "surrealdb";
|
|
4
|
-
import { AccessDefinition, BackendNames, BackendRoutes, BucketNames, ColumnSchema, GetTable, QueryBuilder, QueryOptions, QueryPlan, RoutePayload, SchemaStructure, TableModel, TableNames, TypeNameToTypeMap } from "@spooky-sync/query-builder";
|
|
4
|
+
import { AccessDefinition, BackendNames, BackendRoutes, BucketNames, ColumnSchema, FinalQuery, GetTable, QueryBuilder, QueryOptions, QueryPlan, RoutePayload, SchemaStructure, TableModel, TableNames, TypeNameToTypeMap } from "@spooky-sync/query-builder";
|
|
5
5
|
import { Logger } from "pino";
|
|
6
6
|
import { LoroDoc } from "loro-crdt";
|
|
7
7
|
|
|
@@ -484,6 +484,37 @@ declare class DataModule<S extends SchemaStructure> {
|
|
|
484
484
|
* local-circuit issue). Runs at most once per query (the `hydrated` flag).
|
|
485
485
|
*/
|
|
486
486
|
applyHydration(hash: string, rows: RecordWithId[]): Promise<void>;
|
|
487
|
+
/**
|
|
488
|
+
* Build the cache batch for a set of one-shot rows and persist it to the
|
|
489
|
+
* local DB + in-browser SSP. Maps each row to a `CREATE` op on its own table
|
|
490
|
+
* and extracts EMBEDDED related children (any nesting depth) as their own
|
|
491
|
+
* records — a `.related()` query returns its children embedded, and a later
|
|
492
|
+
* correlated re-materialization needs them present as standalone rows.
|
|
493
|
+
* Shared by `applyHydration` (live registration) and `persistSnapshot`
|
|
494
|
+
* (preload).
|
|
495
|
+
*/
|
|
496
|
+
private buildAndSaveCacheBatch;
|
|
497
|
+
/**
|
|
498
|
+
* Preload/prewarm: persist one-shot rows (and their embedded related children)
|
|
499
|
+
* into the local cache WITHOUT registering a query — no `activeQueries` entry,
|
|
500
|
+
* no `_00_query` view, no TTL heartbeat. The rows live in the local DB as
|
|
501
|
+
* ordinary bodies (never GC'd on their own) so a later `useQuery` seeds its
|
|
502
|
+
* first paint from them instantly, then registers a live view to freshen.
|
|
503
|
+
*/
|
|
504
|
+
persistSnapshot(tableName: string, rows: RecordWithId[]): Promise<void>;
|
|
505
|
+
/**
|
|
506
|
+
* Read the durable preload freshness marker for a query hash, or null if this
|
|
507
|
+
* query was never preloaded in the current bucket. Co-located with the cached
|
|
508
|
+
* rows (per-bucket `_00_preload` table) so a bucket switch that clears the
|
|
509
|
+
* data also clears the marker — a stale marker can't claim "warm" when the
|
|
510
|
+
* rows are gone. Any read error is treated as cold.
|
|
511
|
+
*/
|
|
512
|
+
getPreloadMarker(hash: string): Promise<{
|
|
513
|
+
fetchedAt: number;
|
|
514
|
+
rowCount: number;
|
|
515
|
+
} | null>;
|
|
516
|
+
/** Stamp the preload freshness marker after a successful snapshot fetch. */
|
|
517
|
+
writePreloadMarker(hash: string, rowCount: number): Promise<void>;
|
|
487
518
|
/** True while ≥1 live subscriber is watching this query (refcount guard). */
|
|
488
519
|
hasSubscribers(hash: string): boolean;
|
|
489
520
|
/**
|
|
@@ -1172,6 +1203,7 @@ declare class Sp00kyClient<S extends SchemaStructure> {
|
|
|
1172
1203
|
private devTools;
|
|
1173
1204
|
private crdtManager;
|
|
1174
1205
|
private featureFlags;
|
|
1206
|
+
private preloadedHashes;
|
|
1175
1207
|
private logger;
|
|
1176
1208
|
auth: AuthService<S>;
|
|
1177
1209
|
streamProcessor: StreamProcessorService;
|
|
@@ -1253,6 +1285,30 @@ declare class Sp00kyClient<S extends SchemaStructure> {
|
|
|
1253
1285
|
deauthenticate(): Promise<void>;
|
|
1254
1286
|
query<Table extends TableNames<S>>(table: Table, options: QueryOptions<TableModel<GetTable<S, Table>>, false>, ttl?: QueryTimeToLive): QueryBuilder<S, Table, Sp00kyQueryResultPromise>;
|
|
1255
1287
|
private initQuery;
|
|
1288
|
+
/**
|
|
1289
|
+
* Smart, awaitable preload/prewarm into the LOCAL cache — without registering a
|
|
1290
|
+
* live view (NO `_00_query`, NO subscription, NO TTL heartbeat).
|
|
1291
|
+
*
|
|
1292
|
+
* Cache-aware via a durable per-bucket freshness marker (`_00_preload`):
|
|
1293
|
+
* - COLD (never preloaded in this bucket): fetch the query one-shot from the
|
|
1294
|
+
* remote, persist the rows (+ embedded `.related()` children), stamp the
|
|
1295
|
+
* marker — and AWAIT it. This is the "smart waiting" first load: callers can
|
|
1296
|
+
* `await db.preload(...)` to hold the UI until the data is ready.
|
|
1297
|
+
* - WARM (marker present): return instantly — NEVER blocks. `refresh` decides
|
|
1298
|
+
* whether to also kick a one-time silent refetch (see {@link PreloadOptions}).
|
|
1299
|
+
* Default `onUse` does nothing; the data freshens when the real `useQuery`
|
|
1300
|
+
* mounts and registers its live view.
|
|
1301
|
+
*
|
|
1302
|
+
* Best-effort: any fetch failure (offline, etc.) is a no-op warn (no marker
|
|
1303
|
+
* written, so it's retried next load). Deduped per session by query hash.
|
|
1304
|
+
*/
|
|
1305
|
+
preload(finalQuery: FinalQuery<S, any, any, any, any, any>, options?: PreloadOptions): Promise<void>;
|
|
1306
|
+
/**
|
|
1307
|
+
* One-shot remote fetch + local persist for a preload query. Returns the row
|
|
1308
|
+
* count on success, or -1 on failure (best-effort: logged, never thrown) so
|
|
1309
|
+
* the caller skips stamping the freshness marker and retries next load.
|
|
1310
|
+
*/
|
|
1311
|
+
private fetchAndPersist;
|
|
1256
1312
|
queryRaw(sql: string, params: Record<string, any>, ttl: QueryTimeToLive): Promise<string>;
|
|
1257
1313
|
subscribe(queryHash: string, callback: (records: Record<string, any>[]) => void, options?: {
|
|
1258
1314
|
immediate?: boolean;
|
|
@@ -1318,4 +1374,4 @@ declare function textToHtml(text: string): string;
|
|
|
1318
1374
|
*/
|
|
1319
1375
|
|
|
1320
1376
|
//#endregion
|
|
1321
|
-
export { AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagSnapshot, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, UpdateOptions, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };
|
|
1377
|
+
export { AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagSnapshot, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, UpdateOptions, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };
|
package/dist/index.js
CHANGED
|
@@ -947,7 +947,7 @@ var LocalMigrator = class {
|
|
|
947
947
|
return;
|
|
948
948
|
}
|
|
949
949
|
await this.recreateDatabase(database);
|
|
950
|
-
const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n ";
|
|
950
|
+
const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_preload SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n ";
|
|
951
951
|
const statements = this.splitStatements(fullSchema);
|
|
952
952
|
for (let i = 0; i < statements.length; i++) {
|
|
953
953
|
const statement = statements[i];
|
|
@@ -1222,7 +1222,7 @@ function renderRelationFetchSurql(req) {
|
|
|
1222
1222
|
vars: { __keys: req.keys },
|
|
1223
1223
|
n: 0
|
|
1224
1224
|
};
|
|
1225
|
-
let sql = `SELECT ${req.select && req.select.length > 0 ? ["id", ...req.select].join(", ") : "*"} FROM ${req.table} WHERE ${req.matchField} IN $__keys`;
|
|
1225
|
+
let sql = `SELECT ${req.select && req.select.length > 0 ? ["id", ...req.select].join(", ") : "*"} FROM ${req.table} WHERE ${req.matchField} IN $__keys.map(|$__k| type::record(<string> $__k))`;
|
|
1226
1226
|
if (req.where && req.where.length > 0) sql += ` AND ${renderWhereSurql(req.where, ctx)}`;
|
|
1227
1227
|
if (req.orderBy && req.orderBy.length > 0) sql += renderOrderBy(req.orderBy);
|
|
1228
1228
|
return {
|
|
@@ -2708,6 +2708,22 @@ var DataModule = class {
|
|
|
2708
2708
|
queryState.hydrated = true;
|
|
2709
2709
|
if (rows.length === 0) return;
|
|
2710
2710
|
const tableName = queryState.config.tableName;
|
|
2711
|
+
await this.buildAndSaveCacheBatch(tableName, rows);
|
|
2712
|
+
queryState.config.remoteArray = rows.map((r) => [encodeRecordId(r.id), r._00_rv || 1]);
|
|
2713
|
+
queryState.records = await this.materializeRecords(queryState);
|
|
2714
|
+
const subscribers = this.subscriptions.get(hash);
|
|
2715
|
+
if (subscribers) for (const cb of subscribers) cb(queryState.records);
|
|
2716
|
+
}
|
|
2717
|
+
/**
|
|
2718
|
+
* Build the cache batch for a set of one-shot rows and persist it to the
|
|
2719
|
+
* local DB + in-browser SSP. Maps each row to a `CREATE` op on its own table
|
|
2720
|
+
* and extracts EMBEDDED related children (any nesting depth) as their own
|
|
2721
|
+
* records — a `.related()` query returns its children embedded, and a later
|
|
2722
|
+
* correlated re-materialization needs them present as standalone rows.
|
|
2723
|
+
* Shared by `applyHydration` (live registration) and `persistSnapshot`
|
|
2724
|
+
* (preload).
|
|
2725
|
+
*/
|
|
2726
|
+
async buildAndSaveCacheBatch(tableName, rows) {
|
|
2711
2727
|
const batch = rows.map((record) => ({
|
|
2712
2728
|
table: tableName,
|
|
2713
2729
|
op: "CREATE",
|
|
@@ -2717,10 +2733,43 @@ var DataModule = class {
|
|
|
2717
2733
|
const seen = new Set(rows.map((r) => encodeRecordId(r.id)));
|
|
2718
2734
|
for (const record of rows) this.collectEmbeddedChildren(record, batch, seen);
|
|
2719
2735
|
await this.cache.saveBatch(batch);
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2736
|
+
}
|
|
2737
|
+
/**
|
|
2738
|
+
* Preload/prewarm: persist one-shot rows (and their embedded related children)
|
|
2739
|
+
* into the local cache WITHOUT registering a query — no `activeQueries` entry,
|
|
2740
|
+
* no `_00_query` view, no TTL heartbeat. The rows live in the local DB as
|
|
2741
|
+
* ordinary bodies (never GC'd on their own) so a later `useQuery` seeds its
|
|
2742
|
+
* first paint from them instantly, then registers a live view to freshen.
|
|
2743
|
+
*/
|
|
2744
|
+
async persistSnapshot(tableName, rows) {
|
|
2745
|
+
if (rows.length === 0) return;
|
|
2746
|
+
await this.buildAndSaveCacheBatch(tableName, rows);
|
|
2747
|
+
}
|
|
2748
|
+
/**
|
|
2749
|
+
* Read the durable preload freshness marker for a query hash, or null if this
|
|
2750
|
+
* query was never preloaded in the current bucket. Co-located with the cached
|
|
2751
|
+
* rows (per-bucket `_00_preload` table) so a bucket switch that clears the
|
|
2752
|
+
* data also clears the marker — a stale marker can't claim "warm" when the
|
|
2753
|
+
* rows are gone. Any read error is treated as cold.
|
|
2754
|
+
*/
|
|
2755
|
+
async getPreloadMarker(hash) {
|
|
2756
|
+
try {
|
|
2757
|
+
const row = await this.local.getById("_00_preload", hash);
|
|
2758
|
+
if (!row) return null;
|
|
2759
|
+
return {
|
|
2760
|
+
fetchedAt: Number(row.fetchedAt) || 0,
|
|
2761
|
+
rowCount: Number(row.rowCount) || 0
|
|
2762
|
+
};
|
|
2763
|
+
} catch {
|
|
2764
|
+
return null;
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
/** Stamp the preload freshness marker after a successful snapshot fetch. */
|
|
2768
|
+
async writePreloadMarker(hash, rowCount) {
|
|
2769
|
+
await this.local.upsert("_00_preload", hash, {
|
|
2770
|
+
fetchedAt: Date.now(),
|
|
2771
|
+
rowCount
|
|
2772
|
+
}, "replace");
|
|
2724
2773
|
}
|
|
2725
2774
|
/** True while ≥1 live subscriber is watching this query (refcount guard). */
|
|
2726
2775
|
hasSubscribers(hash) {
|
|
@@ -5051,8 +5100,8 @@ function parseBackendInfo(raw) {
|
|
|
5051
5100
|
|
|
5052
5101
|
//#endregion
|
|
5053
5102
|
//#region src/modules/devtools/index.ts
|
|
5054
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
5055
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
5103
|
+
const CORE_VERSION = "0.0.1-canary.118";
|
|
5104
|
+
const WASM_VERSION = "0.0.1-canary.118";
|
|
5056
5105
|
const SURREAL_VERSION = "3.0.3";
|
|
5057
5106
|
var DevToolsService = class {
|
|
5058
5107
|
eventsHistory = [];
|
|
@@ -5080,7 +5129,8 @@ var DevToolsService = class {
|
|
|
5080
5129
|
} else if (type === "SP00KY_DEVTOOLS_DISCONNECT") this.enabled = false;
|
|
5081
5130
|
});
|
|
5082
5131
|
this.authService.eventSystem.subscribe(AuthEventTypes.AuthStateChanged, () => {
|
|
5083
|
-
this.
|
|
5132
|
+
if (this.authService.isAuthenticated && this.backendInfo.versions.ssp === UNAVAILABLE) this.refreshBackendVersions();
|
|
5133
|
+
else this.notifyDevTools();
|
|
5084
5134
|
});
|
|
5085
5135
|
this.refreshBackendVersions();
|
|
5086
5136
|
this.logger.debug({ Category: "sp00ky-client::DevToolsService::init" }, "Service initialized");
|
|
@@ -7009,6 +7059,7 @@ var Sp00kyClient = class {
|
|
|
7009
7059
|
devTools;
|
|
7010
7060
|
crdtManager;
|
|
7011
7061
|
featureFlags;
|
|
7062
|
+
preloadedHashes = /* @__PURE__ */ new Set();
|
|
7012
7063
|
logger;
|
|
7013
7064
|
auth;
|
|
7014
7065
|
streamProcessor;
|
|
@@ -7206,13 +7257,18 @@ var Sp00kyClient = class {
|
|
|
7206
7257
|
ensureLocalBucket(userId) {
|
|
7207
7258
|
const target = bucketIdForUser(userId);
|
|
7208
7259
|
this.pendingBucketTarget = target;
|
|
7260
|
+
const release = this.local.currentBucketId !== target ? this.local.beginSwitch() : null;
|
|
7209
7261
|
this.bucketSwitchChain = this.bucketSwitchChain.then(async () => {
|
|
7210
|
-
if (this.pendingBucketTarget !== target)
|
|
7211
|
-
|
|
7212
|
-
|
|
7262
|
+
if (this.pendingBucketTarget !== target || this.local.currentBucketId === target) {
|
|
7263
|
+
release?.();
|
|
7264
|
+
return;
|
|
7265
|
+
}
|
|
7266
|
+
await this.doSwitchBucket(target, release);
|
|
7213
7267
|
});
|
|
7214
7268
|
const result = this.bucketSwitchChain;
|
|
7215
|
-
this.bucketSwitchChain = this.bucketSwitchChain.catch(() => {
|
|
7269
|
+
this.bucketSwitchChain = this.bucketSwitchChain.catch(() => {
|
|
7270
|
+
release?.();
|
|
7271
|
+
});
|
|
7216
7272
|
return result;
|
|
7217
7273
|
}
|
|
7218
7274
|
/**
|
|
@@ -7235,7 +7291,7 @@ var Sp00kyClient = class {
|
|
|
7235
7291
|
* re-homed keeping their hashes, sync resumed on the new bucket's own
|
|
7236
7292
|
* outbox, and every query re-registered remotely to refill from the server.
|
|
7237
7293
|
*/
|
|
7238
|
-
async doSwitchBucket(target) {
|
|
7294
|
+
async doSwitchBucket(target, gateRelease) {
|
|
7239
7295
|
this.logger.info({
|
|
7240
7296
|
target,
|
|
7241
7297
|
from: this.local.currentBucketId,
|
|
@@ -7244,7 +7300,7 @@ var Sp00kyClient = class {
|
|
|
7244
7300
|
await this.sync.prepareBucketSwitch();
|
|
7245
7301
|
this.dataModule.quiesce();
|
|
7246
7302
|
this.crdtManager.closeAll({ flush: false });
|
|
7247
|
-
const reopen = this.local.beginSwitch();
|
|
7303
|
+
const reopen = gateRelease ?? this.local.beginSwitch();
|
|
7248
7304
|
try {
|
|
7249
7305
|
await this.local.switchStore(target);
|
|
7250
7306
|
if (this.local.usesSurqlSchema) await this.migrator.provision(this.config.schemaSurql);
|
|
@@ -7339,6 +7395,71 @@ var Sp00kyClient = class {
|
|
|
7339
7395
|
});
|
|
7340
7396
|
return hash;
|
|
7341
7397
|
}
|
|
7398
|
+
/**
|
|
7399
|
+
* Smart, awaitable preload/prewarm into the LOCAL cache — without registering a
|
|
7400
|
+
* live view (NO `_00_query`, NO subscription, NO TTL heartbeat).
|
|
7401
|
+
*
|
|
7402
|
+
* Cache-aware via a durable per-bucket freshness marker (`_00_preload`):
|
|
7403
|
+
* - COLD (never preloaded in this bucket): fetch the query one-shot from the
|
|
7404
|
+
* remote, persist the rows (+ embedded `.related()` children), stamp the
|
|
7405
|
+
* marker — and AWAIT it. This is the "smart waiting" first load: callers can
|
|
7406
|
+
* `await db.preload(...)` to hold the UI until the data is ready.
|
|
7407
|
+
* - WARM (marker present): return instantly — NEVER blocks. `refresh` decides
|
|
7408
|
+
* whether to also kick a one-time silent refetch (see {@link PreloadOptions}).
|
|
7409
|
+
* Default `onUse` does nothing; the data freshens when the real `useQuery`
|
|
7410
|
+
* mounts and registers its live view.
|
|
7411
|
+
*
|
|
7412
|
+
* Best-effort: any fetch failure (offline, etc.) is a no-op warn (no marker
|
|
7413
|
+
* written, so it's retried next load). Deduped per session by query hash.
|
|
7414
|
+
*/
|
|
7415
|
+
async preload(finalQuery, options) {
|
|
7416
|
+
const q = finalQuery.innerQuery;
|
|
7417
|
+
if (this.preloadedHashes.has(q.hash)) return;
|
|
7418
|
+
const tableName = q.tableName;
|
|
7419
|
+
const tableSchema = this.config.schema.tables.find((t) => t.name === tableName);
|
|
7420
|
+
if (!tableSchema) throw new Error(`Table ${tableName} not found`);
|
|
7421
|
+
const params = parseParams(tableSchema.columns, q.selectQuery.vars ?? {});
|
|
7422
|
+
const hashKey = String(q.hash);
|
|
7423
|
+
const marker = await this.dataModule.getPreloadMarker(hashKey);
|
|
7424
|
+
if (!marker) {
|
|
7425
|
+
const rowCount = await this.fetchAndPersist(q, tableName, params);
|
|
7426
|
+
if (rowCount >= 0) {
|
|
7427
|
+
await this.dataModule.writePreloadMarker(hashKey, rowCount);
|
|
7428
|
+
this.preloadedHashes.add(q.hash);
|
|
7429
|
+
}
|
|
7430
|
+
return;
|
|
7431
|
+
}
|
|
7432
|
+
this.preloadedHashes.add(q.hash);
|
|
7433
|
+
const refresh = options?.refresh ?? "onUse";
|
|
7434
|
+
if (refresh === "onUse") return;
|
|
7435
|
+
if (refresh === "stale") {
|
|
7436
|
+
const maxAgeMs = parseDuration(options?.staleTime ?? "1h");
|
|
7437
|
+
if (Date.now() - marker.fetchedAt <= maxAgeMs) return;
|
|
7438
|
+
}
|
|
7439
|
+
this.fetchAndPersist(q, tableName, params).then((rowCount) => {
|
|
7440
|
+
if (rowCount >= 0) return this.dataModule.writePreloadMarker(hashKey, rowCount);
|
|
7441
|
+
});
|
|
7442
|
+
}
|
|
7443
|
+
/**
|
|
7444
|
+
* One-shot remote fetch + local persist for a preload query. Returns the row
|
|
7445
|
+
* count on success, or -1 on failure (best-effort: logged, never thrown) so
|
|
7446
|
+
* the caller skips stamping the freshness marker and retries next load.
|
|
7447
|
+
*/
|
|
7448
|
+
async fetchAndPersist(q, tableName, params) {
|
|
7449
|
+
try {
|
|
7450
|
+
const [rows] = await this.remote.query(q.selectQuery.query, params);
|
|
7451
|
+
const list = rows ?? [];
|
|
7452
|
+
await this.dataModule.persistSnapshot(tableName, list);
|
|
7453
|
+
return list.length;
|
|
7454
|
+
} catch (err) {
|
|
7455
|
+
this.logger.warn({
|
|
7456
|
+
err,
|
|
7457
|
+
hash: q.hash,
|
|
7458
|
+
Category: "sp00ky-client::Sp00kyClient::preload"
|
|
7459
|
+
}, "Preload fetch failed; data will be fetched on demand");
|
|
7460
|
+
return -1;
|
|
7461
|
+
}
|
|
7462
|
+
}
|
|
7342
7463
|
async queryRaw(sql, params, ttl) {
|
|
7343
7464
|
const tableName = sql.split("FROM ")[1].split(" ")[0];
|
|
7344
7465
|
return this.dataModule.query(tableName, sql, params, ttl);
|
package/dist/types.d.ts
CHANGED
|
@@ -360,6 +360,22 @@ interface PersistenceClient {
|
|
|
360
360
|
* Format: number + unit (m=minutes, h=hours, d=days).
|
|
361
361
|
*/
|
|
362
362
|
type QueryTimeToLive = '1m' | '5m' | '10m' | '15m' | '20m' | '25m' | '30m' | '1h' | '2h' | '3h' | '4h' | '5h' | '6h' | '7h' | '8h' | '9h' | '10h' | '11h' | '12h' | '1d';
|
|
363
|
+
/**
|
|
364
|
+
* Refresh behavior for `preload` when the data is already cached locally (warm).
|
|
365
|
+
* The FIRST load (cold) always fetches + blocks regardless.
|
|
366
|
+
* - `onUse` (default): do nothing when warm — the data freshens on use, when the
|
|
367
|
+
* real `useQuery` mounts and registers its live view. No network on load.
|
|
368
|
+
* - `background`: return instantly, but kick a one-time silent refetch.
|
|
369
|
+
* - `stale`: like `background`, but only if the cached copy is older than
|
|
370
|
+
* `staleTime`.
|
|
371
|
+
*/
|
|
372
|
+
type PreloadRefresh = 'onUse' | 'background' | 'stale';
|
|
373
|
+
interface PreloadOptions {
|
|
374
|
+
/** How to refresh when the query is already cached locally. Default `onUse`. */
|
|
375
|
+
refresh?: PreloadRefresh;
|
|
376
|
+
/** For `refresh: 'stale'` — max age before a warm copy is refetched. Default `1h`. */
|
|
377
|
+
staleTime?: QueryTimeToLive;
|
|
378
|
+
}
|
|
363
379
|
/**
|
|
364
380
|
* Result object returned when a query is registered or executed.
|
|
365
381
|
*/
|
|
@@ -724,4 +740,4 @@ interface DebounceOptions {
|
|
|
724
740
|
delay?: number;
|
|
725
741
|
}
|
|
726
742
|
//#endregion
|
|
727
|
-
export {
|
|
743
|
+
export { SyncHealth as A, Logger$1 as B, RecordVersionDiff as C, Sp00kyQueryResult as D, Sp00kyConfig as E, UpEvent as F, EventDefinition as H, LocalStore as I, SealedQuery as L, SyncHealthStatus as M, TimingPhase as N, Sp00kyQueryResultPromise as O, UpdateOptions as P, DatabaseEventSystem as R, RecordVersionArray as S, RunOptions as T, EventSystem as U, SyncEventSystem as V, QueryStatus as _, MutationCallback as a, QueryTimings as b, PersistenceClient as c, PreloadOptions as d, PreloadRefresh as f, QueryState as g, QueryHash as h, MATERIALIZATION_SAMPLE_WINDOW as i, SyncHealthConfig as j, StoreType as k, PhaseStat as l, QueryConfigRecord as m, EventSubscriptionOptions as n, MutationEvent as o, QueryConfig as p, Level$1 as r, MutationEventType as s, DebounceOptions as t, PinoTransmit as u, QueryStatusCallback as v, RegistrationTimings as w, QueryUpdateCallback as x, QueryTimeToLive as y, DatabaseEventTypes as z };
|
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.118",
|
|
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.118",
|
|
63
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.118",
|
|
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",
|
|
@@ -763,6 +763,35 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
763
763
|
if (rows.length === 0) return;
|
|
764
764
|
|
|
765
765
|
const tableName = queryState.config.tableName;
|
|
766
|
+
await this.buildAndSaveCacheBatch(tableName, rows);
|
|
767
|
+
|
|
768
|
+
// Prime remoteArray from the hydrated id+version pairs: `materializeRecords`
|
|
769
|
+
// prefers it for windowed queries (correct window) and it feeds the version
|
|
770
|
+
// dedup. Registration later overwrites it with the authoritative `_00_list_ref`.
|
|
771
|
+
queryState.config.remoteArray = rows.map(
|
|
772
|
+
(r) => [encodeRecordId(r.id), (r._00_rv as number) || 1] as [string, number]
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
queryState.records = await this.materializeRecords(queryState);
|
|
776
|
+
const subscribers = this.subscriptions.get(hash);
|
|
777
|
+
if (subscribers) {
|
|
778
|
+
for (const cb of subscribers) cb(queryState.records);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Build the cache batch for a set of one-shot rows and persist it to the
|
|
784
|
+
* local DB + in-browser SSP. Maps each row to a `CREATE` op on its own table
|
|
785
|
+
* and extracts EMBEDDED related children (any nesting depth) as their own
|
|
786
|
+
* records — a `.related()` query returns its children embedded, and a later
|
|
787
|
+
* correlated re-materialization needs them present as standalone rows.
|
|
788
|
+
* Shared by `applyHydration` (live registration) and `persistSnapshot`
|
|
789
|
+
* (preload).
|
|
790
|
+
*/
|
|
791
|
+
private async buildAndSaveCacheBatch(
|
|
792
|
+
tableName: string,
|
|
793
|
+
rows: RecordWithId[]
|
|
794
|
+
): Promise<void> {
|
|
766
795
|
const batch: CacheRecord[] = rows.map((record) => ({
|
|
767
796
|
table: tableName,
|
|
768
797
|
op: 'CREATE' as const,
|
|
@@ -770,35 +799,58 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
770
799
|
version: (record._00_rv as number) || 1,
|
|
771
800
|
}));
|
|
772
801
|
|
|
773
|
-
// Flicker-free related data on cold first paint: a `.related()` query's
|
|
774
|
-
// rows arrive with their child rows EMBEDDED (the correlated subquery,
|
|
775
|
-
// e.g. `(SELECT * FROM comment WHERE game=$parent.id) AS comments`). The
|
|
776
|
-
// primary batch above persists only the parent table, so the immediate
|
|
777
|
-
// `materializeRecords` below would re-run the correlated surql against a
|
|
778
|
-
// local DB with no child rows and overwrite the embedded result with
|
|
779
|
-
// empties. Extract those embedded children (any nesting depth) and
|
|
780
|
-
// persist them as their own records so the re-materialization finds them.
|
|
781
802
|
const seen = new Set<string>(rows.map((r) => encodeRecordId(r.id)));
|
|
782
803
|
for (const record of rows) {
|
|
783
804
|
this.collectEmbeddedChildren(record, batch, seen);
|
|
784
805
|
}
|
|
785
806
|
|
|
786
807
|
await this.cache.saveBatch(batch);
|
|
808
|
+
}
|
|
787
809
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
810
|
+
/**
|
|
811
|
+
* Preload/prewarm: persist one-shot rows (and their embedded related children)
|
|
812
|
+
* into the local cache WITHOUT registering a query — no `activeQueries` entry,
|
|
813
|
+
* no `_00_query` view, no TTL heartbeat. The rows live in the local DB as
|
|
814
|
+
* ordinary bodies (never GC'd on their own) so a later `useQuery` seeds its
|
|
815
|
+
* first paint from them instantly, then registers a live view to freshen.
|
|
816
|
+
*/
|
|
817
|
+
async persistSnapshot(tableName: string, rows: RecordWithId[]): Promise<void> {
|
|
818
|
+
if (rows.length === 0) return;
|
|
819
|
+
await this.buildAndSaveCacheBatch(tableName, rows);
|
|
820
|
+
}
|
|
794
821
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
822
|
+
/**
|
|
823
|
+
* Read the durable preload freshness marker for a query hash, or null if this
|
|
824
|
+
* query was never preloaded in the current bucket. Co-located with the cached
|
|
825
|
+
* rows (per-bucket `_00_preload` table) so a bucket switch that clears the
|
|
826
|
+
* data also clears the marker — a stale marker can't claim "warm" when the
|
|
827
|
+
* rows are gone. Any read error is treated as cold.
|
|
828
|
+
*/
|
|
829
|
+
async getPreloadMarker(
|
|
830
|
+
hash: string
|
|
831
|
+
): Promise<{ fetchedAt: number; rowCount: number } | null> {
|
|
832
|
+
try {
|
|
833
|
+
const row = await this.local.getById('_00_preload', hash);
|
|
834
|
+
if (!row) return null;
|
|
835
|
+
return {
|
|
836
|
+
fetchedAt: Number((row as any).fetchedAt) || 0,
|
|
837
|
+
rowCount: Number((row as any).rowCount) || 0,
|
|
838
|
+
};
|
|
839
|
+
} catch {
|
|
840
|
+
return null;
|
|
799
841
|
}
|
|
800
842
|
}
|
|
801
843
|
|
|
844
|
+
/** Stamp the preload freshness marker after a successful snapshot fetch. */
|
|
845
|
+
async writePreloadMarker(hash: string, rowCount: number): Promise<void> {
|
|
846
|
+
await this.local.upsert(
|
|
847
|
+
'_00_preload',
|
|
848
|
+
hash,
|
|
849
|
+
{ fetchedAt: Date.now(), rowCount },
|
|
850
|
+
'replace'
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
|
|
802
854
|
/** True while ≥1 live subscriber is watching this query (refcount guard). */
|
|
803
855
|
hasSubscribers(hash: string): boolean {
|
|
804
856
|
return (this.subscriptions.get(hash)?.size ?? 0) > 0;
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type BackendInfo,
|
|
21
21
|
emptyBackendInfo,
|
|
22
22
|
parseBackendInfo,
|
|
23
|
+
UNAVAILABLE,
|
|
23
24
|
} from './versions';
|
|
24
25
|
|
|
25
26
|
// Real bundled frontend versions, injected at build time by tsdown's
|
|
@@ -82,9 +83,17 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
82
83
|
});
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
// Subscribe to auth events
|
|
86
|
+
// Subscribe to auth events. The initial fire-and-forget version fetch (below)
|
|
87
|
+
// races the remote connection; on the free plan the remote DB (SurrealDB
|
|
88
|
+
// Cloud) has no guest access, so `fn::spooky::info()` is only callable once
|
|
89
|
+
// signed in. Re-fetch when auth resolves — until the versions actually land —
|
|
90
|
+
// instead of leaving them 'unavailable' forever.
|
|
86
91
|
this.authService.eventSystem.subscribe(AuthEventTypes.AuthStateChanged, () => {
|
|
87
|
-
this.
|
|
92
|
+
if (this.authService.isAuthenticated && this.backendInfo.versions.ssp === UNAVAILABLE) {
|
|
93
|
+
void this.refreshBackendVersions();
|
|
94
|
+
} else {
|
|
95
|
+
this.notifyDevTools();
|
|
96
|
+
}
|
|
88
97
|
});
|
|
89
98
|
|
|
90
99
|
// Fire-and-forget backend version discovery; re-push state when it lands.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { Sp00kySync } from './sync';
|
|
4
|
+
import { buildSubqueryListRefSelect } from './utils';
|
|
5
|
+
import { encodeRecordId } from '../../utils/index';
|
|
6
|
+
|
|
7
|
+
// Guards the CLIENT half of `.related()` (author/comments) delivery: given
|
|
8
|
+
// subquery-child edges on the server (`_00_list_ref…` rows with
|
|
9
|
+
// `parent IS NOT NONE`), `syncSubqueryChildren` MUST pull the child bodies
|
|
10
|
+
// through the sync engine so a later re-materialization attaches them to the
|
|
11
|
+
// parent. If this stops forwarding the child ids, related fields come back
|
|
12
|
+
// empty (threads render with an "Anonymous" author, comments vanish) even
|
|
13
|
+
// though the server wrote the edges correctly.
|
|
14
|
+
|
|
15
|
+
function makeSync() {
|
|
16
|
+
const logger: any = {
|
|
17
|
+
child: () => logger,
|
|
18
|
+
debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, trace: () => {},
|
|
19
|
+
};
|
|
20
|
+
const remote: any = { query: vi.fn() };
|
|
21
|
+
const queryId = new RecordId('_00_query', 'h1');
|
|
22
|
+
const queryState: any = { config: { id: queryId, subqueryRemoteArray: [] } };
|
|
23
|
+
const dataModule: any = { getQueryByHash: vi.fn().mockReturnValue(queryState) };
|
|
24
|
+
|
|
25
|
+
const sync = new Sp00kySync(
|
|
26
|
+
{} as any, remote, {} as any, dataModule, {} as any, logger,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// syncEngine is constructed internally; replace it with a spy.
|
|
30
|
+
const syncRecords = vi.fn().mockResolvedValue(undefined);
|
|
31
|
+
(sync as any).syncEngine = { syncRecords };
|
|
32
|
+
// Pin the resolved per-user list_ref table (auth routing is out of scope here).
|
|
33
|
+
(sync as any).listRefTable = () => '_00_list_ref_user_x';
|
|
34
|
+
|
|
35
|
+
const run = (hash: string) => (sync as any).syncSubqueryChildren(hash) as Promise<void>;
|
|
36
|
+
return { remote, syncRecords, queryState, queryId, run };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('syncSubqueryChildren — related-child delivery', () => {
|
|
40
|
+
beforeEach(() => vi.clearAllMocks());
|
|
41
|
+
|
|
42
|
+
it('reads the subquery edges (parent IS NOT NONE) scoped to the query id', async () => {
|
|
43
|
+
const { remote, run, queryId } = makeSync();
|
|
44
|
+
remote.query.mockResolvedValue([[]]);
|
|
45
|
+
|
|
46
|
+
await run('h1');
|
|
47
|
+
|
|
48
|
+
expect(remote.query).toHaveBeenCalledWith(
|
|
49
|
+
buildSubqueryListRefSelect('_00_list_ref_user_x'),
|
|
50
|
+
{ in: queryId }
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('forwards each subquery child id to the sync engine so its body is cached', async () => {
|
|
55
|
+
const { remote, syncRecords, run } = makeSync();
|
|
56
|
+
// Server has one author child edge for this query.
|
|
57
|
+
remote.query.mockResolvedValue([[
|
|
58
|
+
{ out: new RecordId('user', 'u'), version: 1 },
|
|
59
|
+
]]);
|
|
60
|
+
|
|
61
|
+
await run('h1');
|
|
62
|
+
|
|
63
|
+
expect(syncRecords).toHaveBeenCalledTimes(1);
|
|
64
|
+
const arg = syncRecords.mock.calls[0][0];
|
|
65
|
+
expect(arg.added).toHaveLength(1);
|
|
66
|
+
expect(encodeRecordId(arg.added[0].id)).toBe('user:u');
|
|
67
|
+
expect(arg.added[0].version).toBe(1);
|
|
68
|
+
expect(arg.removed).toEqual([]); // shared child bodies are never deleted here
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('is idempotent: unchanged edges do not refetch bodies', async () => {
|
|
72
|
+
const { remote, syncRecords, run, queryState } = makeSync();
|
|
73
|
+
queryState.config.subqueryRemoteArray = [['user:u', 1]];
|
|
74
|
+
remote.query.mockResolvedValue([[
|
|
75
|
+
{ out: new RecordId('user', 'u'), version: 1 },
|
|
76
|
+
]]);
|
|
77
|
+
|
|
78
|
+
await run('h1');
|
|
79
|
+
|
|
80
|
+
expect(syncRecords).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -45,6 +45,7 @@ export class LocalMigrator {
|
|
|
45
45
|
const systemSchema = `
|
|
46
46
|
DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
|
|
47
47
|
DEFINE TABLE IF NOT EXISTS _00_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
|
|
48
|
+
DEFINE TABLE IF NOT EXISTS _00_preload SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
|
|
48
49
|
DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
|
|
49
50
|
DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
|
|
50
51
|
`;
|
|
@@ -51,7 +51,7 @@ describe('renderRelationFetchSurql', () => {
|
|
|
51
51
|
where: [{ field: 'hidden', op: '=', value: false }],
|
|
52
52
|
orderBy: [['rank', 'asc']],
|
|
53
53
|
});
|
|
54
|
-
expect(sql).toBe('SELECT * FROM comment WHERE post IN $__keys AND hidden = $__p0 ORDER BY rank asc;');
|
|
54
|
+
expect(sql).toBe('SELECT * FROM comment WHERE post IN $__keys.map(|$__k| type::record(<string> $__k)) AND hidden = $__p0 ORDER BY rank asc;');
|
|
55
55
|
expect(vars).toEqual({ __keys: ['post:1', 'post:2'], __p0: false });
|
|
56
56
|
});
|
|
57
57
|
});
|
|
@@ -77,7 +77,12 @@ export function renderRelationFetchSurql(req: RelationFetch): RenderedQuery {
|
|
|
77
77
|
const ctx: RenderCtx = { vars: { __keys: req.keys }, n: 0 };
|
|
78
78
|
const projection =
|
|
79
79
|
req.select && req.select.length > 0 ? ['id', ...req.select].join(', ') : '*';
|
|
80
|
-
|
|
80
|
+
// The correlation keys arrive as record-id STRINGS (`"user:abc"`), but the
|
|
81
|
+
// matched column (`id`, or a `record<…>` foreign key) is a RecordId. In
|
|
82
|
+
// SurrealDB `id IN ["user:abc"]` never matches (string ≠ record), so every
|
|
83
|
+
// `.related()` field would resolve empty. Coerce each key to a record id with
|
|
84
|
+
// `type::record(<string> …)` (idempotent if a key is already a RecordId).
|
|
85
|
+
let sql = `SELECT ${projection} FROM ${req.table} WHERE ${req.matchField} IN $__keys.map(|$__k| type::record(<string> $__k))`;
|
|
81
86
|
if (req.where && req.where.length > 0) {
|
|
82
87
|
sql += ` AND ${renderWhereSurql(req.where, ctx)}`;
|
|
83
88
|
}
|
|
@@ -106,7 +106,10 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
106
106
|
// ---- worker plumbing -----------------------------------------------------
|
|
107
107
|
|
|
108
108
|
private spawnWorker(): Worker {
|
|
109
|
-
// The worker is a separate
|
|
109
|
+
// The worker is a separate tsdown entry emitted at `dist/sqlite-worker.js`
|
|
110
|
+
// (see tsdown.config.ts). This URL must point at the emitted `.js` so it
|
|
111
|
+
// resolves after bundling into the flat `dist/index.js`; a `.ts` here ships
|
|
112
|
+
// a dangling reference the consumer's bundler can't resolve.
|
|
110
113
|
const worker = new Worker(new URL('./sqlite-worker.js', import.meta.url), { type: 'module' });
|
|
111
114
|
worker.onmessage = (ev: MessageEvent) => {
|
|
112
115
|
const { id, ok, error, ...rest } = ev.data ?? {};
|
package/src/sp00ky.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
QueryStatusCallback,
|
|
6
6
|
Sp00kyQueryResultPromise,
|
|
7
7
|
PersistenceClient,
|
|
8
|
+
PreloadOptions,
|
|
8
9
|
UpdateOptions,
|
|
9
10
|
RunOptions,
|
|
10
11
|
SyncHealth} from './types';
|
|
@@ -17,6 +18,7 @@ import type { LocalStore } from './services/database/index';
|
|
|
17
18
|
import type { UpEvent } from './modules/sync/index';
|
|
18
19
|
import { Sp00kySync } from './modules/sync/index';
|
|
19
20
|
import type {
|
|
21
|
+
FinalQuery,
|
|
20
22
|
GetTable,
|
|
21
23
|
InnerQuery,
|
|
22
24
|
QueryOptions,
|
|
@@ -44,7 +46,7 @@ import { FeatureFlagModule, FeatureFlagHandle } from './modules/feature-flag/ind
|
|
|
44
46
|
import type { FeatureFlagOptions } from './modules/feature-flag/index';
|
|
45
47
|
import { LocalStoragePersistenceClient } from './services/persistence/localstorage';
|
|
46
48
|
import { ANON_USER_ID, bucketIdForUser } from './modules/ref-tables';
|
|
47
|
-
import { parseParams, encodeRecordId } from './utils/index';
|
|
49
|
+
import { parseParams, encodeRecordId, parseDuration } from './utils/index';
|
|
48
50
|
import { SurrealDBPersistenceClient } from './services/persistence/surrealdb';
|
|
49
51
|
import { ResilientPersistenceClient } from './services/persistence/resilient';
|
|
50
52
|
|
|
@@ -128,6 +130,10 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
128
130
|
private devTools: DevToolsService;
|
|
129
131
|
private crdtManager: CrdtManager;
|
|
130
132
|
private featureFlags!: FeatureFlagModule<S>;
|
|
133
|
+
// Query hashes already preloaded this session — skip redundant one-shot
|
|
134
|
+
// fetches when the same preload query is requested again (e.g. a list row
|
|
135
|
+
// re-rendering). Cleared on process/session end only.
|
|
136
|
+
private preloadedHashes = new Set<number>();
|
|
131
137
|
|
|
132
138
|
private logger: ReturnType<typeof createLogger>;
|
|
133
139
|
public auth: AuthService<S>;
|
|
@@ -510,15 +516,32 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
510
516
|
private ensureLocalBucket(userId: string | null): Promise<void> {
|
|
511
517
|
const target = bucketIdForUser(userId);
|
|
512
518
|
this.pendingBucketTarget = target;
|
|
519
|
+
// Close the query gate SYNCHRONOUSLY the instant a switch is pending — the
|
|
520
|
+
// AuthProvider's own auth subscriber fires right after this (same tick) and
|
|
521
|
+
// enables queries, and `doSwitchBucket` only runs a microtask later on the
|
|
522
|
+
// chain. Without closing the gate here, that query is issued through the
|
|
523
|
+
// still-open gate and is in-flight on the local wasm engine when
|
|
524
|
+
// `switchStore` closes the client — which wedges the engine (every
|
|
525
|
+
// subsequent query, including provisioning, hangs → no view ever registers).
|
|
526
|
+
// No-op when already on the target bucket.
|
|
527
|
+
const needsSwitch = this.local.currentBucketId !== target;
|
|
528
|
+
const release = needsSwitch ? this.local.beginSwitch() : null;
|
|
513
529
|
this.bucketSwitchChain = this.bucketSwitchChain.then(async () => {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
530
|
+
// Superseded by a newer flip, or already on target: reopen the gate we
|
|
531
|
+
// closed above and skip the switch.
|
|
532
|
+
if (this.pendingBucketTarget !== target || this.local.currentBucketId === target) {
|
|
533
|
+
release?.();
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
await this.doSwitchBucket(target, release);
|
|
517
537
|
});
|
|
518
538
|
// Isolate chain failures per-caller: a failed switch must not poison every
|
|
519
|
-
// future switch. The caller (auth listener) logs it.
|
|
539
|
+
// future switch. The caller (auth listener) logs it. Reopen the gate on
|
|
540
|
+
// failure so the client never gets stuck closed.
|
|
520
541
|
const result = this.bucketSwitchChain;
|
|
521
|
-
this.bucketSwitchChain = this.bucketSwitchChain.catch(() => {
|
|
542
|
+
this.bucketSwitchChain = this.bucketSwitchChain.catch(() => {
|
|
543
|
+
release?.();
|
|
544
|
+
});
|
|
522
545
|
return result;
|
|
523
546
|
}
|
|
524
547
|
|
|
@@ -542,7 +565,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
542
565
|
* re-homed keeping their hashes, sync resumed on the new bucket's own
|
|
543
566
|
* outbox, and every query re-registered remotely to refill from the server.
|
|
544
567
|
*/
|
|
545
|
-
private async doSwitchBucket(target: string): Promise<void> {
|
|
568
|
+
private async doSwitchBucket(target: string, gateRelease?: (() => void) | null): Promise<void> {
|
|
546
569
|
this.logger.info(
|
|
547
570
|
{ target, from: this.local.currentBucketId, Category: 'sp00ky-client::Sp00kyClient::doSwitchBucket' },
|
|
548
571
|
'Switching local bucket'
|
|
@@ -552,7 +575,10 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
552
575
|
this.dataModule.quiesce();
|
|
553
576
|
this.crdtManager.closeAll({ flush: false });
|
|
554
577
|
|
|
555
|
-
|
|
578
|
+
// Reuse the gate the caller (`ensureLocalBucket`) closed synchronously; only
|
|
579
|
+
// open our own if called without one (keeps the gate continuously closed
|
|
580
|
+
// from the auth flip through the swap — no window for a racing query).
|
|
581
|
+
const reopen = gateRelease ?? this.local.beginSwitch();
|
|
556
582
|
try {
|
|
557
583
|
await this.local.switchStore(target);
|
|
558
584
|
if (this.local.usesSurqlSchema) {
|
|
@@ -701,6 +727,90 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
701
727
|
return hash;
|
|
702
728
|
}
|
|
703
729
|
|
|
730
|
+
/**
|
|
731
|
+
* Smart, awaitable preload/prewarm into the LOCAL cache — without registering a
|
|
732
|
+
* live view (NO `_00_query`, NO subscription, NO TTL heartbeat).
|
|
733
|
+
*
|
|
734
|
+
* Cache-aware via a durable per-bucket freshness marker (`_00_preload`):
|
|
735
|
+
* - COLD (never preloaded in this bucket): fetch the query one-shot from the
|
|
736
|
+
* remote, persist the rows (+ embedded `.related()` children), stamp the
|
|
737
|
+
* marker — and AWAIT it. This is the "smart waiting" first load: callers can
|
|
738
|
+
* `await db.preload(...)` to hold the UI until the data is ready.
|
|
739
|
+
* - WARM (marker present): return instantly — NEVER blocks. `refresh` decides
|
|
740
|
+
* whether to also kick a one-time silent refetch (see {@link PreloadOptions}).
|
|
741
|
+
* Default `onUse` does nothing; the data freshens when the real `useQuery`
|
|
742
|
+
* mounts and registers its live view.
|
|
743
|
+
*
|
|
744
|
+
* Best-effort: any fetch failure (offline, etc.) is a no-op warn (no marker
|
|
745
|
+
* written, so it's retried next load). Deduped per session by query hash.
|
|
746
|
+
*/
|
|
747
|
+
async preload(
|
|
748
|
+
finalQuery: FinalQuery<S, any, any, any, any, any>,
|
|
749
|
+
options?: PreloadOptions
|
|
750
|
+
): Promise<void> {
|
|
751
|
+
const q = finalQuery.innerQuery;
|
|
752
|
+
if (this.preloadedHashes.has(q.hash)) return;
|
|
753
|
+
|
|
754
|
+
const tableName = q.tableName;
|
|
755
|
+
const tableSchema = this.config.schema.tables.find((t) => t.name === tableName);
|
|
756
|
+
if (!tableSchema) {
|
|
757
|
+
throw new Error(`Table ${tableName} not found`);
|
|
758
|
+
}
|
|
759
|
+
const params = parseParams(tableSchema.columns, q.selectQuery.vars ?? {});
|
|
760
|
+
const hashKey = String(q.hash);
|
|
761
|
+
|
|
762
|
+
const marker = await this.dataModule.getPreloadMarker(hashKey);
|
|
763
|
+
|
|
764
|
+
// COLD → fetch + persist + stamp, awaited so the caller can block on it.
|
|
765
|
+
if (!marker) {
|
|
766
|
+
const rowCount = await this.fetchAndPersist(q, tableName, params);
|
|
767
|
+
if (rowCount >= 0) {
|
|
768
|
+
await this.dataModule.writePreloadMarker(hashKey, rowCount);
|
|
769
|
+
this.preloadedHashes.add(q.hash);
|
|
770
|
+
}
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// WARM → never block. Mark handled for this session, then optionally refresh.
|
|
775
|
+
this.preloadedHashes.add(q.hash);
|
|
776
|
+
const refresh = options?.refresh ?? 'onUse';
|
|
777
|
+
if (refresh === 'onUse') return;
|
|
778
|
+
|
|
779
|
+
if (refresh === 'stale') {
|
|
780
|
+
const maxAgeMs = parseDuration(options?.staleTime ?? '1h');
|
|
781
|
+
if (Date.now() - marker.fetchedAt <= maxAgeMs) return; // still fresh
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// `background`, or `stale` past its staleTime → one-time silent refetch.
|
|
785
|
+
void this.fetchAndPersist(q, tableName, params).then((rowCount) => {
|
|
786
|
+
if (rowCount >= 0) return this.dataModule.writePreloadMarker(hashKey, rowCount);
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* One-shot remote fetch + local persist for a preload query. Returns the row
|
|
792
|
+
* count on success, or -1 on failure (best-effort: logged, never thrown) so
|
|
793
|
+
* the caller skips stamping the freshness marker and retries next load.
|
|
794
|
+
*/
|
|
795
|
+
private async fetchAndPersist(
|
|
796
|
+
q: InnerQuery<any, any, any>,
|
|
797
|
+
tableName: string,
|
|
798
|
+
params: Record<string, any>
|
|
799
|
+
): Promise<number> {
|
|
800
|
+
try {
|
|
801
|
+
const [rows] = await this.remote.query<[RecordWithId[]]>(q.selectQuery.query, params);
|
|
802
|
+
const list = rows ?? [];
|
|
803
|
+
await this.dataModule.persistSnapshot(tableName, list);
|
|
804
|
+
return list.length;
|
|
805
|
+
} catch (err) {
|
|
806
|
+
this.logger.warn(
|
|
807
|
+
{ err, hash: q.hash, Category: 'sp00ky-client::Sp00kyClient::preload' },
|
|
808
|
+
'Preload fetch failed; data will be fetched on demand'
|
|
809
|
+
);
|
|
810
|
+
return -1;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
704
814
|
async queryRaw(sql: string, params: Record<string, any>, ttl: QueryTimeToLive) {
|
|
705
815
|
const tableName = sql.split('FROM ')[1].split(' ')[0];
|
|
706
816
|
return this.dataModule.query(tableName, sql, params, ttl);
|
package/src/types.ts
CHANGED
|
@@ -68,6 +68,24 @@ export type QueryTimeToLive =
|
|
|
68
68
|
| '12h'
|
|
69
69
|
| '1d';
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Refresh behavior for `preload` when the data is already cached locally (warm).
|
|
73
|
+
* The FIRST load (cold) always fetches + blocks regardless.
|
|
74
|
+
* - `onUse` (default): do nothing when warm — the data freshens on use, when the
|
|
75
|
+
* real `useQuery` mounts and registers its live view. No network on load.
|
|
76
|
+
* - `background`: return instantly, but kick a one-time silent refetch.
|
|
77
|
+
* - `stale`: like `background`, but only if the cached copy is older than
|
|
78
|
+
* `staleTime`.
|
|
79
|
+
*/
|
|
80
|
+
export type PreloadRefresh = 'onUse' | 'background' | 'stale';
|
|
81
|
+
|
|
82
|
+
export interface PreloadOptions {
|
|
83
|
+
/** How to refresh when the query is already cached locally. Default `onUse`. */
|
|
84
|
+
refresh?: PreloadRefresh;
|
|
85
|
+
/** For `refresh: 'stale'` — max age before a warm copy is refetched. Default `1h`. */
|
|
86
|
+
staleTime?: QueryTimeToLive;
|
|
87
|
+
}
|
|
88
|
+
|
|
71
89
|
/**
|
|
72
90
|
* Result object returned when a query is registered or executed.
|
|
73
91
|
*/
|