@spooky-sync/core 0.0.1-canary.13 → 0.0.1-canary.131
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 +56 -0
- package/dist/index.d.ts +1171 -372
- package/dist/index.js +5726 -1277
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +107 -0
- package/dist/types.d.ts +746 -0
- package/package.json +39 -8
- package/skills/sp00ky-core/SKILL.md +258 -0
- package/skills/sp00ky-core/references/auth.md +98 -0
- package/skills/sp00ky-core/references/config.md +76 -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 +9 -2
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +77 -32
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +288 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +357 -0
- package/src/modules/data/data.hydration.test.ts +150 -0
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.recurring.test.ts +137 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/index.ts +1234 -127
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +154 -0
- package/src/modules/devtools/index.ts +191 -30
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/feature-flag/index.test.ts +120 -0
- package/src/modules/feature-flag/index.ts +209 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +101 -37
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.ts +12 -5
- package/src/modules/sync/queue/queue-up.ts +29 -14
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +73 -7
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.ts +1017 -62
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +182 -17
- package/src/otel/index.ts +127 -0
- package/src/services/database/cache-engine.ts +143 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/engine-factory.ts +32 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +6 -0
- package/src/services/database/local-migrator.ts +28 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +452 -66
- package/src/services/database/plan-render.test.ts +133 -0
- package/src/services/database/plan-render.ts +100 -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 +13 -13
- package/src/services/database/sqlite-cache-engine.test.ts +85 -0
- package/src/services/database/sqlite-cache-engine.ts +699 -0
- package/src/services/database/sqlite-worker.ts +116 -0
- package/src/services/database/surql-translate.ts +291 -0
- package/src/services/database/surreal-cache-engine.ts +139 -0
- package/src/services/logger/index.ts +6 -101
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +41 -0
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +295 -38
- 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 +136 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +18 -2
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +185 -0
- package/src/sp00ky.ts +1016 -0
- package/src/types.ts +258 -15
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +35 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/surql.ts +24 -15
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +77 -1
- package/src/spooky.ts +0 -392
package/src/sp00ky.ts
ADDED
|
@@ -0,0 +1,1016 @@
|
|
|
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} from './types';
|
|
12
|
+
import {
|
|
13
|
+
LocalMigrator,
|
|
14
|
+
RemoteDatabaseService,
|
|
15
|
+
createLocalEngine,
|
|
16
|
+
} from './services/database/index';
|
|
17
|
+
import type { LocalStore } from './services/database/index';
|
|
18
|
+
import { StaleEpochError } from './services/database/index';
|
|
19
|
+
import type { UpEvent } from './modules/sync/index';
|
|
20
|
+
import { Sp00kySync } from './modules/sync/index';
|
|
21
|
+
import type {
|
|
22
|
+
FinalQuery,
|
|
23
|
+
GetTable,
|
|
24
|
+
InnerQuery,
|
|
25
|
+
QueryOptions,
|
|
26
|
+
SchemaStructure,
|
|
27
|
+
TableModel,
|
|
28
|
+
TableNames,
|
|
29
|
+
BucketNames,
|
|
30
|
+
BackendNames,
|
|
31
|
+
BackendRoutes,
|
|
32
|
+
RoutePayload} from '@spooky-sync/query-builder';
|
|
33
|
+
import {
|
|
34
|
+
QueryBuilder
|
|
35
|
+
} from '@spooky-sync/query-builder';
|
|
36
|
+
|
|
37
|
+
import { DevToolsService } from './modules/devtools/index';
|
|
38
|
+
import { createLogger } from './services/logger/index';
|
|
39
|
+
import { AuthService } from './modules/auth/index';
|
|
40
|
+
import { StreamProcessorService } from './services/stream-processor/index';
|
|
41
|
+
import { extractSelectPermissions } from './services/stream-processor/permissions';
|
|
42
|
+
import { EventSystem } from './events/index';
|
|
43
|
+
import { CacheModule } from './modules/cache/index';
|
|
44
|
+
import type { RecordWithId } from './modules/cache/index';
|
|
45
|
+
import { CrdtManager, CrdtField } from './modules/crdt/index';
|
|
46
|
+
import { FeatureFlagModule, FeatureFlagHandle } from './modules/feature-flag/index';
|
|
47
|
+
import type { FeatureFlagOptions } from './modules/feature-flag/index';
|
|
48
|
+
import { LocalStoragePersistenceClient } from './services/persistence/localstorage';
|
|
49
|
+
import { ANON_USER_ID, bucketIdForUser } from './modules/ref-tables';
|
|
50
|
+
import { parseParams, encodeRecordId, parseDuration } from './utils/index';
|
|
51
|
+
import { SurrealDBPersistenceClient } from './services/persistence/surrealdb';
|
|
52
|
+
import { ResilientPersistenceClient } from './services/persistence/resilient';
|
|
53
|
+
|
|
54
|
+
export class BucketHandle {
|
|
55
|
+
constructor(private bucketName: string, private remote: RemoteDatabaseService) {}
|
|
56
|
+
|
|
57
|
+
async put(path: string, content: string | Uint8Array | Blob): Promise<void> {
|
|
58
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${path}".put($content);`, { content });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async get(path: string): Promise<unknown> {
|
|
62
|
+
const [result] = await this.remote.query<[unknown]>(`RETURN f"${this.bucketName}:/${path}".get();`);
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async delete(path: string): Promise<void> {
|
|
67
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${path}".delete();`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async exists(path: string): Promise<boolean> {
|
|
71
|
+
const [result] = await this.remote.query<[boolean]>(`RETURN f"${this.bucketName}:/${path}".exists();`);
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async head(path: string): Promise<Record<string, unknown>> {
|
|
76
|
+
const [result] = await this.remote.query<[Record<string, unknown>]>(`RETURN f"${this.bucketName}:/${path}".head();`);
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async copy(sourcePath: string, targetPath: string): Promise<void> {
|
|
81
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".copy($target);`, { target: targetPath });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async rename(sourcePath: string, targetPath: string): Promise<void> {
|
|
85
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".rename($target);`, { target: targetPath });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async list(prefix?: string): Promise<string[]> {
|
|
89
|
+
const p = prefix ?? '';
|
|
90
|
+
const [result] = await this.remote.query<[string[]]>(`RETURN f"${this.bucketName}:/${p}".list();`);
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Boot hint for which local bucket to open before auth resolves. Written to
|
|
97
|
+
* PLAIN localStorage (never the configured persistenceClient): the surrealdb
|
|
98
|
+
* persistence client stores its keys INSIDE a bucket, and the whole point of
|
|
99
|
+
* the hint is to pick the bucket before any bucket is open. A warm reload of a
|
|
100
|
+
* signed-in user thus opens their own bucket immediately — zero switches.
|
|
101
|
+
* Losing the hint is fail-closed: boot lands on the anon bucket and the auth
|
|
102
|
+
* callback switches to the user's bucket (cache + outbox intact).
|
|
103
|
+
*/
|
|
104
|
+
const LAST_BUCKET_KEY = 'sp00ky:last_bucket';
|
|
105
|
+
|
|
106
|
+
// Max age of a `_00_preload` marker for which instant-hydrate is skipped —
|
|
107
|
+
// a query preloaded this recently already painted from local rows and the
|
|
108
|
+
// register lifecycle re-syncs it, so the one-shot hydrate fetch would be a
|
|
109
|
+
// duplicate round trip. Matches `preload()`'s default `staleTime`.
|
|
110
|
+
const HYDRATE_PRELOAD_FRESH: QueryTimeToLive = '1h';
|
|
111
|
+
|
|
112
|
+
function readBootBucketHint(): string | null {
|
|
113
|
+
try {
|
|
114
|
+
return typeof localStorage !== 'undefined' ? localStorage.getItem(LAST_BUCKET_KEY) : null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function writeBootBucketHint(bucketId: string): void {
|
|
121
|
+
try {
|
|
122
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(LAST_BUCKET_KEY, bucketId);
|
|
123
|
+
} catch {
|
|
124
|
+
/* private-mode storage errors: boot just falls back to the anon bucket */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export class Sp00kyClient<S extends SchemaStructure> {
|
|
129
|
+
private local: LocalStore;
|
|
130
|
+
private remote: RemoteDatabaseService;
|
|
131
|
+
private persistenceClient: PersistenceClient;
|
|
132
|
+
|
|
133
|
+
private migrator: LocalMigrator;
|
|
134
|
+
private cache: CacheModule;
|
|
135
|
+
private dataModule: DataModule<S>;
|
|
136
|
+
private sync: Sp00kySync<S>;
|
|
137
|
+
private devTools: DevToolsService;
|
|
138
|
+
private crdtManager: CrdtManager;
|
|
139
|
+
private featureFlags!: FeatureFlagModule<S>;
|
|
140
|
+
// Query hashes already preloaded this session — skip redundant one-shot
|
|
141
|
+
// fetches when the same preload query is requested again (e.g. a list row
|
|
142
|
+
// re-rendering). Cleared on process/session end only.
|
|
143
|
+
private preloadedHashes = new Set<number>();
|
|
144
|
+
// In-flight background init chains (instant-hydrate + register enqueue) keyed
|
|
145
|
+
// by registration hash. Concurrent mounts of the same query reuse the one
|
|
146
|
+
// chain instead of double-hydrating and double-enqueuing `register`.
|
|
147
|
+
// Sequential re-mounts intentionally start a fresh chain — the unconditional
|
|
148
|
+
// `register` re-enqueue is what freshens a warm preload on use.
|
|
149
|
+
private pendingQueryInits = new Map<string, Promise<void>>();
|
|
150
|
+
|
|
151
|
+
private logger: ReturnType<typeof createLogger>;
|
|
152
|
+
public auth: AuthService<S>;
|
|
153
|
+
public streamProcessor: StreamProcessorService;
|
|
154
|
+
|
|
155
|
+
get remoteClient() {
|
|
156
|
+
return this.remote.getClient();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
get localClient() {
|
|
160
|
+
return this.local.getClient();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get pendingMutationCount(): number {
|
|
164
|
+
return this.sync.pendingMutationCount;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Number of times the initial list_ref LIVE subscription retried on
|
|
168
|
+
* the most recent `setCurrentUserId` call. 0 when the SSP's
|
|
169
|
+
* pre-emptive user-table creation got there first; >0 when LIVE
|
|
170
|
+
* registration hit a "table not found" race. Exposed so the e2e
|
|
171
|
+
* suite can guard the pre-emptive path against regression. */
|
|
172
|
+
get liveRetryCount(): number {
|
|
173
|
+
return this.sync.liveRetryCount;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
subscribeToPendingMutations(cb: (count: number) => void): () => void {
|
|
177
|
+
return this.sync.subscribeToPendingMutations(cb);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Current sync-health snapshot. See {@link Sp00kyConfig.syncHealth}. */
|
|
181
|
+
get syncHealth(): SyncHealth {
|
|
182
|
+
return this.sync.syncHealth;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Observe sync health. Fires immediately with the current status and again
|
|
187
|
+
* on every healthy↔degraded transition. Returns an unsubscribe.
|
|
188
|
+
*/
|
|
189
|
+
subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
|
|
190
|
+
return this.sync.subscribeToSyncHealth(cb);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
constructor(private config: Sp00kyConfig<S>) {
|
|
194
|
+
const logger = createLogger(config.logLevel ?? 'info', config.otelTransmit);
|
|
195
|
+
this.logger = logger.child({ service: 'Sp00kyClient' });
|
|
196
|
+
|
|
197
|
+
this.logger.info(
|
|
198
|
+
{
|
|
199
|
+
config: { ...config, schema: '[SchemaStructure]' },
|
|
200
|
+
Category: 'sp00ky-client::Sp00kyClient::constructor',
|
|
201
|
+
},
|
|
202
|
+
'Sp00kyClient initialized'
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// The default ('surrealdb') engine is a SurrealCacheEngine — a drop-in
|
|
206
|
+
// subclass of LocalDatabaseService that adds the engine-neutral verb surface
|
|
207
|
+
// with zero behavior change. Alternate engines (e.g. 'sqlite') require the
|
|
208
|
+
// raw-SurrealQL call-site migration before they can back `this.local`.
|
|
209
|
+
this.local = createLocalEngine(this.config.localEngine, this.config.database, logger);
|
|
210
|
+
this.remote = new RemoteDatabaseService(this.config.database, logger);
|
|
211
|
+
|
|
212
|
+
if (config.persistenceClient === 'surrealdb') {
|
|
213
|
+
this.persistenceClient = new SurrealDBPersistenceClient(this.local, logger);
|
|
214
|
+
} else if (config.persistenceClient === 'localstorage' || !config.persistenceClient) {
|
|
215
|
+
this.persistenceClient = new LocalStoragePersistenceClient(logger);
|
|
216
|
+
} else {
|
|
217
|
+
this.persistenceClient = config.persistenceClient;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
this.persistenceClient = new ResilientPersistenceClient(this.persistenceClient, logger);
|
|
221
|
+
|
|
222
|
+
this.streamProcessor = new StreamProcessorService(
|
|
223
|
+
new EventSystem(['stream_update']),
|
|
224
|
+
this.local,
|
|
225
|
+
this.persistenceClient,
|
|
226
|
+
logger
|
|
227
|
+
);
|
|
228
|
+
this.migrator = new LocalMigrator(this.local, logger);
|
|
229
|
+
|
|
230
|
+
this.cache = new CacheModule(
|
|
231
|
+
this.local,
|
|
232
|
+
this.streamProcessor,
|
|
233
|
+
(update) => {
|
|
234
|
+
// Direct callback from cache to data module
|
|
235
|
+
this.dataModule.onStreamUpdate(update);
|
|
236
|
+
},
|
|
237
|
+
logger
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// Initialize CRDT Manager. `local` is used to read the initial
|
|
241
|
+
// `_00_crdt` snapshot when a field opens AND to mirror every local
|
|
242
|
+
// edit (so reload/offline see the freshest state); `remote` is used
|
|
243
|
+
// for the debounced outgoing UPSERTs and the parent-table LIVE feed.
|
|
244
|
+
// The debounce window is configurable via `crdtDebounceMs`.
|
|
245
|
+
this.crdtManager = new CrdtManager(
|
|
246
|
+
this.config.schema,
|
|
247
|
+
this.local,
|
|
248
|
+
this.remote,
|
|
249
|
+
logger,
|
|
250
|
+
config.crdtDebounceMs ?? 500,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
this.dataModule = new DataModule(
|
|
254
|
+
this.cache,
|
|
255
|
+
this.local,
|
|
256
|
+
this.config.schema,
|
|
257
|
+
logger,
|
|
258
|
+
this.config.streamDebounceTime
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
// Initialize Auth
|
|
262
|
+
this.auth = new AuthService(this.config.schema, this.remote, this.persistenceClient, logger);
|
|
263
|
+
|
|
264
|
+
// Initialize Sync
|
|
265
|
+
this.sync = new Sp00kySync(
|
|
266
|
+
this.local,
|
|
267
|
+
this.remote,
|
|
268
|
+
this.cache,
|
|
269
|
+
this.dataModule,
|
|
270
|
+
this.config.schema,
|
|
271
|
+
this.logger,
|
|
272
|
+
{
|
|
273
|
+
refSyncIntervalMs: this.config.refSyncIntervalMs,
|
|
274
|
+
anonymousLiveQueries: this.config.enableAnonymousLiveQueries,
|
|
275
|
+
// `syncHealth: false` (or `{ degradeAfterConsecutiveFailures: 0 }`)
|
|
276
|
+
// disables degraded reporting; otherwise default to 3.
|
|
277
|
+
degradeAfterConsecutiveFailures:
|
|
278
|
+
this.config.syncHealth === false
|
|
279
|
+
? 0
|
|
280
|
+
: this.config.syncHealth?.degradeAfterConsecutiveFailures ?? 3,
|
|
281
|
+
}
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
// Initialize feature flags. Reuses the down-queue to register SSP plans
|
|
285
|
+
// on `_00_user_feature` and the auth subscription to re-register handles
|
|
286
|
+
// when the signed-in user changes.
|
|
287
|
+
this.featureFlags = new FeatureFlagModule({
|
|
288
|
+
dataModule: this.dataModule,
|
|
289
|
+
sync: this.sync,
|
|
290
|
+
auth: this.auth,
|
|
291
|
+
logger,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Initialize DevTools
|
|
295
|
+
this.devTools = new DevToolsService(
|
|
296
|
+
this.local,
|
|
297
|
+
this.remote,
|
|
298
|
+
logger,
|
|
299
|
+
this.config.schema,
|
|
300
|
+
this.auth,
|
|
301
|
+
this.dataModule
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
// Register DevTools as a receiver for stream updates
|
|
305
|
+
this.streamProcessor.addReceiver(this.devTools);
|
|
306
|
+
|
|
307
|
+
// Wire up callbacks instead of events
|
|
308
|
+
this.setupCallbacks();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Setup direct callbacks instead of event subscriptions
|
|
313
|
+
*/
|
|
314
|
+
private setupCallbacks() {
|
|
315
|
+
// Surface query fetch-status changes (idle/fetching) in DevTools. Logs a
|
|
316
|
+
// discrete event and triggers a state push so the active-queries panel
|
|
317
|
+
// reflects the flip immediately.
|
|
318
|
+
this.dataModule.onQueryStatusChange = (queryHash, status) => {
|
|
319
|
+
this.devTools.logEvent('QUERY_STATUS_CHANGED', { queryHash, status });
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// Keep an actively-watched query's remote `_00_query.lastActiveAt` fresh so
|
|
323
|
+
// the server TTL sweep doesn't expire it out from under live subscribers.
|
|
324
|
+
// DataModule fires this only while the query still has ≥1 subscriber.
|
|
325
|
+
this.dataModule.onHeartbeat = (queryHash) => {
|
|
326
|
+
void this.sync.heartbeatQuery(queryHash).catch((err) => {
|
|
327
|
+
this.logger.warn(
|
|
328
|
+
{ err, queryHash, Category: 'sp00ky-client::Sp00kyClient::onHeartbeat' },
|
|
329
|
+
'TTL heartbeat failed'
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// Eager teardown of an opt-in deregistered query: enqueue a `cleanup`
|
|
335
|
+
// down-event so it's serialized after any in-flight register/sync for the
|
|
336
|
+
// same query (avoids out-of-order delete-before-create).
|
|
337
|
+
this.dataModule.onDeregister = (queryHash) => {
|
|
338
|
+
this.sync.enqueueDownEvent({ type: 'cleanup', payload: { hash: queryHash } });
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
// Mutation callback for sync
|
|
342
|
+
this.dataModule.onMutation((mutations: UpEvent[]) => {
|
|
343
|
+
// Notify DevTools
|
|
344
|
+
this.devTools.onMutation(mutations);
|
|
345
|
+
|
|
346
|
+
// Enqueue in Sync
|
|
347
|
+
if (mutations.length > 0) {
|
|
348
|
+
this.sync.enqueueMutation(mutations);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// Sync events for incoming updates
|
|
353
|
+
this.sync.events.subscribe('SYNC_QUERY_UPDATED', (event: any) => {
|
|
354
|
+
this.devTools.logEvent('SYNC_QUERY_UPDATED', event.payload);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Hand list_ref-driven row ingests to the CrdtManager so CRDT body
|
|
358
|
+
// / cursor updates reach the receiver even when the cross-session
|
|
359
|
+
// LIVE on the parent table is filtered out by the SurrealDB
|
|
360
|
+
// permission-LIVE gap. Same-user clients receive these rows via
|
|
361
|
+
// CrdtManager's own `LIVE SELECT * FROM <table>`; this hook is the
|
|
362
|
+
// redundant path that fires when only the list_ref bumped.
|
|
363
|
+
this.sync.engineEvents.subscribe('SYNC_REMOTE_DATA_INGESTED', (event: any) => {
|
|
364
|
+
try {
|
|
365
|
+
const records: Array<Record<string, any>> = event.payload?.records ?? [];
|
|
366
|
+
for (const row of records) {
|
|
367
|
+
const id = row?.id;
|
|
368
|
+
const table =
|
|
369
|
+
id && typeof id === 'object' && id.table !== undefined
|
|
370
|
+
? String(id.table)
|
|
371
|
+
: undefined;
|
|
372
|
+
if (!table) continue;
|
|
373
|
+
this.crdtManager.applyRow(table, row);
|
|
374
|
+
}
|
|
375
|
+
} catch (err) {
|
|
376
|
+
this.logger.debug(
|
|
377
|
+
{ err, Category: 'sp00ky-client::engineEvents::ingested' },
|
|
378
|
+
'applyRow forwarding from sync ingest failed'
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Database events for DevTools
|
|
384
|
+
this.local.getEvents().subscribe('DATABASE_LOCAL_QUERY', (event: any) => {
|
|
385
|
+
this.devTools.logEvent('LOCAL_QUERY', event.payload);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
this.remote.getEvents().subscribe('DATABASE_REMOTE_QUERY', (event: any) => {
|
|
389
|
+
this.devTools.logEvent('REMOTE_QUERY', event.payload);
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async init() {
|
|
394
|
+
this.logger.info(
|
|
395
|
+
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
396
|
+
'Sp00kyClient initialization started'
|
|
397
|
+
);
|
|
398
|
+
try {
|
|
399
|
+
// Open the bucket the last session used (per-user local stores). If auth
|
|
400
|
+
// resolves to a different user below, the auth callback switches buckets.
|
|
401
|
+
const bootBucket = readBootBucketHint() ?? ANON_USER_ID;
|
|
402
|
+
await this.local.connect(bootBucket);
|
|
403
|
+
this.logger.debug(
|
|
404
|
+
{ bootBucket, Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
405
|
+
'Local database connected'
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
// Schemaless local engines (SQLite) create tables lazily and need no
|
|
409
|
+
// SurrealQL DDL provisioning.
|
|
410
|
+
if (this.local.usesSurqlSchema) {
|
|
411
|
+
await this.migrator.provision(this.config.schemaSurql);
|
|
412
|
+
this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Schema provisioned');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
await this.remote.connect();
|
|
416
|
+
this.logger.debug(
|
|
417
|
+
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
418
|
+
'Remote database connected'
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
this.streamProcessor.setStateKeySuffix(bootBucket);
|
|
422
|
+
await this.streamProcessor.init();
|
|
423
|
+
// Seed table `select` permissions from the schema before any query is
|
|
424
|
+
// registered — otherwise the SSP default-denies every non-`_00_` table.
|
|
425
|
+
this.streamProcessor.setPermissions(
|
|
426
|
+
extractSelectPermissions(this.config.schemaSurql)
|
|
427
|
+
);
|
|
428
|
+
this.logger.debug(
|
|
429
|
+
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
430
|
+
'StreamProcessor initialized'
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
await this.auth.init();
|
|
434
|
+
this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Auth initialized');
|
|
435
|
+
|
|
436
|
+
// Salt query-id hashing with the SurrealDB session id so two browsers
|
|
437
|
+
// for the same user don't collide on shared `_00_query` rows. The same
|
|
438
|
+
// session id is the `session_id` key in `_00_cursor` rows, so the
|
|
439
|
+
// CrdtManager needs it too.
|
|
440
|
+
const sessionId = await this.fetchSessionId();
|
|
441
|
+
await this.dataModule.init(sessionId);
|
|
442
|
+
this.crdtManager.setSessionId(sessionId);
|
|
443
|
+
this.logger.debug(
|
|
444
|
+
{ sessionId, Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
445
|
+
'DataModule initialized'
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
// Refresh the salt whenever auth state flips (sign-in, sign-out).
|
|
449
|
+
// session::id() changes per WebSocket session, and a sign-in spawns
|
|
450
|
+
// a new authenticated session, so the salt must follow. Also
|
|
451
|
+
// forward the user id into `DataModule` and `Sp00kySync` so they
|
|
452
|
+
// can route to per-user `_00_query_user_<id>` /
|
|
453
|
+
// `_00_list_ref_user_<id>` tables in `RefMode.Dedicated` — the
|
|
454
|
+
// LIVE subscription on `_00_list_ref_user_<id>` is restarted
|
|
455
|
+
// under the new auth context inside `Sp00kySync.setCurrentUserId`
|
|
456
|
+
// since SurrealDB binds the LIVE permission at registration time.
|
|
457
|
+
//
|
|
458
|
+
// Sync prefix BEFORE the first `await`: setting `currentUserId`
|
|
459
|
+
// synchronously here is critical because the AuthProvider's own
|
|
460
|
+
// subscribe callback runs right after ours and immediately enables
|
|
461
|
+
// queries that depend on the user id. Any `await` before
|
|
462
|
+
// `setCurrentUserId` would let those queries register against the
|
|
463
|
+
// stale (null) user id and hit the wrong `_00_query[_user_*]`
|
|
464
|
+
// table.
|
|
465
|
+
this.auth.subscribe(async (userId) => {
|
|
466
|
+
this.dataModule.setCurrentUserId(userId);
|
|
467
|
+
// Mirror the server's `fn::query::register` auth injection for the
|
|
468
|
+
// in-browser SSP: feed the current user's full record id + access
|
|
469
|
+
// method so `$auth`-gated table permissions (e.g. `thread`) resolve
|
|
470
|
+
// locally instead of being rejected. Set synchronously BEFORE the
|
|
471
|
+
// first `await` (like `setCurrentUserId` above) so queries that
|
|
472
|
+
// re-register on this auth flip see the fresh context, not a stale one.
|
|
473
|
+
this.streamProcessor.setSessionAuth(
|
|
474
|
+
this.auth.currentUser?.id ? encodeRecordId(this.auth.currentUser.id) : null,
|
|
475
|
+
this.auth.access
|
|
476
|
+
);
|
|
477
|
+
// Record the target bucket synchronously (still before the first
|
|
478
|
+
// `await`) so a reload mid-switch boots straight into the right store.
|
|
479
|
+
writeBootBucketHint(bucketIdForUser(userId));
|
|
480
|
+
// FIRST await: swap the local store to this user's bucket. Serialized
|
|
481
|
+
// + latest-target-wins internally; no-op when the bucket already
|
|
482
|
+
// matches (the boot-hint warm path).
|
|
483
|
+
await this.ensureLocalBucket(userId);
|
|
484
|
+
const next = await this.fetchSessionId();
|
|
485
|
+
this.dataModule.setSessionId(next);
|
|
486
|
+
this.crdtManager.setSessionId(next);
|
|
487
|
+
try {
|
|
488
|
+
await this.sync.setCurrentUserId(userId);
|
|
489
|
+
} catch (e) {
|
|
490
|
+
this.logger.error(
|
|
491
|
+
{ error: e, Category: 'sp00ky-client::Sp00kyClient::authChange' },
|
|
492
|
+
'sync.setCurrentUserId failed'
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
await this.sync.init();
|
|
498
|
+
this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Sync initialized');
|
|
499
|
+
|
|
500
|
+
this.featureFlags.init();
|
|
501
|
+
this.logger.debug(
|
|
502
|
+
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
503
|
+
'FeatureFlagModule initialized'
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
this.logger.info(
|
|
507
|
+
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
508
|
+
'Sp00kyClient initialization completed successfully'
|
|
509
|
+
);
|
|
510
|
+
} catch (e) {
|
|
511
|
+
this.logger.error(
|
|
512
|
+
{ error: e, Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
513
|
+
'Sp00kyClient initialization failed'
|
|
514
|
+
);
|
|
515
|
+
throw e;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Serializes bucket switches from rapid auth flips; `pendingBucketTarget`
|
|
520
|
+
// makes intermediate targets collapse (A→anon→B never opens the anon bucket).
|
|
521
|
+
private bucketSwitchChain: Promise<void> = Promise.resolve();
|
|
522
|
+
private pendingBucketTarget: string | null = null;
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Ensure the local store is this user's bucket, switching if needed. Called
|
|
526
|
+
* from the auth listener on every auth flip; concurrent calls are chained
|
|
527
|
+
* and superseded intermediates are skipped (latest target wins).
|
|
528
|
+
*/
|
|
529
|
+
private ensureLocalBucket(userId: string | null): Promise<void> {
|
|
530
|
+
const target = bucketIdForUser(userId);
|
|
531
|
+
this.pendingBucketTarget = target;
|
|
532
|
+
// Close the query gate SYNCHRONOUSLY the instant a switch is pending — the
|
|
533
|
+
// AuthProvider's own auth subscriber fires right after this (same tick) and
|
|
534
|
+
// enables queries, and `doSwitchBucket` only runs a microtask later on the
|
|
535
|
+
// chain. Without closing the gate here, that query is issued through the
|
|
536
|
+
// still-open gate and is in-flight on the local wasm engine when
|
|
537
|
+
// `switchStore` closes the client — which wedges the engine (every
|
|
538
|
+
// subsequent query, including provisioning, hangs → no view ever registers).
|
|
539
|
+
// No-op when already on the target bucket.
|
|
540
|
+
const needsSwitch = this.local.currentBucketId !== target;
|
|
541
|
+
const release = needsSwitch ? this.local.beginSwitch() : null;
|
|
542
|
+
this.bucketSwitchChain = this.bucketSwitchChain.then(async () => {
|
|
543
|
+
// Superseded by a newer flip, or already on target: reopen the gate we
|
|
544
|
+
// closed above and skip the switch.
|
|
545
|
+
if (this.pendingBucketTarget !== target || this.local.currentBucketId === target) {
|
|
546
|
+
release?.();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
await this.doSwitchBucket(target, release);
|
|
550
|
+
});
|
|
551
|
+
// Isolate chain failures per-caller: a failed switch must not poison every
|
|
552
|
+
// future switch. The caller (auth listener) logs it. Reopen the gate on
|
|
553
|
+
// failure so the client never gets stuck closed.
|
|
554
|
+
const result = this.bucketSwitchChain;
|
|
555
|
+
this.bucketSwitchChain = this.bucketSwitchChain.catch(() => {
|
|
556
|
+
release?.();
|
|
557
|
+
});
|
|
558
|
+
return result;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* The bucket-switch choreography: drain → swap → rebind.
|
|
563
|
+
*
|
|
564
|
+
* Drain: sync quiesced (poll/LIVE stopped, in-flight round awaited so its
|
|
565
|
+
* outbox delete lands in the OLD bucket, debounce timers cancelled),
|
|
566
|
+
* DataModule timers cleared, CRDT fields closed WITHOUT their final flush
|
|
567
|
+
* (the remote session already belongs to the next user).
|
|
568
|
+
*
|
|
569
|
+
* Swap: gate closes so any local query issued mid-switch (sibling auth
|
|
570
|
+
* subscribers, FeatureFlagModule) waits and then runs against the NEW
|
|
571
|
+
* bucket; store swaps open-new-before-close-old; schema provisions
|
|
572
|
+
* (no-op for a returning bucket); stale `_00_query` rows are wiped (dead
|
|
573
|
+
* sessionId-salted hashes with stale arrays — record bodies stay warm);
|
|
574
|
+
* SSP resets to a fresh circuit with re-seeded permissions.
|
|
575
|
+
*
|
|
576
|
+
* Rebind: auth token re-persisted (the surrealdb persistence client wrote it
|
|
577
|
+
* into the OLD bucket's `_00_kv` before this listener ran), active queries
|
|
578
|
+
* re-homed keeping their hashes, sync resumed on the new bucket's own
|
|
579
|
+
* outbox, and every query re-registered remotely to refill from the server.
|
|
580
|
+
*/
|
|
581
|
+
private async doSwitchBucket(target: string, gateRelease?: (() => void) | null): Promise<void> {
|
|
582
|
+
this.logger.info(
|
|
583
|
+
{ target, from: this.local.currentBucketId, Category: 'sp00ky-client::Sp00kyClient::doSwitchBucket' },
|
|
584
|
+
'Switching local bucket'
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
await this.sync.prepareBucketSwitch();
|
|
588
|
+
this.dataModule.quiesce();
|
|
589
|
+
this.crdtManager.closeAll({ flush: false });
|
|
590
|
+
|
|
591
|
+
// Reuse the gate the caller (`ensureLocalBucket`) closed synchronously; only
|
|
592
|
+
// open our own if called without one (keeps the gate continuously closed
|
|
593
|
+
// from the auth flip through the swap — no window for a racing query).
|
|
594
|
+
const reopen = gateRelease ?? this.local.beginSwitch();
|
|
595
|
+
try {
|
|
596
|
+
await this.local.switchStore(target);
|
|
597
|
+
if (this.local.usesSurqlSchema) {
|
|
598
|
+
await this.migrator.provision(this.config.schemaSurql);
|
|
599
|
+
}
|
|
600
|
+
await this.local.queryUngated('DELETE _00_query;');
|
|
601
|
+
this.streamProcessor.setStateKeySuffix(target);
|
|
602
|
+
await this.streamProcessor.reset();
|
|
603
|
+
this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
|
|
604
|
+
this.cache.clearVersionLookups();
|
|
605
|
+
// Preload dedup is per-bucket: the `_00_preload` markers + cached rows it
|
|
606
|
+
// guards live in the local store we just swapped away from. Keeping the
|
|
607
|
+
// hashes would make `preload()` skip warming the NEW bucket (its store is
|
|
608
|
+
// empty), so every thread/comment prewarm silently no-ops after login.
|
|
609
|
+
this.preloadedHashes.clear();
|
|
610
|
+
} finally {
|
|
611
|
+
reopen();
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (this.auth.token) {
|
|
615
|
+
try {
|
|
616
|
+
await this.persistenceClient.set('sp00ky_auth_token', this.auth.token);
|
|
617
|
+
} catch (e) {
|
|
618
|
+
this.logger.warn(
|
|
619
|
+
{ error: e, Category: 'sp00ky-client::Sp00kyClient::doSwitchBucket' },
|
|
620
|
+
'Failed to re-persist auth token into the new bucket'
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const hashes = await this.dataModule.rebindAfterBucketSwitch();
|
|
626
|
+
await this.sync.completeBucketSwitch();
|
|
627
|
+
for (const hash of hashes) {
|
|
628
|
+
this.sync.enqueueDownEvent({ type: 'register', payload: { hash } });
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
this.logger.info(
|
|
632
|
+
{ target, queries: hashes.length, Category: 'sp00ky-client::Sp00kyClient::doSwitchBucket' },
|
|
633
|
+
'Local bucket switch complete'
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async close() {
|
|
638
|
+
await this.featureFlags.closeAll();
|
|
639
|
+
this.crdtManager.closeAll();
|
|
640
|
+
await this.local.close();
|
|
641
|
+
await this.remote.close();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Subscribe to a feature flag for the current user. Returns a
|
|
646
|
+
* `FeatureFlagHandle` whose `variant()`, `payload()` and `enabled()`
|
|
647
|
+
* accessors reflect the latest assignment from `_00_user_feature`,
|
|
648
|
+
* and whose `subscribe(cb)` fires whenever that assignment changes.
|
|
649
|
+
*
|
|
650
|
+
* Permissions are enforced by SurrealDB: a client can only ever see
|
|
651
|
+
* its own row, and cannot create or modify assignments.
|
|
652
|
+
*/
|
|
653
|
+
feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle {
|
|
654
|
+
return this.featureFlags.feature(key, options);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
authenticate(token: string) {
|
|
658
|
+
return this.remote.getClient().authenticate(token);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Open a CRDT field for collaborative editing.
|
|
663
|
+
* Returns a CrdtField with a LoroDoc that can be bound to any editor.
|
|
664
|
+
* Also starts a LIVE SELECT on the parent table for real-time sync;
|
|
665
|
+
* incoming events trigger a subquery fetch of `_00_crdt` / `_00_cursor`.
|
|
666
|
+
*/
|
|
667
|
+
async openCrdtField(
|
|
668
|
+
table: string,
|
|
669
|
+
recordId: string,
|
|
670
|
+
field: string,
|
|
671
|
+
fallbackText?: string,
|
|
672
|
+
): Promise<CrdtField> {
|
|
673
|
+
return this.crdtManager.open(table, recordId, field, fallbackText);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Close a CRDT field when editing is done.
|
|
678
|
+
*/
|
|
679
|
+
closeCrdtField(table: string, recordId: string, field: string): void {
|
|
680
|
+
this.crdtManager.close(table, recordId, field);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
deauthenticate() {
|
|
684
|
+
return this.remote.getClient().invalidate();
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
query<Table extends TableNames<S>>(
|
|
688
|
+
table: Table,
|
|
689
|
+
options: QueryOptions<TableModel<GetTable<S, Table>>, false>,
|
|
690
|
+
ttl: QueryTimeToLive = '10m'
|
|
691
|
+
): QueryBuilder<S, Table, Sp00kyQueryResultPromise> {
|
|
692
|
+
return new QueryBuilder<S, Table, Sp00kyQueryResultPromise>(
|
|
693
|
+
this.config.schema,
|
|
694
|
+
table,
|
|
695
|
+
async (q) => ({
|
|
696
|
+
hash: await this.initQuery(table, q, ttl),
|
|
697
|
+
}),
|
|
698
|
+
options
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private async initQuery<Table extends TableNames<S>>(
|
|
703
|
+
table: Table,
|
|
704
|
+
q: InnerQuery<any, any, any>,
|
|
705
|
+
ttl: QueryTimeToLive
|
|
706
|
+
) {
|
|
707
|
+
const tableSchema = this.config.schema.tables.find((t) => t.name === table);
|
|
708
|
+
if (!tableSchema) {
|
|
709
|
+
throw new Error(`Table ${table} not found`);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const params = parseParams(tableSchema.columns, q.selectQuery.vars ?? {});
|
|
713
|
+
const hash = await this.dataModule.query(
|
|
714
|
+
table,
|
|
715
|
+
q.selectQuery.query,
|
|
716
|
+
params,
|
|
717
|
+
ttl,
|
|
718
|
+
q.selectQuery.plan
|
|
719
|
+
);
|
|
720
|
+
|
|
721
|
+
// Local-first paint: the hash is returned as soon as the LOCAL registration
|
|
722
|
+
// above completes — `queryState.records` is already seeded from the local
|
|
723
|
+
// cache/SSP snapshot, so `useQuery` subscribes and paints from memory with
|
|
724
|
+
// zero network on the paint path. Instant-hydrate and the `register`
|
|
725
|
+
// down-event continue in a background chain (hydrate strictly before
|
|
726
|
+
// enqueue, so a stale one-shot snapshot can never land after the sync's
|
|
727
|
+
// authoritative `_00_list_ref` overwrite). Concurrent mounts of the same
|
|
728
|
+
// query share one chain; a sequential re-mount starts a fresh one so its
|
|
729
|
+
// `register` re-enqueue keeps freshening warm data on use.
|
|
730
|
+
if (!this.pendingQueryInits.has(hash)) {
|
|
731
|
+
const chain = this.finishQueryInit(hash, q, params).finally(() => {
|
|
732
|
+
this.pendingQueryInits.delete(hash);
|
|
733
|
+
});
|
|
734
|
+
this.pendingQueryInits.set(hash, chain);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
return hash;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Background tail of {@link initQuery}: instant-hydrate (when the query is
|
|
742
|
+
* cold AND wasn't freshly preloaded) followed by enqueuing the `register`
|
|
743
|
+
* down-event. Never rejects — both halves catch and log, so `void`-ing the
|
|
744
|
+
* returned promise can't produce an unhandled rejection.
|
|
745
|
+
*
|
|
746
|
+
* The fresh-preload skip: rows a recent `preload()` persisted are already in
|
|
747
|
+
* the local cache and were part of the first paint; the register lifecycle
|
|
748
|
+
* re-syncs them authoritatively, so the one-shot hydrate fetch would be a
|
|
749
|
+
* pure duplicate round trip. "Fresh" = preloaded this session
|
|
750
|
+
* (`preloadedHashes`) or a `_00_preload` marker younger than
|
|
751
|
+
* HYDRATE_PRELOAD_FRESH. Both are per-bucket (the Set is cleared on bucket
|
|
752
|
+
* switch, the marker table lives in the bucket), so neither can claim
|
|
753
|
+
* freshness across auth contexts.
|
|
754
|
+
*/
|
|
755
|
+
private async finishQueryInit(
|
|
756
|
+
hash: string,
|
|
757
|
+
q: InnerQuery<any, any, any>,
|
|
758
|
+
params: Record<string, any>
|
|
759
|
+
): Promise<void> {
|
|
760
|
+
if (this.config.instantHydrate !== false && this.dataModule.isCold(hash)) {
|
|
761
|
+
try {
|
|
762
|
+
const preloadFresh =
|
|
763
|
+
this.preloadedHashes.has(q.hash) ||
|
|
764
|
+
(await this.dataModule.isPreloadFresh(
|
|
765
|
+
String(q.hash),
|
|
766
|
+
parseDuration(HYDRATE_PRELOAD_FRESH)
|
|
767
|
+
));
|
|
768
|
+
if (!preloadFresh) {
|
|
769
|
+
// Fence against bucket switches: rows fetched under the previous
|
|
770
|
+
// auth context must not hydrate the new bucket's query state — the
|
|
771
|
+
// rebind's re-registration refills it from the right context.
|
|
772
|
+
const epoch = this.local.epoch;
|
|
773
|
+
const [rows] = await this.remote.query<[RecordWithId[]]>(q.selectQuery.query, params);
|
|
774
|
+
if (epoch === this.local.epoch) {
|
|
775
|
+
await this.dataModule.applyHydration(hash, rows ?? []);
|
|
776
|
+
}
|
|
777
|
+
} else {
|
|
778
|
+
this.logger.debug(
|
|
779
|
+
{ hash, Category: 'sp00ky-client::Sp00kyClient::instantHydrate' },
|
|
780
|
+
'Skipping instant hydrate; preload marker fresh'
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
} catch (err) {
|
|
784
|
+
if (err instanceof StaleEpochError) {
|
|
785
|
+
this.logger.debug(
|
|
786
|
+
{ hash, Category: 'sp00ky-client::Sp00kyClient::instantHydrate' },
|
|
787
|
+
'Dropped instant hydrate from before a bucket switch'
|
|
788
|
+
);
|
|
789
|
+
} else {
|
|
790
|
+
this.logger.warn(
|
|
791
|
+
{ err, hash, Category: 'sp00ky-client::Sp00kyClient::instantHydrate' },
|
|
792
|
+
'Instant hydrate failed; proceeding with registration'
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
try {
|
|
799
|
+
await this.sync.enqueueDownEvent({
|
|
800
|
+
type: 'register',
|
|
801
|
+
payload: {
|
|
802
|
+
hash,
|
|
803
|
+
},
|
|
804
|
+
});
|
|
805
|
+
} catch (err) {
|
|
806
|
+
this.logger.error(
|
|
807
|
+
{ err, hash, Category: 'sp00ky-client::Sp00kyClient::initQuery' },
|
|
808
|
+
'Failed to enqueue register down-event'
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Smart, awaitable preload/prewarm into the LOCAL cache — without registering a
|
|
815
|
+
* live view (NO `_00_query`, NO subscription, NO TTL heartbeat).
|
|
816
|
+
*
|
|
817
|
+
* Cache-aware via a durable per-bucket freshness marker (`_00_preload`):
|
|
818
|
+
* - COLD (never preloaded in this bucket): fetch the query one-shot from the
|
|
819
|
+
* remote, persist the rows (+ embedded `.related()` children), stamp the
|
|
820
|
+
* marker — and AWAIT it. This is the "smart waiting" first load: callers can
|
|
821
|
+
* `await db.preload(...)` to hold the UI until the data is ready.
|
|
822
|
+
* - WARM (marker present): return instantly — NEVER blocks. `refresh` decides
|
|
823
|
+
* whether to also kick a one-time silent refetch (see {@link PreloadOptions}).
|
|
824
|
+
* Default `onUse` does nothing; the data freshens when the real `useQuery`
|
|
825
|
+
* mounts and registers its live view.
|
|
826
|
+
*
|
|
827
|
+
* Best-effort: any fetch failure (offline, etc.) is a no-op warn (no marker
|
|
828
|
+
* written, so it's retried next load). Deduped per session by query hash.
|
|
829
|
+
*/
|
|
830
|
+
async preload(
|
|
831
|
+
finalQuery: FinalQuery<S, any, any, any, any, any>,
|
|
832
|
+
options?: PreloadOptions
|
|
833
|
+
): Promise<void> {
|
|
834
|
+
const q = finalQuery.innerQuery;
|
|
835
|
+
if (this.preloadedHashes.has(q.hash)) return;
|
|
836
|
+
|
|
837
|
+
const tableName = q.tableName;
|
|
838
|
+
const tableSchema = this.config.schema.tables.find((t) => t.name === tableName);
|
|
839
|
+
if (!tableSchema) {
|
|
840
|
+
throw new Error(`Table ${tableName} not found`);
|
|
841
|
+
}
|
|
842
|
+
const params = parseParams(tableSchema.columns, q.selectQuery.vars ?? {});
|
|
843
|
+
const hashKey = String(q.hash);
|
|
844
|
+
|
|
845
|
+
const marker = await this.dataModule.getPreloadMarker(hashKey);
|
|
846
|
+
|
|
847
|
+
// COLD → fetch + persist + stamp, awaited so the caller can block on it.
|
|
848
|
+
if (!marker) {
|
|
849
|
+
const rowCount = await this.fetchAndPersist(q, tableName, params);
|
|
850
|
+
if (rowCount >= 0) {
|
|
851
|
+
await this.dataModule.writePreloadMarker(hashKey, rowCount);
|
|
852
|
+
this.preloadedHashes.add(q.hash);
|
|
853
|
+
}
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// WARM → never block. Mark handled for this session, then optionally refresh.
|
|
858
|
+
this.preloadedHashes.add(q.hash);
|
|
859
|
+
const refresh = options?.refresh ?? 'onUse';
|
|
860
|
+
if (refresh === 'onUse') return;
|
|
861
|
+
|
|
862
|
+
if (refresh === 'stale') {
|
|
863
|
+
const maxAgeMs = parseDuration(options?.staleTime ?? '1h');
|
|
864
|
+
if (Date.now() - marker.fetchedAt <= maxAgeMs) return; // still fresh
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// `background`, or `stale` past its staleTime → one-time silent refetch.
|
|
868
|
+
void this.fetchAndPersist(q, tableName, params).then((rowCount) => {
|
|
869
|
+
if (rowCount >= 0) return this.dataModule.writePreloadMarker(hashKey, rowCount);
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* One-shot remote fetch + local persist for a preload query. Returns the row
|
|
875
|
+
* count on success, or -1 on failure (best-effort: logged, never thrown) so
|
|
876
|
+
* the caller skips stamping the freshness marker and retries next load.
|
|
877
|
+
*/
|
|
878
|
+
private async fetchAndPersist(
|
|
879
|
+
q: InnerQuery<any, any, any>,
|
|
880
|
+
tableName: string,
|
|
881
|
+
params: Record<string, any>
|
|
882
|
+
): Promise<number> {
|
|
883
|
+
try {
|
|
884
|
+
const [rows] = await this.remote.query<[RecordWithId[]]>(q.selectQuery.query, params);
|
|
885
|
+
const list = rows ?? [];
|
|
886
|
+
await this.dataModule.persistSnapshot(tableName, list);
|
|
887
|
+
return list.length;
|
|
888
|
+
} catch (err) {
|
|
889
|
+
this.logger.warn(
|
|
890
|
+
{ err, hash: q.hash, Category: 'sp00ky-client::Sp00kyClient::preload' },
|
|
891
|
+
'Preload fetch failed; data will be fetched on demand'
|
|
892
|
+
);
|
|
893
|
+
return -1;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async queryRaw(sql: string, params: Record<string, any>, ttl: QueryTimeToLive) {
|
|
898
|
+
const tableName = sql.split('FROM ')[1].split(' ')[0];
|
|
899
|
+
return this.dataModule.query(tableName, sql, params, ttl);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async subscribe(
|
|
903
|
+
queryHash: string,
|
|
904
|
+
callback: (records: Record<string, any>[]) => void,
|
|
905
|
+
options?: { immediate?: boolean }
|
|
906
|
+
): Promise<() => void> {
|
|
907
|
+
return this.dataModule.subscribe(queryHash, callback, options);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Opt-in eager teardown for a query whose last subscriber has gone away
|
|
912
|
+
* (e.g. a viewport-windowed list cancelling an off-screen window). No-op
|
|
913
|
+
* while any subscriber remains. Tears down the remote `_00_query` view +
|
|
914
|
+
* local WASM view instead of waiting for the TTL sweep. Default behavior
|
|
915
|
+
* (no call here) keeps the view resident for cheap re-subscription.
|
|
916
|
+
*/
|
|
917
|
+
deregisterQuery(queryHash: string): void {
|
|
918
|
+
this.dataModule.deregisterQuery(queryHash);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Subscribe to a query's fetch-status changes (idle/fetching). With
|
|
923
|
+
* `{ immediate: true }` the callback fires synchronously with the current
|
|
924
|
+
* status. Powers the `useQuery` hook's `isFetching()` accessor.
|
|
925
|
+
*/
|
|
926
|
+
subscribeQueryStatus(
|
|
927
|
+
queryHash: string,
|
|
928
|
+
callback: QueryStatusCallback,
|
|
929
|
+
options?: { immediate?: boolean }
|
|
930
|
+
): () => void {
|
|
931
|
+
return this.dataModule.subscribeStatus(queryHash, callback, options);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Report the frontend processing time (ms) a client framework spent applying
|
|
936
|
+
* an update for a query (e.g. `useQuery`'s `reconcile()`), so DevTools/MCP can
|
|
937
|
+
* surface the "frontend" phase of the per-query timing breakdown.
|
|
938
|
+
*/
|
|
939
|
+
reportFrontendTiming(queryHash: string, ms: number): void {
|
|
940
|
+
this.dataModule.recordFrontendTiming(queryHash, ms);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
run<
|
|
944
|
+
B extends BackendNames<S>,
|
|
945
|
+
R extends BackendRoutes<S, B>,
|
|
946
|
+
>(backend: B, path: R, payload: RoutePayload<S, B, R>, options?: RunOptions) {
|
|
947
|
+
return this.dataModule.run(backend, path, payload, options);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
runRecurring<
|
|
951
|
+
B extends BackendNames<S>,
|
|
952
|
+
R extends BackendRoutes<S, B>,
|
|
953
|
+
>(
|
|
954
|
+
backend: B,
|
|
955
|
+
path: R,
|
|
956
|
+
payload: RoutePayload<S, B, R>,
|
|
957
|
+
options: RunOptions & { interval: number; assignedTo: string }
|
|
958
|
+
) {
|
|
959
|
+
return this.dataModule.runRecurring(backend, path, payload, options);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
pokeRecurring<B extends BackendNames<S>>(
|
|
963
|
+
backend: B,
|
|
964
|
+
path: BackendRoutes<S, B>,
|
|
965
|
+
options: { assignedTo: string }
|
|
966
|
+
) {
|
|
967
|
+
return this.dataModule.pokeRecurring(backend, path, options);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
cancelRecurring<B extends BackendNames<S>>(
|
|
971
|
+
backend: B,
|
|
972
|
+
path: BackendRoutes<S, B>,
|
|
973
|
+
options: { assignedTo: string }
|
|
974
|
+
) {
|
|
975
|
+
return this.dataModule.cancelRecurring(backend, path, options);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
bucket<B extends BucketNames<S>>(name: B): BucketHandle {
|
|
979
|
+
return new BucketHandle(name, this.remote);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
create(id: string, data: Record<string, unknown>) {
|
|
983
|
+
return this.dataModule.create(id, data);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
update(table: string, id: string, data: Record<string, unknown>, options?: UpdateOptions) {
|
|
987
|
+
return this.dataModule.update(table, id, data, options);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
delete(table: string, id: string) {
|
|
991
|
+
return this.dataModule.delete(table, id);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
async useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T> {
|
|
995
|
+
return fn(this.remote.getClient());
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Fetch SurrealDB's `session::id()` as a string. Used as a salt for
|
|
1000
|
+
* query-id hashing so two sessions for the same user get distinct
|
|
1001
|
+
* `_00_query` rows. Returns empty string if the query fails (we still
|
|
1002
|
+
* boot, just without session scoping for IDs).
|
|
1003
|
+
*/
|
|
1004
|
+
private async fetchSessionId(): Promise<string> {
|
|
1005
|
+
try {
|
|
1006
|
+
const [sid] = await this.remote.query<[string]>('RETURN <string>session::id()');
|
|
1007
|
+
return typeof sid === 'string' ? sid : '';
|
|
1008
|
+
} catch (e) {
|
|
1009
|
+
this.logger.warn(
|
|
1010
|
+
{ error: e, Category: 'sp00ky-client::Sp00kyClient::fetchSessionId' },
|
|
1011
|
+
'Failed to fetch session::id() — proceeding with empty salt'
|
|
1012
|
+
);
|
|
1013
|
+
return '';
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|