@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.211
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/AGENTS.md +57 -0
- package/dist/index.d.ts +2514 -58
- package/dist/index.js +12561 -2449
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +303 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +751 -11
- package/package.json +11 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +36 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/auth.local-first.test.ts +101 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +127 -24
- package/src/modules/cache/cache.relay.test.ts +95 -0
- package/src/modules/cache/index.ts +163 -43
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.membership.test.ts +523 -0
- package/src/modules/data/data.notify-table.test.ts +41 -0
- package/src/modules/data/data.pending-ids.test.ts +199 -0
- package/src/modules/data/data.rebind.test.ts +170 -0
- package/src/modules/data/data.rematerialize.test.ts +114 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.settled-writes.test.ts +206 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/id-set-plan.test.ts +122 -0
- package/src/modules/data/index.ts +1815 -151
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +194 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +450 -46
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +146 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +308 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +164 -82
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +180 -0
- package/src/modules/sync/queue/queue-down.ts +80 -13
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +241 -57
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.retry.test.ts +237 -0
- package/src/modules/sync/scheduler.ts +215 -13
- package/src/modules/sync/sync.cleanup.test.ts +116 -0
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.heartbeat.test.ts +80 -0
- package/src/modules/sync/sync.live-removal.test.ts +175 -0
- package/src/modules/sync/sync.reconnect.test.ts +145 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.tabs.test.ts +249 -0
- package/src/modules/sync/sync.ts +1726 -99
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +201 -17
- package/src/otel/index.ts +13 -10
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +193 -0
- package/src/services/database/connection-supervisor.test.ts +289 -0
- package/src/services/database/connection-supervisor.ts +415 -0
- package/src/services/database/database.query-timeout.test.ts +83 -0
- package/src/services/database/database.ts +41 -12
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +484 -67
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +110 -14
- package/src/services/database/sqlite-cache-engine.test.ts +616 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1358 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +104 -0
- package/src/services/database/sqlite-plan-sql.ts +138 -0
- package/src/services/database/sqlite-projection.test.ts +99 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.ts +3 -2
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +796 -84
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +59 -3
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +365 -0
- package/src/services/tabs/coordinator.ts +633 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +258 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.local-first.test.ts +60 -0
- package/src/sp00ky.ts +1693 -0
- package/src/types.ts +528 -13
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +79 -13
- package/src/utils/parser.test.ts +49 -120
- package/src/utils/parser.ts +32 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/src/spooky.ts +0 -395
package/src/sp00ky.ts
ADDED
|
@@ -0,0 +1,1693 @@
|
|
|
1
|
+
import { DataModule } from './modules/data/index';
|
|
2
|
+
import type {
|
|
3
|
+
Sp00kyConfig,
|
|
4
|
+
QueryTimeToLive,
|
|
5
|
+
QueryStatusCallback,
|
|
6
|
+
Sp00kyQueryResultPromise,
|
|
7
|
+
PersistenceClient,
|
|
8
|
+
PreloadOptions,
|
|
9
|
+
UpdateOptions,
|
|
10
|
+
RunOptions,
|
|
11
|
+
SyncHealth,
|
|
12
|
+
StorageHealth,
|
|
13
|
+
} from './types';
|
|
14
|
+
import {
|
|
15
|
+
ConnectionSupervisor,
|
|
16
|
+
LocalMigrator,
|
|
17
|
+
RemoteDatabaseService,
|
|
18
|
+
createLocalEngine,
|
|
19
|
+
} from './services/database/index';
|
|
20
|
+
import type { LocalStore } from './services/database/index';
|
|
21
|
+
import { StaleEpochError } from './services/database/index';
|
|
22
|
+
import type { UpEvent } from './modules/sync/index';
|
|
23
|
+
import { Sp00kySync } from './modules/sync/index';
|
|
24
|
+
import type {
|
|
25
|
+
FinalQuery,
|
|
26
|
+
GetTable,
|
|
27
|
+
InnerQuery,
|
|
28
|
+
QueryOptions,
|
|
29
|
+
SchemaStructure,
|
|
30
|
+
TableModel,
|
|
31
|
+
TableNames,
|
|
32
|
+
BucketNames,
|
|
33
|
+
BackendNames,
|
|
34
|
+
BackendRoutes,
|
|
35
|
+
RoutePayload,
|
|
36
|
+
} from '@spooky-sync/query-builder';
|
|
37
|
+
import { QueryBuilder } from '@spooky-sync/query-builder';
|
|
38
|
+
|
|
39
|
+
import { DevToolsService } from './modules/devtools/index';
|
|
40
|
+
import { createLogger } from './services/logger/index';
|
|
41
|
+
import { AuthService } from './modules/auth/index';
|
|
42
|
+
import { StreamProcessorService } from './services/stream-processor/index';
|
|
43
|
+
import { extractSelectPermissions } from './services/stream-processor/permissions';
|
|
44
|
+
import { EventSystem } from './events/index';
|
|
45
|
+
import { CacheModule } from './modules/cache/index';
|
|
46
|
+
import type { RecordWithId } from './modules/cache/index';
|
|
47
|
+
import { CrdtManager, CrdtField } from './modules/crdt/index';
|
|
48
|
+
import { preloadLoro } from './modules/crdt/loro-loader';
|
|
49
|
+
import { FeatureFlagModule, FeatureFlagHandle } from './modules/feature-flag/index';
|
|
50
|
+
import type { FeatureFlagOptions, FeatureFlagOverride } from './modules/feature-flag/index';
|
|
51
|
+
import { AppReleaseModule, AppReleaseHandle } from './modules/app-release/index';
|
|
52
|
+
import type { AppReleaseOptions } from './modules/app-release/index';
|
|
53
|
+
import { LocalStoragePersistenceClient } from './services/persistence/localstorage';
|
|
54
|
+
import { ANON_USER_ID, bucketIdForUser } from './modules/ref-tables';
|
|
55
|
+
import { parseQueryParams, encodeRecordId, parseDuration } from './utils/index';
|
|
56
|
+
import { SurrealDBPersistenceClient } from './services/persistence/surrealdb';
|
|
57
|
+
import { ResilientPersistenceClient } from './services/persistence/resilient';
|
|
58
|
+
import { detectSharedTabsSupport } from './services/tabs/support';
|
|
59
|
+
import { TabsCoordinator, type CoordinatorHooks } from './services/tabs/coordinator';
|
|
60
|
+
import { computeTabsFingerprint, hash53, type TabRole } from './services/tabs/protocol';
|
|
61
|
+
import type { SqliteCacheEngine } from './services/database/sqlite-cache-engine';
|
|
62
|
+
import type { BlobCache, BlobReadOptions, BlobUrlLease } from './services/blobs/index';
|
|
63
|
+
import { MemoryBlobStore, createBlobCache, resolveBlobBudget } from './services/blobs/index';
|
|
64
|
+
|
|
65
|
+
import {
|
|
66
|
+
blurhashSidecarPath,
|
|
67
|
+
encodeImageToBlurhash,
|
|
68
|
+
isImagePath,
|
|
69
|
+
isBlurhashValid,
|
|
70
|
+
type BlurhashSetting,
|
|
71
|
+
type BlurhashEncodeOptions,
|
|
72
|
+
} from './utils/blurhash';
|
|
73
|
+
|
|
74
|
+
/** Coerce whatever the `.get()` RPC hands back into a Blob. */
|
|
75
|
+
export function bucketContentToBlob(content: unknown): Blob | null {
|
|
76
|
+
if (content == null) return null;
|
|
77
|
+
if (content instanceof Blob) return content;
|
|
78
|
+
if (typeof content === 'string') return new Blob([content]);
|
|
79
|
+
if (content instanceof ArrayBuffer) return new Blob([content]);
|
|
80
|
+
// Uint8Array and friends. The cast sidesteps `ArrayBufferLike` vs
|
|
81
|
+
// `ArrayBuffer` (SharedArrayBuffer) in lib.dom's BlobPart.
|
|
82
|
+
if (ArrayBuffer.isView(content)) return new Blob([content as unknown as BlobPart]);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface BucketPutOptions {
|
|
87
|
+
/** Override the client-level {@link Sp00kyConfig.blurhash} setting for this put. */
|
|
88
|
+
blurhash?: BlurhashSetting;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface BucketPutResult {
|
|
92
|
+
/** The computed blurhash when the content was a hashable image; else null. */
|
|
93
|
+
blurhash: string | null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface BucketHandleSettings {
|
|
97
|
+
blurhash?: BlurhashSetting;
|
|
98
|
+
logger?: { warn: (obj: unknown, msg?: string) => void };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Paths known to have no blurhash sidecar, per tab. The blob cache has no
|
|
103
|
+
* negative caching, so without this every mount of a hashless image would pay
|
|
104
|
+
* one serialized remote read. Cleared when a put writes a sidecar or a delete
|
|
105
|
+
* removes the image. Keyed `${bucket}:${path}` (the image path, not the sidecar).
|
|
106
|
+
*/
|
|
107
|
+
const missingBlurhash = new Set<string>();
|
|
108
|
+
|
|
109
|
+
export class BucketHandle {
|
|
110
|
+
constructor(
|
|
111
|
+
private bucketName: string,
|
|
112
|
+
private remote: RemoteDatabaseService,
|
|
113
|
+
/** Absent on the raw handle the cache itself reads through. */
|
|
114
|
+
private blobs?: BlobCache | null,
|
|
115
|
+
private settings?: BucketHandleSettings
|
|
116
|
+
) {}
|
|
117
|
+
|
|
118
|
+
/** Effective blurhash setting: per-call option > client config > default ON. */
|
|
119
|
+
private resolveBlurhash(option?: BlurhashSetting): BlurhashEncodeOptions | null {
|
|
120
|
+
const setting = option ?? this.settings?.blurhash ?? true;
|
|
121
|
+
if (setting === false) return null;
|
|
122
|
+
return setting === true ? {} : setting;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async put(
|
|
126
|
+
path: string,
|
|
127
|
+
content: string | Uint8Array | Blob,
|
|
128
|
+
options?: BucketPutOptions
|
|
129
|
+
): Promise<BucketPutResult> {
|
|
130
|
+
// Start hashing while the upload is in flight; both browser-side costs
|
|
131
|
+
// overlap and the sidecar put only queues once the main put resolved.
|
|
132
|
+
const encodeOptions = this.resolveBlurhash(options?.blurhash);
|
|
133
|
+
const hashPromise =
|
|
134
|
+
encodeOptions && isImagePath(path)
|
|
135
|
+
? encodeImageToBlurhash(content, encodeOptions)
|
|
136
|
+
: Promise.resolve(null);
|
|
137
|
+
|
|
138
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${path}".put($content);`, { content });
|
|
139
|
+
// A path can be overwritten, so anything cached under it is now wrong.
|
|
140
|
+
await this.blobs?.invalidate({ bucket: this.bucketName, path });
|
|
141
|
+
|
|
142
|
+
// The sidecar is best-effort: a hash or sidecar failure must never fail
|
|
143
|
+
// the image put that triggered it.
|
|
144
|
+
let hash: string | null = null;
|
|
145
|
+
try {
|
|
146
|
+
hash = await hashPromise;
|
|
147
|
+
if (hash) {
|
|
148
|
+
const sidecar = blurhashSidecarPath(path);
|
|
149
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${sidecar}".put($content);`, {
|
|
150
|
+
content: hash,
|
|
151
|
+
});
|
|
152
|
+
await this.blobs?.invalidate({ bucket: this.bucketName, path: sidecar });
|
|
153
|
+
missingBlurhash.delete(`${this.bucketName}:${path}`);
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
hash = null;
|
|
157
|
+
this.settings?.logger?.warn({ error, path }, 'blurhash sidecar put failed');
|
|
158
|
+
}
|
|
159
|
+
return { blurhash: hash };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The blurhash stored alongside an uploaded image (see
|
|
164
|
+
* {@link blurhashSidecarPath}), or null when there is none. Reads through the
|
|
165
|
+
* blob cache, so a warm client answers from OPFS without a network hop, and
|
|
166
|
+
* misses are remembered per tab so a hashless image costs at most one
|
|
167
|
+
* serialized remote read per session.
|
|
168
|
+
*/
|
|
169
|
+
async blurhash(path: string): Promise<string | null> {
|
|
170
|
+
const cacheKey = `${this.bucketName}:${path}`;
|
|
171
|
+
if (missingBlurhash.has(cacheKey)) return null;
|
|
172
|
+
try {
|
|
173
|
+
const blob = await this.read(blurhashSidecarPath(path), { persist: true });
|
|
174
|
+
if (!blob) {
|
|
175
|
+
missingBlurhash.add(cacheKey);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
const hash = (await blob.text()).trim();
|
|
179
|
+
if (!isBlurhashValid(hash).result) {
|
|
180
|
+
this.settings?.logger?.warn({ path }, 'blurhash sidecar holds an invalid hash');
|
|
181
|
+
missingBlurhash.add(cacheKey);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
return hash;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
// Transient failure: do NOT negative-cache, the next mount may succeed.
|
|
187
|
+
this.settings?.logger?.warn({ error, path }, 'blurhash sidecar read failed');
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async get(path: string): Promise<unknown> {
|
|
193
|
+
const [result] = await this.remote.query<[unknown]>(
|
|
194
|
+
`RETURN f"${this.bucketName}:/${path}".get();`
|
|
195
|
+
);
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Read through the local blob cache: OPFS first, the bucket second. Unlike
|
|
201
|
+
* {@link get} this survives a reload and works offline. Returns null when the
|
|
202
|
+
* file exists in neither place.
|
|
203
|
+
*/
|
|
204
|
+
async read(path: string, options?: BlobReadOptions): Promise<Blob | null> {
|
|
205
|
+
if (!this.blobs) return bucketContentToBlob(await this.get(path));
|
|
206
|
+
return this.blobs.read({ bucket: this.bucketName, path }, options);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A refcounted object URL for `path`, suitable for `<img src>`. The caller
|
|
211
|
+
* MUST call `release()` when the URL goes off screen. Returns null when the
|
|
212
|
+
* file does not exist, or when object URLs are unavailable (non-browser).
|
|
213
|
+
*/
|
|
214
|
+
async url(path: string, options?: BlobReadOptions): Promise<BlobUrlLease | null> {
|
|
215
|
+
if (!this.blobs) return null;
|
|
216
|
+
return this.blobs.acquireUrl({ bucket: this.bucketName, path }, options);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Exempt `path` from pressure eviction. Pinned bytes never expire. */
|
|
220
|
+
pin(path: string): void {
|
|
221
|
+
this.blobs?.setPinned({ bucket: this.bucketName, path }, true);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
unpin(path: string): void {
|
|
225
|
+
this.blobs?.setPinned({ bucket: this.bucketName, path }, false);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Drop `path` from the local cache without touching the remote file. */
|
|
229
|
+
async evict(path: string): Promise<void> {
|
|
230
|
+
await this.blobs?.invalidate({ bucket: this.bucketName, path });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Warm the cache for offline use. Already-cached paths are skipped. */
|
|
234
|
+
async prefetch(paths: string[]): Promise<void> {
|
|
235
|
+
await this.blobs?.prefetch(paths.map((path) => ({ bucket: this.bucketName, path })));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async delete(path: string): Promise<void> {
|
|
239
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${path}".delete();`);
|
|
240
|
+
await this.blobs?.invalidate({ bucket: this.bucketName, path });
|
|
241
|
+
// Symmetry with put: an image's blurhash sidecar dies with it. Best-effort,
|
|
242
|
+
// the sidecar may simply not exist.
|
|
243
|
+
if (isImagePath(path)) {
|
|
244
|
+
const sidecar = blurhashSidecarPath(path);
|
|
245
|
+
try {
|
|
246
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${sidecar}".delete();`);
|
|
247
|
+
await this.blobs?.invalidate({ bucket: this.bucketName, path: sidecar });
|
|
248
|
+
} catch {
|
|
249
|
+
// Nothing to clean up.
|
|
250
|
+
}
|
|
251
|
+
missingBlurhash.delete(`${this.bucketName}:${path}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async exists(path: string): Promise<boolean> {
|
|
256
|
+
const [result] = await this.remote.query<[boolean]>(
|
|
257
|
+
`RETURN f"${this.bucketName}:/${path}".exists();`
|
|
258
|
+
);
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async head(path: string): Promise<Record<string, unknown>> {
|
|
263
|
+
const [result] = await this.remote.query<[Record<string, unknown>]>(
|
|
264
|
+
`RETURN f"${this.bucketName}:/${path}".head();`
|
|
265
|
+
);
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async copy(sourcePath: string, targetPath: string): Promise<void> {
|
|
270
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".copy($target);`, {
|
|
271
|
+
target: targetPath,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async rename(sourcePath: string, targetPath: string): Promise<void> {
|
|
276
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".rename($target);`, {
|
|
277
|
+
target: targetPath,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async list(prefix?: string): Promise<string[]> {
|
|
282
|
+
const p = prefix ?? '';
|
|
283
|
+
const [result] = await this.remote.query<[string[]]>(
|
|
284
|
+
`RETURN f"${this.bucketName}:/${p}".list();`
|
|
285
|
+
);
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Boot hint for which local bucket to open before auth resolves. Written to
|
|
292
|
+
* PLAIN localStorage (never the configured persistenceClient): the surrealdb
|
|
293
|
+
* persistence client stores its keys INSIDE a bucket, and the whole point of
|
|
294
|
+
* the hint is to pick the bucket before any bucket is open. A warm reload of a
|
|
295
|
+
* signed-in user thus opens their own bucket immediately — zero switches.
|
|
296
|
+
* Losing the hint is fail-closed: boot lands on the anon bucket and the auth
|
|
297
|
+
* callback switches to the user's bucket (cache + outbox intact).
|
|
298
|
+
*/
|
|
299
|
+
const LAST_BUCKET_KEY = 'sp00ky:last_bucket';
|
|
300
|
+
|
|
301
|
+
/** Reported for engines that don't track local-store durability. Frozen so a
|
|
302
|
+
* subscriber can't mutate the shared snapshot. */
|
|
303
|
+
const UNKNOWN_STORAGE_HEALTH: StorageHealth = Object.freeze({
|
|
304
|
+
status: 'unknown',
|
|
305
|
+
fallback: false,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
function readBootBucketHint(): string | null {
|
|
309
|
+
try {
|
|
310
|
+
return typeof localStorage !== 'undefined' ? localStorage.getItem(LAST_BUCKET_KEY) : null;
|
|
311
|
+
} catch {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function writeBootBucketHint(bucketId: string): void {
|
|
317
|
+
try {
|
|
318
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(LAST_BUCKET_KEY, bucketId);
|
|
319
|
+
} catch {
|
|
320
|
+
/* private-mode storage errors: boot just falls back to the anon bucket */
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export class Sp00kyClient<S extends SchemaStructure> {
|
|
325
|
+
private local: LocalStore;
|
|
326
|
+
private remote: RemoteDatabaseService;
|
|
327
|
+
private blobs: BlobCache;
|
|
328
|
+
private connectionSupervisor: ConnectionSupervisor;
|
|
329
|
+
private persistenceClient: PersistenceClient;
|
|
330
|
+
|
|
331
|
+
private migrator: LocalMigrator;
|
|
332
|
+
private cache: CacheModule;
|
|
333
|
+
private dataModule: DataModule<S>;
|
|
334
|
+
private sync: Sp00kySync<S>;
|
|
335
|
+
private devTools: DevToolsService;
|
|
336
|
+
private crdtManager: CrdtManager;
|
|
337
|
+
/**
|
|
338
|
+
* True once the LOCAL half of boot is done and the client can serve reads
|
|
339
|
+
* from the local store. Distinct from being connected: `syncHealth` covers
|
|
340
|
+
* reaching the server and `storageHealth` covers whether the local store is
|
|
341
|
+
* durable, but neither says "usable". Consumers gate their first paint on
|
|
342
|
+
* this, which is what makes a warm boot instant and an offline boot possible.
|
|
343
|
+
*/
|
|
344
|
+
private localReady = false;
|
|
345
|
+
// Principal the current query-id salt was minted for (`null` = signed out).
|
|
346
|
+
// Rotated only on a real auth flip: see the call sites in `init`.
|
|
347
|
+
private saltUserId: string | null = null;
|
|
348
|
+
private featureFlags!: FeatureFlagModule<S>;
|
|
349
|
+
private appReleases!: AppReleaseModule<S>;
|
|
350
|
+
// Query hashes already preloaded this session — skip redundant one-shot
|
|
351
|
+
// fetches when the same preload query is requested again (e.g. a list row
|
|
352
|
+
// re-rendering). Cleared on process/session end only.
|
|
353
|
+
private preloadedHashes = new Set<number>();
|
|
354
|
+
// In-flight background init chains (instant-hydrate + register enqueue) keyed
|
|
355
|
+
// by registration hash. Concurrent mounts of the same query reuse the one
|
|
356
|
+
// chain instead of double-hydrating and double-enqueuing `register`.
|
|
357
|
+
// Sequential re-mounts intentionally start a fresh chain — the unconditional
|
|
358
|
+
// `register` re-enqueue is what freshens a warm preload on use.
|
|
359
|
+
private pendingQueryInits = new Map<string, Promise<void>>();
|
|
360
|
+
|
|
361
|
+
private logger: ReturnType<typeof createLogger>;
|
|
362
|
+
public auth: AuthService<S>;
|
|
363
|
+
public streamProcessor: StreamProcessorService;
|
|
364
|
+
|
|
365
|
+
// Shared-tabs: non-null when the capability gate passed at construction.
|
|
366
|
+
// The coordinator owns role state; `sharedActive` flips false if the broker
|
|
367
|
+
// rejects/times out and this tab permanently falls back to solo.
|
|
368
|
+
private tabsCoordinator: TabsCoordinator | null = null;
|
|
369
|
+
private sharedActive = false;
|
|
370
|
+
|
|
371
|
+
/** Current shared-tabs role, or null when the feature is off/fell back. */
|
|
372
|
+
get tabRole(): TabRole | null {
|
|
373
|
+
return this.sharedActive ? this.tabsCoordinator!.role : null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
get remoteClient() {
|
|
377
|
+
return this.remote.getClient();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
get localClient() {
|
|
381
|
+
return this.local.getClient();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
get pendingMutationCount(): number {
|
|
385
|
+
return this.sync.pendingMutationCount;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Number of times the initial list_ref LIVE subscription retried on
|
|
389
|
+
* the most recent `setCurrentUserId` call. 0 when the SSP's
|
|
390
|
+
* pre-emptive user-table creation got there first; >0 when LIVE
|
|
391
|
+
* registration hit a "table not found" race. Exposed so the e2e
|
|
392
|
+
* suite can guard the pre-emptive path against regression. */
|
|
393
|
+
get liveRetryCount(): number {
|
|
394
|
+
return this.sync.liveRetryCount;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
subscribeToPendingMutations(cb: (count: number) => void): () => void {
|
|
398
|
+
return this.sync.subscribeToPendingMutations(cb);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Current sync-health snapshot. See {@link Sp00kyConfig.syncHealth}. */
|
|
402
|
+
get syncHealth(): SyncHealth {
|
|
403
|
+
return this.sync.syncHealth;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Observe sync health. Fires immediately with the current status and again
|
|
408
|
+
* on every healthy↔degraded transition. Returns an unsubscribe.
|
|
409
|
+
*/
|
|
410
|
+
subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
|
|
411
|
+
return this.sync.subscribeToSyncHealth(cb);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Durability of the local cache. See {@link StorageHealth}. `'unknown'` for
|
|
415
|
+
* engines that don't report it. */
|
|
416
|
+
get storageHealth(): StorageHealth {
|
|
417
|
+
return this.local.storageHealth ?? UNKNOWN_STORAGE_HEALTH;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Observe local-store durability. Fires immediately with the current snapshot
|
|
422
|
+
* and again on every change (at most once per bucket open in practice).
|
|
423
|
+
* Returns an unsubscribe.
|
|
424
|
+
*/
|
|
425
|
+
subscribeToStorageHealth(cb: (health: StorageHealth) => void): () => void {
|
|
426
|
+
if (this.local.subscribeToStorageHealth) {
|
|
427
|
+
return this.local.subscribeToStorageHealth(cb);
|
|
428
|
+
}
|
|
429
|
+
cb(UNKNOWN_STORAGE_HEALTH);
|
|
430
|
+
return () => {};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
constructor(private config: Sp00kyConfig<S>) {
|
|
434
|
+
const logger = createLogger(config.logLevel ?? 'info', config.otelTransmit);
|
|
435
|
+
this.logger = logger.child({ service: 'Sp00kyClient' });
|
|
436
|
+
|
|
437
|
+
this.logger.info(
|
|
438
|
+
{
|
|
439
|
+
config: { ...config, schema: '[SchemaStructure]' },
|
|
440
|
+
Category: 'sp00ky-client::Sp00kyClient::constructor',
|
|
441
|
+
},
|
|
442
|
+
'Sp00kyClient initialized'
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
// Preload the loro CRDT engine at startup (fetches the chunk on page load)
|
|
446
|
+
// so the first `openCrdtField` doesn't block on a network round-trip. Left
|
|
447
|
+
// off, loro is never loaded unless a CRDT field is explicitly opened.
|
|
448
|
+
if (config.crdt) void preloadLoro();
|
|
449
|
+
|
|
450
|
+
// The default ('surrealdb') engine is a SurrealCacheEngine — a drop-in
|
|
451
|
+
// subclass of LocalDatabaseService that adds the engine-neutral verb surface
|
|
452
|
+
// with zero behavior change. Alternate engines (e.g. 'sqlite') require the
|
|
453
|
+
// raw-SurrealQL call-site migration before they can back `this.local`.
|
|
454
|
+
const tabsSupport = detectSharedTabsSupport(this.config);
|
|
455
|
+
this.local = createLocalEngine(this.config.localEngine, this.config.database, logger, {
|
|
456
|
+
shared: tabsSupport.supported,
|
|
457
|
+
});
|
|
458
|
+
this.remote = new RemoteDatabaseService(this.config.database, logger);
|
|
459
|
+
// Owns socket liveness for the life of the client: re-opens the connection
|
|
460
|
+
// when the SDK's own reconnect gives up, and heartbeats to catch a socket
|
|
461
|
+
// that died without ever firing a `close` event.
|
|
462
|
+
this.connectionSupervisor = new ConnectionSupervisor(this.remote, logger);
|
|
463
|
+
|
|
464
|
+
// Durable cache for bucket file bytes. Reads go through a raw (uncached)
|
|
465
|
+
// handle so the cache's own L2 fetch can't recurse back into itself. The
|
|
466
|
+
// namespace is provisional: `init()` rebinds it to the boot bucket and
|
|
467
|
+
// reconciles against what is actually on disk.
|
|
468
|
+
this.blobs = createBlobCache({
|
|
469
|
+
local: this.local,
|
|
470
|
+
namespace: ANON_USER_ID,
|
|
471
|
+
logger,
|
|
472
|
+
fetchRemote: async (key) =>
|
|
473
|
+
bucketContentToBlob(await this.rawBucket(key.bucket).get(key.path)),
|
|
474
|
+
headRemote: (key) => this.rawBucket(key.bucket).head(key.path),
|
|
475
|
+
maxBytes: this.config.blobCache?.maxBytes,
|
|
476
|
+
// Opting out keeps the in-tab dedupe and object-URL refcounting that
|
|
477
|
+
// existed before this cache, and persists nothing.
|
|
478
|
+
store:
|
|
479
|
+
this.config.blobCache?.enabled === false ? new MemoryBlobStore(ANON_USER_ID) : undefined,
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
if (config.persistenceClient === 'surrealdb') {
|
|
483
|
+
this.persistenceClient = new SurrealDBPersistenceClient(this.local, logger);
|
|
484
|
+
} else if (config.persistenceClient === 'localstorage' || !config.persistenceClient) {
|
|
485
|
+
this.persistenceClient = new LocalStoragePersistenceClient(logger);
|
|
486
|
+
} else {
|
|
487
|
+
this.persistenceClient = config.persistenceClient;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
this.persistenceClient = new ResilientPersistenceClient(this.persistenceClient, logger);
|
|
491
|
+
|
|
492
|
+
this.streamProcessor = new StreamProcessorService(
|
|
493
|
+
new EventSystem(['stream_update']),
|
|
494
|
+
this.local,
|
|
495
|
+
logger
|
|
496
|
+
);
|
|
497
|
+
// Circuit snapshots are checkpointed, never per-ingest, and on by default
|
|
498
|
+
// where the store can hold them (the OPFS SQLite engine). See
|
|
499
|
+
// `persistCircuit` / `circuitProjection` in types.ts.
|
|
500
|
+
this.streamProcessor.configureCircuitPersistence(
|
|
501
|
+
config.persistCircuit ?? config.localEngine === 'sqlite',
|
|
502
|
+
config.circuitCheckpointMs
|
|
503
|
+
);
|
|
504
|
+
this.streamProcessor.configureProjection(config.circuitProjection ?? true);
|
|
505
|
+
this.migrator = new LocalMigrator(this.local, logger);
|
|
506
|
+
|
|
507
|
+
this.cache = new CacheModule(
|
|
508
|
+
this.local,
|
|
509
|
+
this.streamProcessor,
|
|
510
|
+
(update) => {
|
|
511
|
+
// Direct callback from cache to data module
|
|
512
|
+
this.dataModule.onStreamUpdate(update);
|
|
513
|
+
},
|
|
514
|
+
logger
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
// Initialize CRDT Manager. `local` is used to read the initial
|
|
518
|
+
// `_00_crdt` snapshot when a field opens AND to mirror every local
|
|
519
|
+
// edit (so reload/offline see the freshest state); `remote` is used
|
|
520
|
+
// for the debounced outgoing UPSERTs and the parent-table LIVE feed.
|
|
521
|
+
// The debounce window is configurable via `crdtDebounceMs`.
|
|
522
|
+
this.crdtManager = new CrdtManager(
|
|
523
|
+
this.config.schema,
|
|
524
|
+
this.local,
|
|
525
|
+
this.remote,
|
|
526
|
+
logger,
|
|
527
|
+
config.crdtDebounceMs ?? 500
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
this.dataModule = new DataModule(
|
|
531
|
+
this.cache,
|
|
532
|
+
this.local,
|
|
533
|
+
this.config.schema,
|
|
534
|
+
logger,
|
|
535
|
+
this.config.streamDebounceTime
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
// Initialize Auth
|
|
539
|
+
this.auth = new AuthService(this.config.schema, this.remote, this.persistenceClient, logger);
|
|
540
|
+
|
|
541
|
+
// Initialize Sync
|
|
542
|
+
this.sync = new Sp00kySync(
|
|
543
|
+
this.local,
|
|
544
|
+
this.remote,
|
|
545
|
+
this.cache,
|
|
546
|
+
this.dataModule,
|
|
547
|
+
this.config.schema,
|
|
548
|
+
this.logger,
|
|
549
|
+
{
|
|
550
|
+
refSyncIntervalMs: this.config.refSyncIntervalMs,
|
|
551
|
+
anonymousLiveQueries: this.config.enableAnonymousLiveQueries,
|
|
552
|
+
// `syncHealth: false` (or `{ degradeAfterConsecutiveFailures: 0 }`)
|
|
553
|
+
// disables degraded reporting; otherwise default to 3.
|
|
554
|
+
degradeAfterConsecutiveFailures:
|
|
555
|
+
this.config.syncHealth === false
|
|
556
|
+
? 0
|
|
557
|
+
: (this.config.syncHealth?.degradeAfterConsecutiveFailures ?? 3),
|
|
558
|
+
pushTimeoutMs: this.config.pushTimeoutMs,
|
|
559
|
+
downTimeoutMs: this.config.downTimeoutMs,
|
|
560
|
+
// Read-only: sync mirrors the transport state into `SyncHealth`.
|
|
561
|
+
connectionSupervisor: this.connectionSupervisor,
|
|
562
|
+
}
|
|
563
|
+
);
|
|
564
|
+
this.sync.setPrimeGate(() => this.streamProcessor.whenPrimed());
|
|
565
|
+
|
|
566
|
+
// Initialize feature flags. Reuses the down-queue to register SSP plans
|
|
567
|
+
// on `_00_user_feature` and the auth subscription to re-register handles
|
|
568
|
+
// when the signed-in user changes.
|
|
569
|
+
this.featureFlags = new FeatureFlagModule({
|
|
570
|
+
dataModule: this.dataModule,
|
|
571
|
+
sync: this.sync,
|
|
572
|
+
auth: this.auth,
|
|
573
|
+
logger,
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// App release announcements (world-readable `_00_app_release`, written by
|
|
577
|
+
// spky deploy/release). Same shared-live-query design as feature flags.
|
|
578
|
+
this.appReleases = new AppReleaseModule({
|
|
579
|
+
dataModule: this.dataModule,
|
|
580
|
+
sync: this.sync,
|
|
581
|
+
auth: this.auth,
|
|
582
|
+
logger,
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// Initialize DevTools
|
|
586
|
+
this.devTools = new DevToolsService(
|
|
587
|
+
this.local,
|
|
588
|
+
this.remote,
|
|
589
|
+
logger,
|
|
590
|
+
this.config.schema,
|
|
591
|
+
this.auth,
|
|
592
|
+
this.dataModule
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
// Let the DevTools Access tab read and write local flag overrides. Done
|
|
596
|
+
// here rather than via the constructor because FeatureFlagModule is built
|
|
597
|
+
// above and DevToolsService takes its deps positionally.
|
|
598
|
+
this.devTools.setFeatureFlagOverrides(this.featureFlags);
|
|
599
|
+
|
|
600
|
+
// Register DevTools as a receiver for stream updates
|
|
601
|
+
this.streamProcessor.addReceiver(this.devTools);
|
|
602
|
+
|
|
603
|
+
// Wire up callbacks instead of events
|
|
604
|
+
this.setupCallbacks();
|
|
605
|
+
|
|
606
|
+
// Shared-tabs: construct the coordinator last so its hooks can close over
|
|
607
|
+
// every module. Nothing starts until init() calls coordinator.start().
|
|
608
|
+
if (tabsSupport.supported) {
|
|
609
|
+
this.tabsCoordinator = this.buildTabsCoordinator();
|
|
610
|
+
} else if (this.config.sharedTabs) {
|
|
611
|
+
this.logger.info(
|
|
612
|
+
{
|
|
613
|
+
reason: (tabsSupport as { reason: string }).reason,
|
|
614
|
+
Category: 'sp00ky-client::Sp00kyClient::tabs',
|
|
615
|
+
},
|
|
616
|
+
'sharedTabs requested but unsupported here; running solo'
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
// Report shared-tabs state whenever the feature was REQUESTED, active or
|
|
620
|
+
// not: "asked to share one store, running alone because X" is exactly the
|
|
621
|
+
// thing you need to see in the panel. Apps that never set the flag report
|
|
622
|
+
// null and the panel shows no section at all.
|
|
623
|
+
if (this.config.sharedTabs) {
|
|
624
|
+
const unsupportedReason = tabsSupport.supported
|
|
625
|
+
? undefined
|
|
626
|
+
: (tabsSupport as { reason: string }).reason;
|
|
627
|
+
this.devTools.setTabsInfoProvider(() => {
|
|
628
|
+
const c = this.tabsCoordinator;
|
|
629
|
+
if (!this.sharedActive || !c) {
|
|
630
|
+
return { active: false, reason: unsupportedReason ?? 'fell-back' };
|
|
631
|
+
}
|
|
632
|
+
const hub = c.syncHub;
|
|
633
|
+
return {
|
|
634
|
+
active: true,
|
|
635
|
+
role: c.role,
|
|
636
|
+
tabId: c.tabId,
|
|
637
|
+
leadershipId: c.leadershipId,
|
|
638
|
+
leaderTabId: c.role === 'leader' ? c.tabId : c.leaderTabId,
|
|
639
|
+
...(hub ? { followers: hub.followerCount, relayedBatches: hub.relayedBatches } : {}),
|
|
640
|
+
};
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
this.devTools.setBlobInfoProvider(() => this.blobs.stats());
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** The shared-tabs role machinery, wired to this client's modules. */
|
|
647
|
+
private buildTabsCoordinator(): TabsCoordinator {
|
|
648
|
+
const engine = this.local as SqliteCacheEngine;
|
|
649
|
+
const tabId =
|
|
650
|
+
typeof crypto !== 'undefined' && crypto.randomUUID
|
|
651
|
+
? crypto.randomUUID()
|
|
652
|
+
: `tab_${Math.random().toString(36).slice(2)}`;
|
|
653
|
+
const hooks: CoordinatorHooks = {
|
|
654
|
+
adoptOwner: (bucketId, opts) =>
|
|
655
|
+
engine.adoptOwner(bucketId, {
|
|
656
|
+
workerLockName: opts.workerLockName,
|
|
657
|
+
allowMemoryFallback: opts.allowMemoryFallback,
|
|
658
|
+
resumeHeld: opts.resumeHeld,
|
|
659
|
+
}),
|
|
660
|
+
adoptAttached: (dbPort, snapshot) =>
|
|
661
|
+
engine.adoptAttached(dbPort, snapshot, (reason) => engine.onLeaderLost(reason)),
|
|
662
|
+
releaseOwnership: () => engine.releaseOwnership(),
|
|
663
|
+
onLeaderLost: (reason) => engine.onLeaderLost(reason),
|
|
664
|
+
exposeClientPort: (clientId, port) => engine.exposeClientPort(clientId, port),
|
|
665
|
+
removeClientPort: (clientId) => engine.removeClientPort(clientId),
|
|
666
|
+
becomeSyncLeader: (hub) => {
|
|
667
|
+
this.streamProcessor.setPersistenceEnabled(true);
|
|
668
|
+
this.cache.setIngestRelay((tuples) => hub.relayIngest(tuples));
|
|
669
|
+
this.sync.setTabContext('leader', tabId);
|
|
670
|
+
this.dataModule.setTabId(tabId);
|
|
671
|
+
this.sync.promoteToLeader(hub);
|
|
672
|
+
},
|
|
673
|
+
resumeSyncLeaderDuties: () => this.sync.resumeLeaderDuties(),
|
|
674
|
+
becomeSyncFollower: (forwarder) => {
|
|
675
|
+
this.streamProcessor.setPersistenceEnabled(false);
|
|
676
|
+
// A follower's own mutations go to the leader, which ingests them and
|
|
677
|
+
// fans them out to the other followers: one hop, no server round-trip.
|
|
678
|
+
// Only the mutation path relays; this tab's sync fetches are the
|
|
679
|
+
// leader's data coming back and must not be re-broadcast.
|
|
680
|
+
this.cache.setIngestRelay((tuples) => forwarder.ingest(tuples), {
|
|
681
|
+
localWritesOnly: true,
|
|
682
|
+
});
|
|
683
|
+
this.sync.setTabContext('follower', tabId);
|
|
684
|
+
this.dataModule.setTabId(tabId);
|
|
685
|
+
// Installs the whole syncPort handler: ingest-relay, list_ref relay,
|
|
686
|
+
// settled writes, rollbacks.
|
|
687
|
+
this.sync.demoteToFollower(forwarder);
|
|
688
|
+
},
|
|
689
|
+
becomeSyncSolo: () => {
|
|
690
|
+
this.streamProcessor.setPersistenceEnabled(true);
|
|
691
|
+
this.cache.setIngestRelay(null);
|
|
692
|
+
this.sync.setTabContext('solo', tabId);
|
|
693
|
+
},
|
|
694
|
+
currentStorageHealth: () =>
|
|
695
|
+
this.local.storageHealth ?? { status: 'unknown', fallback: false },
|
|
696
|
+
};
|
|
697
|
+
return new TabsCoordinator({
|
|
698
|
+
tabId,
|
|
699
|
+
fingerprint: computeTabsFingerprint({
|
|
700
|
+
coreVersion:
|
|
701
|
+
typeof __SP00KY_CORE_VERSION__ !== 'undefined' ? __SP00KY_CORE_VERSION__ : 'unknown',
|
|
702
|
+
schemaHash: hash53(this.config.schemaSurql),
|
|
703
|
+
endpoint: this.config.database.endpoint ?? '',
|
|
704
|
+
namespace: this.config.database.namespace,
|
|
705
|
+
database: this.config.database.database,
|
|
706
|
+
}),
|
|
707
|
+
hooks,
|
|
708
|
+
logger: this.logger,
|
|
709
|
+
onLeaderPageHide: () => {
|
|
710
|
+
void engine.shutdownOwnedWorker();
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Setup direct callbacks instead of event subscriptions
|
|
717
|
+
*/
|
|
718
|
+
private setupCallbacks() {
|
|
719
|
+
// Surface query fetch-status changes (idle/fetching) in DevTools. Logs a
|
|
720
|
+
// discrete event and triggers a state push so the active-queries panel
|
|
721
|
+
// reflects the flip immediately.
|
|
722
|
+
this.dataModule.onQueryStatusChange = (queryHash, status) => {
|
|
723
|
+
this.devTools.logEvent('QUERY_STATUS_CHANGED', { queryHash, status });
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
// Keep an actively-watched query's remote `_00_query.lastActiveAt` fresh so
|
|
727
|
+
// the server TTL sweep doesn't expire it out from under live subscribers.
|
|
728
|
+
// DataModule fires this only while the query still has ≥1 subscriber.
|
|
729
|
+
this.dataModule.onHeartbeat = (queryHash) => {
|
|
730
|
+
void this.sync.heartbeatQuery(queryHash).catch((err) => {
|
|
731
|
+
this.logger.warn(
|
|
732
|
+
{ err, queryHash, Category: 'sp00ky-client::Sp00kyClient::onHeartbeat' },
|
|
733
|
+
'TTL heartbeat failed'
|
|
734
|
+
);
|
|
735
|
+
});
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
// Eager teardown of an opt-in deregistered query: enqueue a `cleanup`
|
|
739
|
+
// down-event so it's serialized after any in-flight register/sync for the
|
|
740
|
+
// same query (avoids out-of-order delete-before-create).
|
|
741
|
+
this.dataModule.onDeregister = (queryHash) => {
|
|
742
|
+
this.sync.enqueueDownEvent({ type: 'cleanup', payload: { hash: queryHash } });
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
// Mutation callback for sync
|
|
746
|
+
this.dataModule.onMutation((mutations: UpEvent[]) => {
|
|
747
|
+
// Notify DevTools
|
|
748
|
+
this.devTools.onMutation(mutations);
|
|
749
|
+
|
|
750
|
+
// Enqueue in Sync
|
|
751
|
+
if (mutations.length > 0) {
|
|
752
|
+
this.sync.enqueueMutation(mutations);
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// Sync events for incoming updates
|
|
757
|
+
this.sync.events.subscribe('SYNC_QUERY_UPDATED', (event: any) => {
|
|
758
|
+
this.devTools.logEvent('SYNC_QUERY_UPDATED', event.payload);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
// Hand list_ref-driven row ingests to the CrdtManager so CRDT body
|
|
762
|
+
// / cursor updates reach the receiver even when the cross-session
|
|
763
|
+
// LIVE on the parent table is filtered out by the SurrealDB
|
|
764
|
+
// permission-LIVE gap. Same-user clients receive these rows via
|
|
765
|
+
// CrdtManager's own `LIVE SELECT * FROM <table>`; this hook is the
|
|
766
|
+
// redundant path that fires when only the list_ref bumped.
|
|
767
|
+
this.sync.engineEvents.subscribe('SYNC_REMOTE_DATA_INGESTED', (event: any) => {
|
|
768
|
+
try {
|
|
769
|
+
const records: Array<Record<string, any>> = event.payload?.records ?? [];
|
|
770
|
+
for (const row of records) {
|
|
771
|
+
const id = row?.id;
|
|
772
|
+
const table =
|
|
773
|
+
id && typeof id === 'object' && id.table !== undefined ? String(id.table) : undefined;
|
|
774
|
+
if (!table) continue;
|
|
775
|
+
this.crdtManager.applyRow(table, row);
|
|
776
|
+
}
|
|
777
|
+
} catch (err) {
|
|
778
|
+
this.logger.debug(
|
|
779
|
+
{ err, Category: 'sp00ky-client::engineEvents::ingested' },
|
|
780
|
+
'applyRow forwarding from sync ingest failed'
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
// Database events for DevTools
|
|
786
|
+
this.local.getEvents().subscribe('DATABASE_LOCAL_QUERY', (event: any) => {
|
|
787
|
+
this.devTools.logEvent('LOCAL_QUERY', event.payload);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
this.remote.getEvents().subscribe('DATABASE_REMOTE_QUERY', (event: any) => {
|
|
791
|
+
this.devTools.logEvent('REMOTE_QUERY', event.payload);
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async init() {
|
|
796
|
+
this.logger.info(
|
|
797
|
+
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
798
|
+
'Sp00kyClient initialization started'
|
|
799
|
+
);
|
|
800
|
+
try {
|
|
801
|
+
// Open the bucket the last session used (per-user local stores). If auth
|
|
802
|
+
// resolves to a different user below, the auth callback switches buckets.
|
|
803
|
+
const bootBucket = readBootBucketHint() ?? ANON_USER_ID;
|
|
804
|
+
if (this.tabsCoordinator) {
|
|
805
|
+
// Shared-tabs: the broker assigns this tab's role; the coordinator's
|
|
806
|
+
// hooks open the store (leader) or attach to the leader's (follower).
|
|
807
|
+
// Any failure here (no SharedWorker start, election timeout, rejected
|
|
808
|
+
// fingerprint) falls back to plain solo boot: exactly the flag-off
|
|
809
|
+
// path, including the second-tab memory fallback + its warning.
|
|
810
|
+
// A role can still land AFTER start() gave up (the election that timed
|
|
811
|
+
// out here keeps running), and when it does this tab really is sharing
|
|
812
|
+
// the leader's store. Track every transition so the reported state is
|
|
813
|
+
// the current one instead of frozen at whatever boot saw.
|
|
814
|
+
this.tabsCoordinator.onRoleChange((role) => {
|
|
815
|
+
this.sharedActive = role !== 'solo';
|
|
816
|
+
// Surface the transition in DevTools. Reconstructing a failover from
|
|
817
|
+
// the `database.tabs` snapshot alone is guesswork after the fact; a
|
|
818
|
+
// discrete event gives `get_events` the leader handover directly.
|
|
819
|
+
this.devTools.logEvent('TABS_ROLE_CHANGED', {
|
|
820
|
+
role,
|
|
821
|
+
tabId: this.tabsCoordinator?.tabId,
|
|
822
|
+
leadershipId: this.tabsCoordinator?.leadershipId,
|
|
823
|
+
leaderTabId: this.tabsCoordinator?.leaderTabId,
|
|
824
|
+
promotionMs: this.tabsCoordinator?.lastPromotionMs,
|
|
825
|
+
});
|
|
826
|
+
});
|
|
827
|
+
try {
|
|
828
|
+
const role = await this.tabsCoordinator.start(bootBucket);
|
|
829
|
+
this.sharedActive = true;
|
|
830
|
+
this.logger.info(
|
|
831
|
+
{ role, bootBucket, Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
832
|
+
'Shared-tabs role assigned'
|
|
833
|
+
);
|
|
834
|
+
} catch (e) {
|
|
835
|
+
this.logger.warn(
|
|
836
|
+
{ err: e, Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
837
|
+
'Shared-tabs unavailable; booting solo'
|
|
838
|
+
);
|
|
839
|
+
this.sharedActive = false;
|
|
840
|
+
await this.local.connect(bootBucket);
|
|
841
|
+
}
|
|
842
|
+
} else {
|
|
843
|
+
await this.local.connect(bootBucket);
|
|
844
|
+
}
|
|
845
|
+
this.logger.debug(
|
|
846
|
+
{ bootBucket, Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
847
|
+
'Local database connected'
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
// Schemaless local engines (SQLite) create tables lazily and need no
|
|
851
|
+
// SurrealQL DDL provisioning.
|
|
852
|
+
if (this.local.usesSurqlSchema) {
|
|
853
|
+
await this.migrator.provision(this.config.schemaSurql);
|
|
854
|
+
this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Schema provisioned');
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Warm the blob cache in the background. Deliberately NOT awaited, and
|
|
858
|
+
// deliberately after `remote.connect()`: this walks the OPFS directory to
|
|
859
|
+
// rebuild the manifest, and awaiting it ahead of the socket delayed the
|
|
860
|
+
// connect (and the connection supervisor with it) for no benefit. Reads
|
|
861
|
+
// await `BlobCache.ready` internally, so a bucket read that lands mid-walk
|
|
862
|
+
// still sees a reconciled manifest. Best-effort: a cold blob cache is a
|
|
863
|
+
// slow first image, not a broken client.
|
|
864
|
+
void (async () => {
|
|
865
|
+
try {
|
|
866
|
+
this.blobs.setMaxBytes(await resolveBlobBudget(this.config.blobCache?.maxBytes));
|
|
867
|
+
await this.blobs.start(bootBucket);
|
|
868
|
+
} catch (e) {
|
|
869
|
+
this.logger.warn(
|
|
870
|
+
{ err: e, Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
871
|
+
'Blob cache failed to start; bucket files will not be cached locally'
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
})();
|
|
875
|
+
|
|
876
|
+
await this.streamProcessor.init();
|
|
877
|
+
// Seed table `select` permissions from the schema before any query is
|
|
878
|
+
// registered — otherwise the SSP default-denies every non-`_00_` table.
|
|
879
|
+
this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
|
|
880
|
+
// Fill the circuit from the local store in the background (snapshot +
|
|
881
|
+
// reconcile, or every cached row). Not awaited: first paint reads the
|
|
882
|
+
// store directly; sync waits on `whenPrimed` before its first diff.
|
|
883
|
+
void this.primeCircuit();
|
|
884
|
+
this.logger.debug(
|
|
885
|
+
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
886
|
+
'StreamProcessor initialized'
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
// Restore the session from the cached JWT, with no network. This is what
|
|
891
|
+
// lets the rest of the boot - and the app on top of it - proceed as a
|
|
892
|
+
// signed-in user before a socket exists. `initRemote()` verifies the
|
|
893
|
+
// token afterwards and signs out for real if the server rejects it.
|
|
894
|
+
const restoredUserId = await this.auth.restoreSessionFromToken();
|
|
895
|
+
|
|
896
|
+
// Salt for query-id hashing, minted locally (see `mintSessionSalt`). It
|
|
897
|
+
// is stable for the life of this client: the salt keys `_00_query` rows
|
|
898
|
+
// and local cache entries, so rotating it would invalidate every query
|
|
899
|
+
// hash and force a full re-register. Auth flips are the one case that
|
|
900
|
+
// must rotate it - a sign-in is a different principal.
|
|
901
|
+
const sessionId = this.mintSessionSalt();
|
|
902
|
+
this.saltUserId = restoredUserId;
|
|
903
|
+
await this.dataModule.init(sessionId);
|
|
904
|
+
this.crdtManager.setSessionId(sessionId);
|
|
905
|
+
|
|
906
|
+
// Route queries and satisfy `$auth`-gated permission predicates from the
|
|
907
|
+
// restored identity, BEFORE anything can register. Without this a query
|
|
908
|
+
// registering pre-verification would target the wrong
|
|
909
|
+
// `_00_query_user_<id>` table and register a permission-dead SSP view.
|
|
910
|
+
if (restoredUserId) {
|
|
911
|
+
this.dataModule.setCurrentUserId(restoredUserId);
|
|
912
|
+
this.streamProcessor.setSessionAuth(this.sessionAuthId(), this.auth.access);
|
|
913
|
+
}
|
|
914
|
+
this.logger.debug(
|
|
915
|
+
{ sessionId, Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
916
|
+
'DataModule initialized'
|
|
917
|
+
);
|
|
918
|
+
|
|
919
|
+
// Refresh the salt whenever auth state flips (sign-in, sign-out).
|
|
920
|
+
// session::id() changes per WebSocket session, and a sign-in spawns
|
|
921
|
+
// a new authenticated session, so the salt must follow. Also
|
|
922
|
+
// forward the user id into `DataModule` and `Sp00kySync` so they
|
|
923
|
+
// can route to per-user `_00_query_user_<id>` /
|
|
924
|
+
// `_00_list_ref_user_<id>` tables in `RefMode.Dedicated` — the
|
|
925
|
+
// LIVE subscription on `_00_list_ref_user_<id>` is restarted
|
|
926
|
+
// under the new auth context inside `Sp00kySync.setCurrentUserId`
|
|
927
|
+
// since SurrealDB binds the LIVE permission at registration time.
|
|
928
|
+
//
|
|
929
|
+
// Sync prefix BEFORE the first `await`: setting `currentUserId`
|
|
930
|
+
// synchronously here is critical because the AuthProvider's own
|
|
931
|
+
// subscribe callback runs right after ours and immediately enables
|
|
932
|
+
// queries that depend on the user id. Any `await` before
|
|
933
|
+
// `setCurrentUserId` would let those queries register against the
|
|
934
|
+
// stale (null) user id and hit the wrong `_00_query[_user_*]`
|
|
935
|
+
// table.
|
|
936
|
+
this.auth.subscribe(async (userId) => {
|
|
937
|
+
this.dataModule.setCurrentUserId(userId);
|
|
938
|
+
// Mirror the server's `fn::query::register` auth injection for the
|
|
939
|
+
// in-browser SSP: feed the current user's full record id + access
|
|
940
|
+
// method so `$auth`-gated table permissions (e.g. `thread`) resolve
|
|
941
|
+
// locally instead of being rejected. Set synchronously BEFORE the
|
|
942
|
+
// first `await` (like `setCurrentUserId` above) so queries that
|
|
943
|
+
// re-register on this auth flip see the fresh context, not a stale one.
|
|
944
|
+
this.streamProcessor.setSessionAuth(this.sessionAuthId(), this.auth.access);
|
|
945
|
+
// Record the target bucket synchronously (still before the first
|
|
946
|
+
// `await`) so a reload mid-switch boots straight into the right store.
|
|
947
|
+
writeBootBucketHint(bucketIdForUser(userId));
|
|
948
|
+
// FIRST await: swap the local store to this user's bucket. Serialized
|
|
949
|
+
// + latest-target-wins internally; no-op when the bucket already
|
|
950
|
+
// matches (the boot-hint warm path).
|
|
951
|
+
await this.ensureLocalBucket(userId);
|
|
952
|
+
// Only rotate the salt when the PRINCIPAL actually changed: a sign-in or
|
|
953
|
+
// sign-out is a different principal and must not keep the old
|
|
954
|
+
// principal's query ids, but the first fire of this callback after boot
|
|
955
|
+
// carries the same user the salt was already minted for, and rotating
|
|
956
|
+
// there would invalidate every query hash for no change in value.
|
|
957
|
+
// Canonicalize with the SAME encoding the restore path used, NOT
|
|
958
|
+
// String(): after background verification this callback carries a
|
|
959
|
+
// RecordId, while a session restored from the token carries the plain
|
|
960
|
+
// "table:id" string. Comparing their raw stringifications made every
|
|
961
|
+
// warm boot look like a principal change, which rotated the salt and
|
|
962
|
+
// re-registered every mounted query - the list painted from cache and
|
|
963
|
+
// then emptied a second later.
|
|
964
|
+
const saltFor = this.sessionAuthId();
|
|
965
|
+
if (saltFor !== this.saltUserId) {
|
|
966
|
+
this.saltUserId = saltFor;
|
|
967
|
+
const next = this.mintSessionSalt();
|
|
968
|
+
this.dataModule.setSessionId(next);
|
|
969
|
+
this.crdtManager.setSessionId(next);
|
|
970
|
+
}
|
|
971
|
+
try {
|
|
972
|
+
await this.sync.setCurrentUserId(userId);
|
|
973
|
+
} catch (e) {
|
|
974
|
+
this.logger.error(
|
|
975
|
+
{ error: e, Category: 'sp00ky-client::Sp00kyClient::authChange' },
|
|
976
|
+
'sync.setCurrentUserId failed'
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
await this.sync.init();
|
|
982
|
+
this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Sync initialized');
|
|
983
|
+
|
|
984
|
+
this.featureFlags.init();
|
|
985
|
+
this.logger.debug(
|
|
986
|
+
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
987
|
+
'FeatureFlagModule initialized'
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
this.appReleases.init();
|
|
991
|
+
this.logger.debug(
|
|
992
|
+
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
993
|
+
'AppReleaseModule initialized'
|
|
994
|
+
);
|
|
995
|
+
|
|
996
|
+
// LOCAL BOOT IS DONE — the client can serve reads. Consumers gate their
|
|
997
|
+
// UI on this resolving, so everything above must stay network-free.
|
|
998
|
+
this.localReady = true;
|
|
999
|
+
this.logger.info(
|
|
1000
|
+
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
1001
|
+
'Sp00kyClient local initialization completed; connecting in the background'
|
|
1002
|
+
);
|
|
1003
|
+
|
|
1004
|
+
// The network half, deliberately NOT awaited. Nothing above needed it:
|
|
1005
|
+
// queries paint from the local store, `sync.init()` tolerates a closed
|
|
1006
|
+
// socket, and the session was restored from the cached token. Awaiting
|
|
1007
|
+
// this was the entire reason a warm reload sat on a loading screen for
|
|
1008
|
+
// seconds over data the browser already had on disk - and the reason an
|
|
1009
|
+
// offline boot never completed at all.
|
|
1010
|
+
void this.initRemote();
|
|
1011
|
+
} catch (e) {
|
|
1012
|
+
this.logger.error(
|
|
1013
|
+
{ error: e, Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
1014
|
+
'Sp00kyClient initialization failed'
|
|
1015
|
+
);
|
|
1016
|
+
throw e;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* The network half of boot: connect, verify the restored session, and let the
|
|
1022
|
+
* sync engine catch up. Runs in the background after `init()` has already
|
|
1023
|
+
* resolved, so nothing here is on the paint path.
|
|
1024
|
+
*
|
|
1025
|
+
* Every step is best-effort. A failure leaves the client in exactly the state
|
|
1026
|
+
* a warm offline boot is in - local reads working, writes queued in the
|
|
1027
|
+
* outbox - and the connection supervisor keeps retrying underneath.
|
|
1028
|
+
*/
|
|
1029
|
+
private async initRemote(): Promise<void> {
|
|
1030
|
+
try {
|
|
1031
|
+
await this.remote.connect();
|
|
1032
|
+
this.logger.debug(
|
|
1033
|
+
{ Category: 'sp00ky-client::Sp00kyClient::initRemote' },
|
|
1034
|
+
'Remote database connected'
|
|
1035
|
+
);
|
|
1036
|
+
} catch (e) {
|
|
1037
|
+
// NOT fatal. This used to throw out of init() and leave the consuming app
|
|
1038
|
+
// on its loading screen forever with no network. The supervisor (started
|
|
1039
|
+
// unconditionally below) owns the retry from here.
|
|
1040
|
+
this.logger.warn(
|
|
1041
|
+
{ err: e, Category: 'sp00ky-client::Sp00kyClient::initRemote' },
|
|
1042
|
+
'Remote connect failed; running from the local store and retrying in the background'
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Started whether or not the first connect succeeded - it is the thing that
|
|
1047
|
+
// revives the socket, so gating it on a successful connect would mean a
|
|
1048
|
+
// boot-time failure never recovered.
|
|
1049
|
+
this.connectionSupervisor.start();
|
|
1050
|
+
|
|
1051
|
+
try {
|
|
1052
|
+
// Verifies the optimistically restored token against the server. On a
|
|
1053
|
+
// rejected token this signs out for real; on an unreachable server it
|
|
1054
|
+
// keeps the cached session (see AuthService.check).
|
|
1055
|
+
await this.auth.init();
|
|
1056
|
+
this.logger.debug(
|
|
1057
|
+
{ Category: 'sp00ky-client::Sp00kyClient::initRemote' },
|
|
1058
|
+
'Auth verified'
|
|
1059
|
+
);
|
|
1060
|
+
} catch (e) {
|
|
1061
|
+
this.logger.warn(
|
|
1062
|
+
{ err: e, Category: 'sp00ky-client::Sp00kyClient::initRemote' },
|
|
1063
|
+
'Auth verification failed; keeping the restored session'
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Serializes bucket switches from rapid auth flips; `pendingBucketTarget`
|
|
1069
|
+
// makes intermediate targets collapse (A→anon→B never opens the anon bucket).
|
|
1070
|
+
private bucketSwitchChain: Promise<void> = Promise.resolve();
|
|
1071
|
+
private pendingBucketTarget: string | null = null;
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Ensure the local store is this user's bucket, switching if needed. Called
|
|
1075
|
+
* from the auth listener on every auth flip; concurrent calls are chained
|
|
1076
|
+
* and superseded intermediates are skipped (latest target wins).
|
|
1077
|
+
*/
|
|
1078
|
+
private ensureLocalBucket(userId: string | null): Promise<void> {
|
|
1079
|
+
const target = bucketIdForUser(userId);
|
|
1080
|
+
this.pendingBucketTarget = target;
|
|
1081
|
+
// Close the query gate SYNCHRONOUSLY the instant a switch is pending — the
|
|
1082
|
+
// AuthProvider's own auth subscriber fires right after this (same tick) and
|
|
1083
|
+
// enables queries, and `doSwitchBucket` only runs a microtask later on the
|
|
1084
|
+
// chain. Without closing the gate here, that query is issued through the
|
|
1085
|
+
// still-open gate and is in-flight on the local wasm engine when
|
|
1086
|
+
// `switchStore` closes the client — which wedges the engine (every
|
|
1087
|
+
// subsequent query, including provisioning, hangs → no view ever registers).
|
|
1088
|
+
// No-op when already on the target bucket.
|
|
1089
|
+
const needsSwitch = this.local.currentBucketId !== target;
|
|
1090
|
+
const release = needsSwitch ? this.local.beginSwitch() : null;
|
|
1091
|
+
this.bucketSwitchChain = this.bucketSwitchChain.then(async () => {
|
|
1092
|
+
// Superseded by a newer flip, or already on target: reopen the gate we
|
|
1093
|
+
// closed above and skip the switch.
|
|
1094
|
+
if (this.pendingBucketTarget !== target || this.local.currentBucketId === target) {
|
|
1095
|
+
release?.();
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
await this.doSwitchBucket(target, release);
|
|
1099
|
+
});
|
|
1100
|
+
// Isolate chain failures per-caller: a failed switch must not poison every
|
|
1101
|
+
// future switch. The caller (auth listener) logs it. Reopen the gate on
|
|
1102
|
+
// failure so the client never gets stuck closed.
|
|
1103
|
+
const result = this.bucketSwitchChain;
|
|
1104
|
+
this.bucketSwitchChain = this.bucketSwitchChain.catch(() => {
|
|
1105
|
+
release?.();
|
|
1106
|
+
});
|
|
1107
|
+
return result;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* The bucket-switch choreography: drain → swap → rebind.
|
|
1112
|
+
*
|
|
1113
|
+
* Drain: sync quiesced (poll/LIVE stopped, in-flight round awaited so its
|
|
1114
|
+
* outbox delete lands in the OLD bucket, debounce timers cancelled),
|
|
1115
|
+
* DataModule timers cleared, CRDT fields closed WITHOUT their final flush
|
|
1116
|
+
* (the remote session already belongs to the next user).
|
|
1117
|
+
*
|
|
1118
|
+
* Swap: gate closes so any local query issued mid-switch (sibling auth
|
|
1119
|
+
* subscribers, FeatureFlagModule) waits and then runs against the NEW
|
|
1120
|
+
* bucket; store swaps open-new-before-close-old; schema provisions
|
|
1121
|
+
* (no-op for a returning bucket); stale `_00_query` rows are wiped (dead
|
|
1122
|
+
* sessionId-salted hashes with stale arrays — record bodies stay warm);
|
|
1123
|
+
* SSP resets to a fresh circuit with re-seeded permissions.
|
|
1124
|
+
*
|
|
1125
|
+
* Rebind: auth token re-persisted (the surrealdb persistence client wrote it
|
|
1126
|
+
* into the OLD bucket's `_00_kv` before this listener ran), active queries
|
|
1127
|
+
* re-homed keeping their hashes, sync resumed on the new bucket's own
|
|
1128
|
+
* outbox, and every query re-registered remotely to refill from the server.
|
|
1129
|
+
*/
|
|
1130
|
+
private async doSwitchBucket(target: string, gateRelease?: (() => void) | null): Promise<void> {
|
|
1131
|
+
this.logger.info(
|
|
1132
|
+
{
|
|
1133
|
+
target,
|
|
1134
|
+
from: this.local.currentBucketId,
|
|
1135
|
+
Category: 'sp00ky-client::Sp00kyClient::doSwitchBucket',
|
|
1136
|
+
},
|
|
1137
|
+
'Switching local bucket'
|
|
1138
|
+
);
|
|
1139
|
+
|
|
1140
|
+
const leavingBucket = this.local.currentBucketId;
|
|
1141
|
+
|
|
1142
|
+
await this.sync.prepareBucketSwitch();
|
|
1143
|
+
this.dataModule.quiesce();
|
|
1144
|
+
this.crdtManager.closeAll({ flush: false });
|
|
1145
|
+
|
|
1146
|
+
// Reuse the gate the caller (`ensureLocalBucket`) closed synchronously; only
|
|
1147
|
+
// open our own if called without one (keeps the gate continuously closed
|
|
1148
|
+
// from the auth flip through the swap — no window for a racing query).
|
|
1149
|
+
const reopen = gateRelease ?? this.local.beginSwitch();
|
|
1150
|
+
try {
|
|
1151
|
+
if (this.sharedActive && this.tabsCoordinator) {
|
|
1152
|
+
// Shared-tabs: a bucket switch is a namespace move. Leaving the old
|
|
1153
|
+
// namespace re-elects it (if this tab led it); joining the new one
|
|
1154
|
+
// assigns a fresh role, whose hooks open or attach the store. The
|
|
1155
|
+
// leader wipe-on-pool-open replaces the DELETE _00_query below, and a
|
|
1156
|
+
// joining follower must NOT wipe: other tabs' rows there are live.
|
|
1157
|
+
try {
|
|
1158
|
+
await this.tabsCoordinator.moveToBucket(target);
|
|
1159
|
+
} catch (e) {
|
|
1160
|
+
this.logger.warn(
|
|
1161
|
+
{ err: e, target, Category: 'sp00ky-client::Sp00kyClient::doSwitchBucket' },
|
|
1162
|
+
'Shared-tabs bucket move failed; switching solo'
|
|
1163
|
+
);
|
|
1164
|
+
this.sharedActive = false;
|
|
1165
|
+
this.sync.setTabContext('solo', null);
|
|
1166
|
+
this.cache.setIngestRelay(null);
|
|
1167
|
+
this.streamProcessor.setPersistenceEnabled(true);
|
|
1168
|
+
await this.local.switchStore(target);
|
|
1169
|
+
await this.local.queryUngated('DELETE _00_query;');
|
|
1170
|
+
}
|
|
1171
|
+
} else {
|
|
1172
|
+
await this.local.switchStore(target);
|
|
1173
|
+
if (this.local.usesSurqlSchema) {
|
|
1174
|
+
await this.migrator.provision(this.config.schemaSurql);
|
|
1175
|
+
}
|
|
1176
|
+
await this.local.queryUngated('DELETE _00_query;');
|
|
1177
|
+
}
|
|
1178
|
+
// Cached bytes are namespaced per local bucket, so the switch is a
|
|
1179
|
+
// repoint, not a wipe: signing back in finds the cache warm. Apps that
|
|
1180
|
+
// want the signed-out user's files gone opt in with `clearOnSignOut`.
|
|
1181
|
+
try {
|
|
1182
|
+
if (
|
|
1183
|
+
this.config.blobCache?.clearOnSignOut &&
|
|
1184
|
+
target === ANON_USER_ID &&
|
|
1185
|
+
leavingBucket !== ANON_USER_ID
|
|
1186
|
+
) {
|
|
1187
|
+
await this.blobs.clear();
|
|
1188
|
+
}
|
|
1189
|
+
await this.blobs.setNamespace(target);
|
|
1190
|
+
} catch (e) {
|
|
1191
|
+
this.logger.warn(
|
|
1192
|
+
{ err: e, target, Category: 'sp00ky-client::Sp00kyClient::doSwitchBucket' },
|
|
1193
|
+
'Blob cache bucket switch failed'
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
await this.streamProcessor.reset();
|
|
1197
|
+
this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
|
|
1198
|
+
this.cache.clearVersionLookups();
|
|
1199
|
+
void this.primeCircuit();
|
|
1200
|
+
// Preload dedup is per-bucket: the `_00_preload` markers + cached rows it
|
|
1201
|
+
// guards live in the local store we just swapped away from. Keeping the
|
|
1202
|
+
// hashes would make `preload()` skip warming the NEW bucket (its store is
|
|
1203
|
+
// empty), so every thread/comment prewarm silently no-ops after login.
|
|
1204
|
+
this.preloadedHashes.clear();
|
|
1205
|
+
} finally {
|
|
1206
|
+
reopen();
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
if (this.auth.token) {
|
|
1210
|
+
try {
|
|
1211
|
+
await this.persistenceClient.set('sp00ky_auth_token', this.auth.token);
|
|
1212
|
+
} catch (e) {
|
|
1213
|
+
this.logger.warn(
|
|
1214
|
+
{ error: e, Category: 'sp00ky-client::Sp00kyClient::doSwitchBucket' },
|
|
1215
|
+
'Failed to re-persist auth token into the new bucket'
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
const hashes = await this.dataModule.rebindAfterBucketSwitch();
|
|
1221
|
+
await this.sync.completeBucketSwitch();
|
|
1222
|
+
for (const hash of hashes) {
|
|
1223
|
+
this.sync.enqueueDownEvent({ type: 'register', payload: { hash } });
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
this.logger.info(
|
|
1227
|
+
{ target, queries: hashes.length, Category: 'sp00ky-client::Sp00kyClient::doSwitchBucket' },
|
|
1228
|
+
'Local bucket switch complete'
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
async close() {
|
|
1233
|
+
// Before anything else: a live supervisor would read the intentional
|
|
1234
|
+
// `remote.close()` below as a failure and immediately reconnect.
|
|
1235
|
+
this.connectionSupervisor.dispose();
|
|
1236
|
+
await this.featureFlags.closeAll();
|
|
1237
|
+
await this.appReleases.closeAll();
|
|
1238
|
+
this.crdtManager.closeAll();
|
|
1239
|
+
this.crdtManager.dispose();
|
|
1240
|
+
// Flush the blob manifest while the local store is still open — the flush
|
|
1241
|
+
// writes `_00_blob` rows through it — and revoke every object URL.
|
|
1242
|
+
await this.blobs.close().catch(() => {});
|
|
1243
|
+
// Last chance to persist the circuit while the store is still ours.
|
|
1244
|
+
await this.streamProcessor.checkpoint('close').catch(() => {});
|
|
1245
|
+
// Leaving the broker first hands leadership to another tab (and releases
|
|
1246
|
+
// the OPFS handles via the worker shutdown) before the store closes.
|
|
1247
|
+
if (this.tabsCoordinator) await this.tabsCoordinator.stop();
|
|
1248
|
+
await this.local.close();
|
|
1249
|
+
await this.remote.close();
|
|
1250
|
+
// Free the wasm circuit explicitly. V8 cannot see wasm-internal bytes, so
|
|
1251
|
+
// relying on the wasm-bindgen FinalizationRegistry leaves the whole store
|
|
1252
|
+
// resident until a GC that may never come, and a client that is recreated
|
|
1253
|
+
// (provider remount, HMR) would stack circuits.
|
|
1254
|
+
this.streamProcessor.dispose();
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* Subscribe to a feature flag for the current user. Returns a
|
|
1259
|
+
* `FeatureFlagHandle` whose `variant()`, `payload()` and `enabled()`
|
|
1260
|
+
* accessors reflect the latest assignment from `_00_user_feature`,
|
|
1261
|
+
* and whose `subscribe(cb)` fires whenever that assignment changes.
|
|
1262
|
+
*
|
|
1263
|
+
* Permissions are enforced by SurrealDB: a client can only ever see
|
|
1264
|
+
* its own row, and cannot create or modify assignments.
|
|
1265
|
+
*/
|
|
1266
|
+
feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle {
|
|
1267
|
+
return this.featureFlags.feature(key, options);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* Force a feature flag to `variant` in THIS browser only; `null` clears it.
|
|
1272
|
+
*
|
|
1273
|
+
* Nothing is sent to the server — the `_00_user_feature` assignment is
|
|
1274
|
+
* untouched, so clearing restores whatever the server says. Persisted to
|
|
1275
|
+
* localStorage, survives reloads, and applies while signed out. Backs the
|
|
1276
|
+
* DevTools Access tab, and is a convenient hook for tests.
|
|
1277
|
+
*
|
|
1278
|
+
* To change a flag for OTHER users you need admin rights (`spky admin add`)
|
|
1279
|
+
* and the DevTools Access tab, or `spky flag`.
|
|
1280
|
+
*/
|
|
1281
|
+
setFeatureOverride(key: string, variant: string | null, payload?: unknown): void {
|
|
1282
|
+
this.featureFlags.setLocalOverride(key, variant, payload);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/** Drop every local feature flag override set via `setFeatureOverride`. */
|
|
1286
|
+
clearFeatureOverrides(): void {
|
|
1287
|
+
this.featureFlags.clearLocalOverrides();
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/** The local feature flag overrides currently in effect, keyed by flag. */
|
|
1291
|
+
getFeatureOverrides(): Record<string, FeatureFlagOverride> {
|
|
1292
|
+
return this.featureFlags.getLocalOverrides();
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Observe the announced release of an app (`_00_app_release:<app>`, written
|
|
1297
|
+
* by `spky deploy` / `spky release`). The handle's `snapshot()` carries the
|
|
1298
|
+
* announced version plus the cache-bust/mandatory flags, and
|
|
1299
|
+
* `updateAvailable(currentVersion)` compares it semver-wise against the
|
|
1300
|
+
* running build. World-readable; writes are root-only.
|
|
1301
|
+
*/
|
|
1302
|
+
appRelease(app: string, options?: AppReleaseOptions): AppReleaseHandle {
|
|
1303
|
+
return this.appReleases.release(app, options);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
authenticate(token: string) {
|
|
1307
|
+
return this.remote.getClient().authenticate(token);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* Open a CRDT field for collaborative editing.
|
|
1312
|
+
* Returns a CrdtField with a LoroDoc that can be bound to any editor.
|
|
1313
|
+
* Also starts a LIVE SELECT on the parent table for real-time sync;
|
|
1314
|
+
* incoming events trigger a subquery fetch of `_00_crdt` / `_00_cursor`.
|
|
1315
|
+
*/
|
|
1316
|
+
async openCrdtField(
|
|
1317
|
+
table: string,
|
|
1318
|
+
recordId: string,
|
|
1319
|
+
field: string,
|
|
1320
|
+
fallbackText?: string
|
|
1321
|
+
): Promise<CrdtField> {
|
|
1322
|
+
return this.crdtManager.open(table, recordId, field, fallbackText);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Close a CRDT field when editing is done.
|
|
1327
|
+
*/
|
|
1328
|
+
closeCrdtField(table: string, recordId: string, field: string): void {
|
|
1329
|
+
this.crdtManager.close(table, recordId, field);
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
deauthenticate() {
|
|
1333
|
+
return this.remote.getClient().invalidate();
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
query<Table extends TableNames<S>>(
|
|
1337
|
+
table: Table,
|
|
1338
|
+
options: QueryOptions<TableModel<GetTable<S, Table>>, false>,
|
|
1339
|
+
ttl: QueryTimeToLive = '10m'
|
|
1340
|
+
): QueryBuilder<S, Table, Sp00kyQueryResultPromise> {
|
|
1341
|
+
return new QueryBuilder<S, Table, Sp00kyQueryResultPromise>(
|
|
1342
|
+
this.config.schema,
|
|
1343
|
+
table,
|
|
1344
|
+
async (q) => ({
|
|
1345
|
+
hash: await this.initQuery(table, q, ttl),
|
|
1346
|
+
}),
|
|
1347
|
+
options
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
private async initQuery<Table extends TableNames<S>>(
|
|
1352
|
+
table: Table,
|
|
1353
|
+
q: InnerQuery<any, any, any>,
|
|
1354
|
+
ttl: QueryTimeToLive
|
|
1355
|
+
) {
|
|
1356
|
+
const tableSchema = this.config.schema.tables.find((t) => t.name === table);
|
|
1357
|
+
if (!tableSchema) {
|
|
1358
|
+
throw new Error(`Table ${table} not found`);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const params = parseQueryParams(tableSchema.columns, q.selectQuery.vars ?? {});
|
|
1362
|
+
const hash = await this.dataModule.query(
|
|
1363
|
+
table,
|
|
1364
|
+
q.selectQuery.query,
|
|
1365
|
+
params,
|
|
1366
|
+
ttl,
|
|
1367
|
+
q.selectQuery.plan
|
|
1368
|
+
);
|
|
1369
|
+
|
|
1370
|
+
// Local-first paint: the hash is returned as soon as the LOCAL registration
|
|
1371
|
+
// above completes — `queryState.records` is already seeded from the local
|
|
1372
|
+
// cache/SSP snapshot, so `useQuery` subscribes and paints from memory with
|
|
1373
|
+
// zero network on the paint path. Instant-hydrate and the `register`
|
|
1374
|
+
// down-event continue in a background chain (hydrate strictly before
|
|
1375
|
+
// enqueue, so a stale one-shot snapshot can never land after the sync's
|
|
1376
|
+
// authoritative `_00_list_ref` overwrite). Concurrent mounts of the same
|
|
1377
|
+
// query share one chain; a sequential re-mount starts a fresh one so its
|
|
1378
|
+
// `register` re-enqueue keeps freshening warm data on use.
|
|
1379
|
+
if (!this.pendingQueryInits.has(hash)) {
|
|
1380
|
+
const chain = this.finishQueryInit(hash, q, params).finally(() => {
|
|
1381
|
+
this.pendingQueryInits.delete(hash);
|
|
1382
|
+
});
|
|
1383
|
+
this.pendingQueryInits.set(hash, chain);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
return hash;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Background tail of {@link initQuery}: instant-hydrate (opt-in via
|
|
1391
|
+
* `config.instantHydrate`, and only when the query is cold) followed by
|
|
1392
|
+
* enqueuing the `register` down-event. Never rejects — both halves catch and
|
|
1393
|
+
* log, so `void`-ing the returned promise can't produce an unhandled
|
|
1394
|
+
* rejection. By default (hydrate off) the register lifecycle is the single
|
|
1395
|
+
* freshness path; the one-shot fetch is an optimization apps enable
|
|
1396
|
+
* explicitly, and it runs regardless of preload state — cache-first delivery
|
|
1397
|
+
* never depends on WHY rows are cached.
|
|
1398
|
+
*/
|
|
1399
|
+
private async finishQueryInit(
|
|
1400
|
+
hash: string,
|
|
1401
|
+
q: InnerQuery<any, any, any>,
|
|
1402
|
+
params: Record<string, any>
|
|
1403
|
+
): Promise<void> {
|
|
1404
|
+
if (this.config.instantHydrate === true && this.dataModule.isCold(hash)) {
|
|
1405
|
+
try {
|
|
1406
|
+
// Fence against bucket switches: rows fetched under the previous
|
|
1407
|
+
// auth context must not hydrate the new bucket's query state — the
|
|
1408
|
+
// rebind's re-registration refills it from the right context.
|
|
1409
|
+
const epoch = this.local.epoch;
|
|
1410
|
+
const [rows] = await this.remote.query<[RecordWithId[]]>(q.selectQuery.query, params);
|
|
1411
|
+
if (epoch === this.local.epoch) {
|
|
1412
|
+
await this.dataModule.applyHydration(hash, rows ?? []);
|
|
1413
|
+
}
|
|
1414
|
+
} catch (err) {
|
|
1415
|
+
if (err instanceof StaleEpochError) {
|
|
1416
|
+
this.logger.debug(
|
|
1417
|
+
{ hash, Category: 'sp00ky-client::Sp00kyClient::instantHydrate' },
|
|
1418
|
+
'Dropped instant hydrate from before a bucket switch'
|
|
1419
|
+
);
|
|
1420
|
+
} else {
|
|
1421
|
+
this.logger.warn(
|
|
1422
|
+
{ err, hash, Category: 'sp00ky-client::Sp00kyClient::instantHydrate' },
|
|
1423
|
+
'Instant hydrate failed; proceeding with registration'
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
try {
|
|
1430
|
+
await this.sync.enqueueDownEvent({
|
|
1431
|
+
type: 'register',
|
|
1432
|
+
payload: {
|
|
1433
|
+
hash,
|
|
1434
|
+
},
|
|
1435
|
+
});
|
|
1436
|
+
} catch (err) {
|
|
1437
|
+
this.logger.error(
|
|
1438
|
+
{ err, hash, Category: 'sp00ky-client::Sp00kyClient::initQuery' },
|
|
1439
|
+
'Failed to enqueue register down-event'
|
|
1440
|
+
);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* Smart, awaitable preload/prewarm into the LOCAL cache — without registering a
|
|
1446
|
+
* live view (NO `_00_query`, NO subscription, NO TTL heartbeat).
|
|
1447
|
+
*
|
|
1448
|
+
* Cache-aware via a durable per-bucket freshness marker (`_00_preload`):
|
|
1449
|
+
* - COLD (never preloaded in this bucket): fetch the query one-shot from the
|
|
1450
|
+
* remote, persist the rows (+ embedded `.related()` children), stamp the
|
|
1451
|
+
* marker — and AWAIT it. This is the "smart waiting" first load: callers can
|
|
1452
|
+
* `await db.preload(...)` to hold the UI until the data is ready.
|
|
1453
|
+
* - WARM (marker present): return instantly — NEVER blocks. `refresh` decides
|
|
1454
|
+
* whether to also kick a one-time silent refetch (see {@link PreloadOptions}).
|
|
1455
|
+
* Default `onUse` does nothing; the data freshens when the real `useQuery`
|
|
1456
|
+
* mounts and registers its live view.
|
|
1457
|
+
*
|
|
1458
|
+
* Best-effort: any fetch failure (offline, etc.) is a no-op warn (no marker
|
|
1459
|
+
* written, so it's retried next load). Deduped per session by query hash.
|
|
1460
|
+
*/
|
|
1461
|
+
async preload(
|
|
1462
|
+
finalQuery: FinalQuery<S, any, any, any, any, any>,
|
|
1463
|
+
options?: PreloadOptions
|
|
1464
|
+
): Promise<void> {
|
|
1465
|
+
const q = finalQuery.innerQuery;
|
|
1466
|
+
if (this.preloadedHashes.has(q.hash)) return;
|
|
1467
|
+
|
|
1468
|
+
const tableName = q.tableName;
|
|
1469
|
+
const tableSchema = this.config.schema.tables.find((t) => t.name === tableName);
|
|
1470
|
+
if (!tableSchema) {
|
|
1471
|
+
throw new Error(`Table ${tableName} not found`);
|
|
1472
|
+
}
|
|
1473
|
+
const params = parseQueryParams(tableSchema.columns, q.selectQuery.vars ?? {});
|
|
1474
|
+
const hashKey = String(q.hash);
|
|
1475
|
+
|
|
1476
|
+
const marker = await this.dataModule.getPreloadMarker(hashKey);
|
|
1477
|
+
|
|
1478
|
+
// COLD → fetch + persist + stamp, awaited so the caller can block on it.
|
|
1479
|
+
if (!marker) {
|
|
1480
|
+
const rowCount = await this.fetchAndPersist(q, tableName, params);
|
|
1481
|
+
if (rowCount >= 0) {
|
|
1482
|
+
await this.dataModule.writePreloadMarker(hashKey, rowCount);
|
|
1483
|
+
this.preloadedHashes.add(q.hash);
|
|
1484
|
+
}
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// WARM → never block. Mark handled for this session, then optionally refresh.
|
|
1489
|
+
this.preloadedHashes.add(q.hash);
|
|
1490
|
+
const refresh = options?.refresh ?? 'onUse';
|
|
1491
|
+
if (refresh === 'onUse') return;
|
|
1492
|
+
|
|
1493
|
+
if (refresh === 'stale') {
|
|
1494
|
+
const maxAgeMs = parseDuration(options?.staleTime ?? '1h');
|
|
1495
|
+
if (Date.now() - marker.fetchedAt <= maxAgeMs) return; // still fresh
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// `background`, or `stale` past its staleTime → one-time silent refetch.
|
|
1499
|
+
void this.fetchAndPersist(q, tableName, params).then((rowCount) => {
|
|
1500
|
+
if (rowCount >= 0) return this.dataModule.writePreloadMarker(hashKey, rowCount);
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* One-shot remote fetch + local persist for a preload query. Returns the row
|
|
1506
|
+
* count on success, or -1 on failure (best-effort: logged, never thrown) so
|
|
1507
|
+
* the caller skips stamping the freshness marker and retries next load.
|
|
1508
|
+
*/
|
|
1509
|
+
private async fetchAndPersist(
|
|
1510
|
+
q: InnerQuery<any, any, any>,
|
|
1511
|
+
tableName: string,
|
|
1512
|
+
params: Record<string, any>
|
|
1513
|
+
): Promise<number> {
|
|
1514
|
+
try {
|
|
1515
|
+
const [rows] = await this.remote.query<[RecordWithId[]]>(q.selectQuery.query, params);
|
|
1516
|
+
const list = rows ?? [];
|
|
1517
|
+
await this.dataModule.persistSnapshot(tableName, list);
|
|
1518
|
+
return list.length;
|
|
1519
|
+
} catch (err) {
|
|
1520
|
+
this.logger.warn(
|
|
1521
|
+
{ err, hash: q.hash, Category: 'sp00ky-client::Sp00kyClient::preload' },
|
|
1522
|
+
'Preload fetch failed; data will be fetched on demand'
|
|
1523
|
+
);
|
|
1524
|
+
return -1;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
async queryRaw(sql: string, params: Record<string, any>, ttl: QueryTimeToLive) {
|
|
1529
|
+
const tableName = sql.split('FROM ')[1].split(' ')[0];
|
|
1530
|
+
return this.dataModule.query(tableName, sql, params, ttl);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
async subscribe(
|
|
1534
|
+
queryHash: string,
|
|
1535
|
+
callback: (records: Record<string, any>[]) => void,
|
|
1536
|
+
options?: { immediate?: boolean }
|
|
1537
|
+
): Promise<() => void> {
|
|
1538
|
+
return this.dataModule.subscribe(queryHash, callback, options);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* Opt-in eager teardown for a query whose last subscriber has gone away
|
|
1543
|
+
* (e.g. a viewport-windowed list cancelling an off-screen window). No-op
|
|
1544
|
+
* while any subscriber remains. Tears down the remote `_00_query` view +
|
|
1545
|
+
* local WASM view instead of waiting for the TTL sweep. Default behavior
|
|
1546
|
+
* (no call here) keeps the view resident for cheap re-subscription.
|
|
1547
|
+
*/
|
|
1548
|
+
deregisterQuery(queryHash: string): void {
|
|
1549
|
+
this.dataModule.deregisterQuery(queryHash);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/**
|
|
1553
|
+
* Subscribe to a query's fetch-status changes (idle/fetching). With
|
|
1554
|
+
* `{ immediate: true }` the callback fires synchronously with the current
|
|
1555
|
+
* status. Powers the `useQuery` hook's `isFetching()` accessor.
|
|
1556
|
+
*/
|
|
1557
|
+
subscribeQueryStatus(
|
|
1558
|
+
queryHash: string,
|
|
1559
|
+
callback: QueryStatusCallback,
|
|
1560
|
+
options?: { immediate?: boolean }
|
|
1561
|
+
): () => void {
|
|
1562
|
+
return this.dataModule.subscribeStatus(queryHash, callback, options);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/**
|
|
1566
|
+
* Report the frontend processing time (ms) a client framework spent applying
|
|
1567
|
+
* an update for a query (e.g. `useQuery`'s `reconcile()`), so DevTools/MCP can
|
|
1568
|
+
* surface the "frontend" phase of the per-query timing breakdown.
|
|
1569
|
+
*/
|
|
1570
|
+
reportFrontendTiming(queryHash: string, ms: number): void {
|
|
1571
|
+
this.dataModule.recordFrontendTiming(queryHash, ms);
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
|
|
1575
|
+
backend: B,
|
|
1576
|
+
path: R,
|
|
1577
|
+
payload: RoutePayload<S, B, R>,
|
|
1578
|
+
options?: RunOptions
|
|
1579
|
+
) {
|
|
1580
|
+
return this.dataModule.run(backend, path, payload, options);
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
bucket<B extends BucketNames<S>>(name: B): BucketHandle {
|
|
1584
|
+
return new BucketHandle(name, this.remote, this.blobs, {
|
|
1585
|
+
blurhash: this.config.blurhash,
|
|
1586
|
+
logger: this.logger,
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/** Cache-free handle. The blob cache reads the remote through this, so a
|
|
1591
|
+
* cache miss can't loop back into the cache. */
|
|
1592
|
+
private rawBucket(name: string): BucketHandle {
|
|
1593
|
+
return new BucketHandle(name, this.remote, null, {
|
|
1594
|
+
blurhash: this.config.blurhash,
|
|
1595
|
+
logger: this.logger,
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/** Blob cache counters for DevTools. */
|
|
1600
|
+
getBlobCacheStats() {
|
|
1601
|
+
return this.blobs.stats();
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
create(id: string, data: Record<string, unknown>) {
|
|
1605
|
+
return this.dataModule.create(id, data);
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
update(table: string, id: string, data: Record<string, unknown>, options?: UpdateOptions) {
|
|
1609
|
+
return this.dataModule.update(table, id, data, options);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
delete(table: string, id: string) {
|
|
1613
|
+
return this.dataModule.delete(table, id);
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/**
|
|
1617
|
+
* Whether the local store is initialized and reads can be served. See the
|
|
1618
|
+
* `localReady` field: this is deliberately independent of connectivity.
|
|
1619
|
+
*/
|
|
1620
|
+
isLocalReady(): boolean {
|
|
1621
|
+
return this.localReady;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
async useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T> {
|
|
1625
|
+
return fn(this.remote.getClient());
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
/**
|
|
1629
|
+
* Mint the salt used for query-id hashing, so two sessions registering the
|
|
1630
|
+
* same logical query get distinct `_00_query` rows.
|
|
1631
|
+
*
|
|
1632
|
+
* Generated LOCALLY, deliberately. This used to be `RETURN <string>session::id()`,
|
|
1633
|
+
* which cost a serial round trip on the critical boot path and resolved to
|
|
1634
|
+
* `''` offline. The value never needed to come from the server: the server
|
|
1635
|
+
* derives its own `clientId` inside `fn::query::register` and *ignores*
|
|
1636
|
+
* whatever the caller passed, and the permission rules that matter gate on
|
|
1637
|
+
* `auth_id = $auth.id` rather than the session (`_00_list_ref`). Session
|
|
1638
|
+
* scoping via `clientId = session::id()` was in fact removed upstream because
|
|
1639
|
+
* it broke a user with two tabs open. All this value has to be is unique per
|
|
1640
|
+
* browser session, which `randomUUID` gives us for free and offline.
|
|
1641
|
+
*/
|
|
1642
|
+
/**
|
|
1643
|
+
* The current principal as the `"table:id"` string the in-browser SSP wants
|
|
1644
|
+
* for `$auth.id`, or null when signed out.
|
|
1645
|
+
*
|
|
1646
|
+
* Tolerates BOTH shapes `currentUser.id` can take, which is the point:
|
|
1647
|
+
* a session restored from the cached token carries a plain string (the JWT's
|
|
1648
|
+
* `ID` claim), while one verified by the server carries a RecordId. Passing
|
|
1649
|
+
* the former to `encodeRecordId` reads `.table` off a string and throws
|
|
1650
|
+
* during boot.
|
|
1651
|
+
*/
|
|
1652
|
+
/**
|
|
1653
|
+
* Prime the in-browser circuit from the local store. Builds the context the
|
|
1654
|
+
* stream processor needs: every synced table (the app schema plus the
|
|
1655
|
+
* server-written meta tables that sync down), a schema hash so a snapshot
|
|
1656
|
+
* projected under another schema is not trusted, and the ids whose local
|
|
1657
|
+
* `_00_rv` was bumped by an unsettled mutation.
|
|
1658
|
+
*/
|
|
1659
|
+
private async primeCircuit(): Promise<void> {
|
|
1660
|
+
const tables = [
|
|
1661
|
+
...this.config.schema.tables.map((t) => t.name),
|
|
1662
|
+
'_00_user_feature',
|
|
1663
|
+
'_00_app_release',
|
|
1664
|
+
];
|
|
1665
|
+
let pendingIds = new Set<string>();
|
|
1666
|
+
try {
|
|
1667
|
+
const pending = await this.dataModule.getPendingRecordIds();
|
|
1668
|
+
pendingIds = new Set([...pending.writes, ...pending.deletes]);
|
|
1669
|
+
} catch {
|
|
1670
|
+
/* no outbox yet: nothing is pending */
|
|
1671
|
+
}
|
|
1672
|
+
await this.streamProcessor.primeFromLocal({
|
|
1673
|
+
tables,
|
|
1674
|
+
schemaHash: String(hash53(this.config.schemaSurql)),
|
|
1675
|
+
pendingIds,
|
|
1676
|
+
onVersions: (_table, entries) => this.cache.primeVersions(entries),
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
private sessionAuthId(): string | null {
|
|
1681
|
+
const id = this.auth.currentUser?.id;
|
|
1682
|
+
if (!id) return null;
|
|
1683
|
+
return typeof id === 'string' ? id : encodeRecordId(id);
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
private mintSessionSalt(): string {
|
|
1687
|
+
const c: Crypto | undefined =
|
|
1688
|
+
typeof globalThis !== 'undefined' ? (globalThis as { crypto?: Crypto }).crypto : undefined;
|
|
1689
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
1690
|
+
// Older browsers / non-secure contexts: uniqueness is all that is required.
|
|
1691
|
+
return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
|
|
1692
|
+
}
|
|
1693
|
+
}
|