@spooky-sync/core 0.0.1-canary.11 → 0.0.1-canary.111

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.
Files changed (86) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +1033 -372
  3. package/dist/index.js +5490 -1331
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/sqlite-worker.d.ts +1 -0
  7. package/dist/sqlite-worker.js +107 -0
  8. package/dist/types.d.ts +721 -0
  9. package/package.json +40 -9
  10. package/skills/sp00ky-core/SKILL.md +258 -0
  11. package/skills/sp00ky-core/references/auth.md +98 -0
  12. package/skills/sp00ky-core/references/config.md +76 -0
  13. package/src/build-globals.d.ts +12 -0
  14. package/src/events/events.test.ts +2 -1
  15. package/src/events/index.ts +3 -0
  16. package/src/index.ts +9 -2
  17. package/src/modules/auth/events/index.ts +2 -1
  18. package/src/modules/auth/index.ts +59 -20
  19. package/src/modules/cache/index.ts +77 -32
  20. package/src/modules/cache/types.ts +2 -2
  21. package/src/modules/crdt/crdt-field.ts +288 -0
  22. package/src/modules/crdt/crdt-hydration.test.ts +206 -0
  23. package/src/modules/crdt/index.ts +357 -0
  24. package/src/modules/data/data.rebind.test.ts +147 -0
  25. package/src/modules/data/data.status.test.ts +249 -0
  26. package/src/modules/data/index.ts +996 -125
  27. package/src/modules/data/window-query.test.ts +52 -0
  28. package/src/modules/data/window-query.ts +154 -0
  29. package/src/modules/devtools/index.ts +180 -28
  30. package/src/modules/devtools/versions.test.ts +74 -0
  31. package/src/modules/devtools/versions.ts +81 -0
  32. package/src/modules/feature-flag/index.test.ts +120 -0
  33. package/src/modules/feature-flag/index.ts +209 -0
  34. package/src/modules/ref-tables.test.ts +91 -0
  35. package/src/modules/ref-tables.ts +88 -0
  36. package/src/modules/sync/engine.ts +101 -37
  37. package/src/modules/sync/events/index.ts +9 -2
  38. package/src/modules/sync/queue/queue-down.ts +12 -5
  39. package/src/modules/sync/queue/queue-up.ts +29 -14
  40. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  41. package/src/modules/sync/scheduler.ts +73 -7
  42. package/src/modules/sync/sync.health.test.ts +149 -0
  43. package/src/modules/sync/sync.ts +1017 -62
  44. package/src/modules/sync/utils.test.ts +269 -2
  45. package/src/modules/sync/utils.ts +182 -17
  46. package/src/otel/index.ts +127 -0
  47. package/src/services/database/cache-engine.ts +143 -0
  48. package/src/services/database/database.ts +11 -11
  49. package/src/services/database/engine-factory.ts +32 -0
  50. package/src/services/database/events/index.ts +2 -1
  51. package/src/services/database/index.ts +6 -0
  52. package/src/services/database/local-migrator.ts +27 -27
  53. package/src/services/database/local.test.ts +64 -0
  54. package/src/services/database/local.ts +452 -66
  55. package/src/services/database/plan-render.test.ts +85 -0
  56. package/src/services/database/plan-render.ts +86 -0
  57. package/src/services/database/relation-resolver.test.ts +413 -0
  58. package/src/services/database/relation-resolver.ts +0 -0
  59. package/src/services/database/remote.ts +13 -13
  60. package/src/services/database/sqlite-cache-engine.test.ts +85 -0
  61. package/src/services/database/sqlite-cache-engine.ts +693 -0
  62. package/src/services/database/sqlite-worker.ts +116 -0
  63. package/src/services/database/surql-translate.ts +291 -0
  64. package/src/services/database/surreal-cache-engine.ts +122 -0
  65. package/src/services/logger/index.ts +6 -101
  66. package/src/services/persistence/localstorage.ts +2 -2
  67. package/src/services/persistence/resilient.ts +41 -0
  68. package/src/services/persistence/surrealdb.ts +10 -10
  69. package/src/services/stream-processor/index.ts +295 -38
  70. package/src/services/stream-processor/permissions.test.ts +47 -0
  71. package/src/services/stream-processor/permissions.ts +53 -0
  72. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  73. package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
  74. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  75. package/src/services/stream-processor/wasm-types.ts +18 -2
  76. package/src/sp00ky.auth-order.test.ts +92 -0
  77. package/src/sp00ky.ts +795 -0
  78. package/src/types.ts +231 -15
  79. package/src/utils/error-classification.test.ts +44 -0
  80. package/src/utils/error-classification.ts +7 -0
  81. package/src/utils/index.ts +35 -13
  82. package/src/utils/parser.ts +3 -2
  83. package/src/utils/surql.ts +24 -15
  84. package/src/utils/withRetry.test.ts +1 -1
  85. package/tsdown.config.ts +64 -1
  86. package/src/spooky.ts +0 -392
package/src/sp00ky.ts ADDED
@@ -0,0 +1,795 @@
1
+ import { DataModule } from './modules/data/index';
2
+ import type {
3
+ Sp00kyConfig,
4
+ QueryTimeToLive,
5
+ QueryStatusCallback,
6
+ Sp00kyQueryResultPromise,
7
+ PersistenceClient,
8
+ UpdateOptions,
9
+ RunOptions,
10
+ SyncHealth} from './types';
11
+ import {
12
+ LocalMigrator,
13
+ RemoteDatabaseService,
14
+ createLocalEngine,
15
+ } from './services/database/index';
16
+ import type { LocalStore } from './services/database/index';
17
+ import type { UpEvent } from './modules/sync/index';
18
+ import { Sp00kySync } from './modules/sync/index';
19
+ import type {
20
+ GetTable,
21
+ InnerQuery,
22
+ QueryOptions,
23
+ SchemaStructure,
24
+ TableModel,
25
+ TableNames,
26
+ BucketNames,
27
+ BackendNames,
28
+ BackendRoutes,
29
+ RoutePayload} from '@spooky-sync/query-builder';
30
+ import {
31
+ QueryBuilder
32
+ } from '@spooky-sync/query-builder';
33
+
34
+ import { DevToolsService } from './modules/devtools/index';
35
+ import { createLogger } from './services/logger/index';
36
+ import { AuthService } from './modules/auth/index';
37
+ import { StreamProcessorService } from './services/stream-processor/index';
38
+ import { extractSelectPermissions } from './services/stream-processor/permissions';
39
+ import { EventSystem } from './events/index';
40
+ import { CacheModule } from './modules/cache/index';
41
+ import type { RecordWithId } from './modules/cache/index';
42
+ import { CrdtManager, CrdtField } from './modules/crdt/index';
43
+ import { FeatureFlagModule, FeatureFlagHandle } from './modules/feature-flag/index';
44
+ import type { FeatureFlagOptions } from './modules/feature-flag/index';
45
+ import { LocalStoragePersistenceClient } from './services/persistence/localstorage';
46
+ import { ANON_USER_ID, bucketIdForUser } from './modules/ref-tables';
47
+ import { parseParams, encodeRecordId } from './utils/index';
48
+ import { SurrealDBPersistenceClient } from './services/persistence/surrealdb';
49
+ import { ResilientPersistenceClient } from './services/persistence/resilient';
50
+
51
+ export class BucketHandle {
52
+ constructor(private bucketName: string, private remote: RemoteDatabaseService) {}
53
+
54
+ async put(path: string, content: string | Uint8Array | Blob): Promise<void> {
55
+ await this.remote.query(`RETURN f"${this.bucketName}:/${path}".put($content);`, { content });
56
+ }
57
+
58
+ async get(path: string): Promise<unknown> {
59
+ const [result] = await this.remote.query<[unknown]>(`RETURN f"${this.bucketName}:/${path}".get();`);
60
+ return result;
61
+ }
62
+
63
+ async delete(path: string): Promise<void> {
64
+ await this.remote.query(`RETURN f"${this.bucketName}:/${path}".delete();`);
65
+ }
66
+
67
+ async exists(path: string): Promise<boolean> {
68
+ const [result] = await this.remote.query<[boolean]>(`RETURN f"${this.bucketName}:/${path}".exists();`);
69
+ return result;
70
+ }
71
+
72
+ async head(path: string): Promise<Record<string, unknown>> {
73
+ const [result] = await this.remote.query<[Record<string, unknown>]>(`RETURN f"${this.bucketName}:/${path}".head();`);
74
+ return result;
75
+ }
76
+
77
+ async copy(sourcePath: string, targetPath: string): Promise<void> {
78
+ await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".copy($target);`, { target: targetPath });
79
+ }
80
+
81
+ async rename(sourcePath: string, targetPath: string): Promise<void> {
82
+ await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".rename($target);`, { target: targetPath });
83
+ }
84
+
85
+ async list(prefix?: string): Promise<string[]> {
86
+ const p = prefix ?? '';
87
+ const [result] = await this.remote.query<[string[]]>(`RETURN f"${this.bucketName}:/${p}".list();`);
88
+ return result;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Boot hint for which local bucket to open before auth resolves. Written to
94
+ * PLAIN localStorage (never the configured persistenceClient): the surrealdb
95
+ * persistence client stores its keys INSIDE a bucket, and the whole point of
96
+ * the hint is to pick the bucket before any bucket is open. A warm reload of a
97
+ * signed-in user thus opens their own bucket immediately — zero switches.
98
+ * Losing the hint is fail-closed: boot lands on the anon bucket and the auth
99
+ * callback switches to the user's bucket (cache + outbox intact).
100
+ */
101
+ const LAST_BUCKET_KEY = 'sp00ky:last_bucket';
102
+
103
+ function readBootBucketHint(): string | null {
104
+ try {
105
+ return typeof localStorage !== 'undefined' ? localStorage.getItem(LAST_BUCKET_KEY) : null;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ function writeBootBucketHint(bucketId: string): void {
112
+ try {
113
+ if (typeof localStorage !== 'undefined') localStorage.setItem(LAST_BUCKET_KEY, bucketId);
114
+ } catch {
115
+ /* private-mode storage errors: boot just falls back to the anon bucket */
116
+ }
117
+ }
118
+
119
+ export class Sp00kyClient<S extends SchemaStructure> {
120
+ private local: LocalStore;
121
+ private remote: RemoteDatabaseService;
122
+ private persistenceClient: PersistenceClient;
123
+
124
+ private migrator: LocalMigrator;
125
+ private cache: CacheModule;
126
+ private dataModule: DataModule<S>;
127
+ private sync: Sp00kySync<S>;
128
+ private devTools: DevToolsService;
129
+ private crdtManager: CrdtManager;
130
+ private featureFlags!: FeatureFlagModule<S>;
131
+
132
+ private logger: ReturnType<typeof createLogger>;
133
+ public auth: AuthService<S>;
134
+ public streamProcessor: StreamProcessorService;
135
+
136
+ get remoteClient() {
137
+ return this.remote.getClient();
138
+ }
139
+
140
+ get localClient() {
141
+ return this.local.getClient();
142
+ }
143
+
144
+ get pendingMutationCount(): number {
145
+ return this.sync.pendingMutationCount;
146
+ }
147
+
148
+ /** Number of times the initial list_ref LIVE subscription retried on
149
+ * the most recent `setCurrentUserId` call. 0 when the SSP's
150
+ * pre-emptive user-table creation got there first; >0 when LIVE
151
+ * registration hit a "table not found" race. Exposed so the e2e
152
+ * suite can guard the pre-emptive path against regression. */
153
+ get liveRetryCount(): number {
154
+ return this.sync.liveRetryCount;
155
+ }
156
+
157
+ subscribeToPendingMutations(cb: (count: number) => void): () => void {
158
+ return this.sync.subscribeToPendingMutations(cb);
159
+ }
160
+
161
+ /** Current sync-health snapshot. See {@link Sp00kyConfig.syncHealth}. */
162
+ get syncHealth(): SyncHealth {
163
+ return this.sync.syncHealth;
164
+ }
165
+
166
+ /**
167
+ * Observe sync health. Fires immediately with the current status and again
168
+ * on every healthy↔degraded transition. Returns an unsubscribe.
169
+ */
170
+ subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
171
+ return this.sync.subscribeToSyncHealth(cb);
172
+ }
173
+
174
+ constructor(private config: Sp00kyConfig<S>) {
175
+ const logger = createLogger(config.logLevel ?? 'info', config.otelTransmit);
176
+ this.logger = logger.child({ service: 'Sp00kyClient' });
177
+
178
+ this.logger.info(
179
+ {
180
+ config: { ...config, schema: '[SchemaStructure]' },
181
+ Category: 'sp00ky-client::Sp00kyClient::constructor',
182
+ },
183
+ 'Sp00kyClient initialized'
184
+ );
185
+
186
+ // The default ('surrealdb') engine is a SurrealCacheEngine — a drop-in
187
+ // subclass of LocalDatabaseService that adds the engine-neutral verb surface
188
+ // with zero behavior change. Alternate engines (e.g. 'sqlite') require the
189
+ // raw-SurrealQL call-site migration before they can back `this.local`.
190
+ this.local = createLocalEngine(this.config.localEngine, this.config.database, logger);
191
+ this.remote = new RemoteDatabaseService(this.config.database, logger);
192
+
193
+ if (config.persistenceClient === 'surrealdb') {
194
+ this.persistenceClient = new SurrealDBPersistenceClient(this.local, logger);
195
+ } else if (config.persistenceClient === 'localstorage' || !config.persistenceClient) {
196
+ this.persistenceClient = new LocalStoragePersistenceClient(logger);
197
+ } else {
198
+ this.persistenceClient = config.persistenceClient;
199
+ }
200
+
201
+ this.persistenceClient = new ResilientPersistenceClient(this.persistenceClient, logger);
202
+
203
+ this.streamProcessor = new StreamProcessorService(
204
+ new EventSystem(['stream_update']),
205
+ this.local,
206
+ this.persistenceClient,
207
+ logger
208
+ );
209
+ this.migrator = new LocalMigrator(this.local, logger);
210
+
211
+ this.cache = new CacheModule(
212
+ this.local,
213
+ this.streamProcessor,
214
+ (update) => {
215
+ // Direct callback from cache to data module
216
+ this.dataModule.onStreamUpdate(update);
217
+ },
218
+ logger
219
+ );
220
+
221
+ // Initialize CRDT Manager. `local` is used to read the initial
222
+ // `_00_crdt` snapshot when a field opens AND to mirror every local
223
+ // edit (so reload/offline see the freshest state); `remote` is used
224
+ // for the debounced outgoing UPSERTs and the parent-table LIVE feed.
225
+ // The debounce window is configurable via `crdtDebounceMs`.
226
+ this.crdtManager = new CrdtManager(
227
+ this.config.schema,
228
+ this.local,
229
+ this.remote,
230
+ logger,
231
+ config.crdtDebounceMs ?? 500,
232
+ );
233
+
234
+ this.dataModule = new DataModule(
235
+ this.cache,
236
+ this.local,
237
+ this.config.schema,
238
+ logger,
239
+ this.config.streamDebounceTime
240
+ );
241
+
242
+ // Initialize Auth
243
+ this.auth = new AuthService(this.config.schema, this.remote, this.persistenceClient, logger);
244
+
245
+ // Initialize Sync
246
+ this.sync = new Sp00kySync(
247
+ this.local,
248
+ this.remote,
249
+ this.cache,
250
+ this.dataModule,
251
+ this.config.schema,
252
+ this.logger,
253
+ {
254
+ refSyncIntervalMs: this.config.refSyncIntervalMs,
255
+ anonymousLiveQueries: this.config.enableAnonymousLiveQueries,
256
+ // `syncHealth: false` (or `{ degradeAfterConsecutiveFailures: 0 }`)
257
+ // disables degraded reporting; otherwise default to 3.
258
+ degradeAfterConsecutiveFailures:
259
+ this.config.syncHealth === false
260
+ ? 0
261
+ : this.config.syncHealth?.degradeAfterConsecutiveFailures ?? 3,
262
+ }
263
+ );
264
+
265
+ // Initialize feature flags. Reuses the down-queue to register SSP plans
266
+ // on `_00_user_feature` and the auth subscription to re-register handles
267
+ // when the signed-in user changes.
268
+ this.featureFlags = new FeatureFlagModule({
269
+ dataModule: this.dataModule,
270
+ sync: this.sync,
271
+ auth: this.auth,
272
+ logger,
273
+ });
274
+
275
+ // Initialize DevTools
276
+ this.devTools = new DevToolsService(
277
+ this.local,
278
+ this.remote,
279
+ logger,
280
+ this.config.schema,
281
+ this.auth,
282
+ this.dataModule
283
+ );
284
+
285
+ // Register DevTools as a receiver for stream updates
286
+ this.streamProcessor.addReceiver(this.devTools);
287
+
288
+ // Wire up callbacks instead of events
289
+ this.setupCallbacks();
290
+ }
291
+
292
+ /**
293
+ * Setup direct callbacks instead of event subscriptions
294
+ */
295
+ private setupCallbacks() {
296
+ // Surface query fetch-status changes (idle/fetching) in DevTools. Logs a
297
+ // discrete event and triggers a state push so the active-queries panel
298
+ // reflects the flip immediately.
299
+ this.dataModule.onQueryStatusChange = (queryHash, status) => {
300
+ this.devTools.logEvent('QUERY_STATUS_CHANGED', { queryHash, status });
301
+ };
302
+
303
+ // Keep an actively-watched query's remote `_00_query.lastActiveAt` fresh so
304
+ // the server TTL sweep doesn't expire it out from under live subscribers.
305
+ // DataModule fires this only while the query still has ≥1 subscriber.
306
+ this.dataModule.onHeartbeat = (queryHash) => {
307
+ void this.sync.heartbeatQuery(queryHash).catch((err) => {
308
+ this.logger.warn(
309
+ { err, queryHash, Category: 'sp00ky-client::Sp00kyClient::onHeartbeat' },
310
+ 'TTL heartbeat failed'
311
+ );
312
+ });
313
+ };
314
+
315
+ // Eager teardown of an opt-in deregistered query: enqueue a `cleanup`
316
+ // down-event so it's serialized after any in-flight register/sync for the
317
+ // same query (avoids out-of-order delete-before-create).
318
+ this.dataModule.onDeregister = (queryHash) => {
319
+ this.sync.enqueueDownEvent({ type: 'cleanup', payload: { hash: queryHash } });
320
+ };
321
+
322
+ // Mutation callback for sync
323
+ this.dataModule.onMutation((mutations: UpEvent[]) => {
324
+ // Notify DevTools
325
+ this.devTools.onMutation(mutations);
326
+
327
+ // Enqueue in Sync
328
+ if (mutations.length > 0) {
329
+ this.sync.enqueueMutation(mutations);
330
+ }
331
+ });
332
+
333
+ // Sync events for incoming updates
334
+ this.sync.events.subscribe('SYNC_QUERY_UPDATED', (event: any) => {
335
+ this.devTools.logEvent('SYNC_QUERY_UPDATED', event.payload);
336
+ });
337
+
338
+ // Hand list_ref-driven row ingests to the CrdtManager so CRDT body
339
+ // / cursor updates reach the receiver even when the cross-session
340
+ // LIVE on the parent table is filtered out by the SurrealDB
341
+ // permission-LIVE gap. Same-user clients receive these rows via
342
+ // CrdtManager's own `LIVE SELECT * FROM <table>`; this hook is the
343
+ // redundant path that fires when only the list_ref bumped.
344
+ this.sync.engineEvents.subscribe('SYNC_REMOTE_DATA_INGESTED', (event: any) => {
345
+ try {
346
+ const records: Array<Record<string, any>> = event.payload?.records ?? [];
347
+ for (const row of records) {
348
+ const id = row?.id;
349
+ const table =
350
+ id && typeof id === 'object' && id.table !== undefined
351
+ ? String(id.table)
352
+ : undefined;
353
+ if (!table) continue;
354
+ this.crdtManager.applyRow(table, row);
355
+ }
356
+ } catch (err) {
357
+ this.logger.debug(
358
+ { err, Category: 'sp00ky-client::engineEvents::ingested' },
359
+ 'applyRow forwarding from sync ingest failed'
360
+ );
361
+ }
362
+ });
363
+
364
+ // Database events for DevTools
365
+ this.local.getEvents().subscribe('DATABASE_LOCAL_QUERY', (event: any) => {
366
+ this.devTools.logEvent('LOCAL_QUERY', event.payload);
367
+ });
368
+
369
+ this.remote.getEvents().subscribe('DATABASE_REMOTE_QUERY', (event: any) => {
370
+ this.devTools.logEvent('REMOTE_QUERY', event.payload);
371
+ });
372
+ }
373
+
374
+ async init() {
375
+ this.logger.info(
376
+ { Category: 'sp00ky-client::Sp00kyClient::init' },
377
+ 'Sp00kyClient initialization started'
378
+ );
379
+ try {
380
+ // Open the bucket the last session used (per-user local stores). If auth
381
+ // resolves to a different user below, the auth callback switches buckets.
382
+ const bootBucket = readBootBucketHint() ?? ANON_USER_ID;
383
+ await this.local.connect(bootBucket);
384
+ this.logger.debug(
385
+ { bootBucket, Category: 'sp00ky-client::Sp00kyClient::init' },
386
+ 'Local database connected'
387
+ );
388
+
389
+ // Schemaless local engines (SQLite) create tables lazily and need no
390
+ // SurrealQL DDL provisioning.
391
+ if (this.local.usesSurqlSchema) {
392
+ await this.migrator.provision(this.config.schemaSurql);
393
+ this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Schema provisioned');
394
+ }
395
+
396
+ await this.remote.connect();
397
+ this.logger.debug(
398
+ { Category: 'sp00ky-client::Sp00kyClient::init' },
399
+ 'Remote database connected'
400
+ );
401
+
402
+ this.streamProcessor.setStateKeySuffix(bootBucket);
403
+ await this.streamProcessor.init();
404
+ // Seed table `select` permissions from the schema before any query is
405
+ // registered — otherwise the SSP default-denies every non-`_00_` table.
406
+ this.streamProcessor.setPermissions(
407
+ extractSelectPermissions(this.config.schemaSurql)
408
+ );
409
+ this.logger.debug(
410
+ { Category: 'sp00ky-client::Sp00kyClient::init' },
411
+ 'StreamProcessor initialized'
412
+ );
413
+
414
+ await this.auth.init();
415
+ this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Auth initialized');
416
+
417
+ // Salt query-id hashing with the SurrealDB session id so two browsers
418
+ // for the same user don't collide on shared `_00_query` rows. The same
419
+ // session id is the `session_id` key in `_00_cursor` rows, so the
420
+ // CrdtManager needs it too.
421
+ const sessionId = await this.fetchSessionId();
422
+ await this.dataModule.init(sessionId);
423
+ this.crdtManager.setSessionId(sessionId);
424
+ this.logger.debug(
425
+ { sessionId, Category: 'sp00ky-client::Sp00kyClient::init' },
426
+ 'DataModule initialized'
427
+ );
428
+
429
+ // Refresh the salt whenever auth state flips (sign-in, sign-out).
430
+ // session::id() changes per WebSocket session, and a sign-in spawns
431
+ // a new authenticated session, so the salt must follow. Also
432
+ // forward the user id into `DataModule` and `Sp00kySync` so they
433
+ // can route to per-user `_00_query_user_<id>` /
434
+ // `_00_list_ref_user_<id>` tables in `RefMode.Dedicated` — the
435
+ // LIVE subscription on `_00_list_ref_user_<id>` is restarted
436
+ // under the new auth context inside `Sp00kySync.setCurrentUserId`
437
+ // since SurrealDB binds the LIVE permission at registration time.
438
+ //
439
+ // Sync prefix BEFORE the first `await`: setting `currentUserId`
440
+ // synchronously here is critical because the AuthProvider's own
441
+ // subscribe callback runs right after ours and immediately enables
442
+ // queries that depend on the user id. Any `await` before
443
+ // `setCurrentUserId` would let those queries register against the
444
+ // stale (null) user id and hit the wrong `_00_query[_user_*]`
445
+ // table.
446
+ this.auth.subscribe(async (userId) => {
447
+ this.dataModule.setCurrentUserId(userId);
448
+ // Mirror the server's `fn::query::register` auth injection for the
449
+ // in-browser SSP: feed the current user's full record id + access
450
+ // method so `$auth`-gated table permissions (e.g. `thread`) resolve
451
+ // locally instead of being rejected. Set synchronously BEFORE the
452
+ // first `await` (like `setCurrentUserId` above) so queries that
453
+ // re-register on this auth flip see the fresh context, not a stale one.
454
+ this.streamProcessor.setSessionAuth(
455
+ this.auth.currentUser?.id ? encodeRecordId(this.auth.currentUser.id) : null,
456
+ this.auth.access
457
+ );
458
+ // Record the target bucket synchronously (still before the first
459
+ // `await`) so a reload mid-switch boots straight into the right store.
460
+ writeBootBucketHint(bucketIdForUser(userId));
461
+ // FIRST await: swap the local store to this user's bucket. Serialized
462
+ // + latest-target-wins internally; no-op when the bucket already
463
+ // matches (the boot-hint warm path).
464
+ await this.ensureLocalBucket(userId);
465
+ const next = await this.fetchSessionId();
466
+ this.dataModule.setSessionId(next);
467
+ this.crdtManager.setSessionId(next);
468
+ try {
469
+ await this.sync.setCurrentUserId(userId);
470
+ } catch (e) {
471
+ this.logger.error(
472
+ { error: e, Category: 'sp00ky-client::Sp00kyClient::authChange' },
473
+ 'sync.setCurrentUserId failed'
474
+ );
475
+ }
476
+ });
477
+
478
+ await this.sync.init();
479
+ this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Sync initialized');
480
+
481
+ this.featureFlags.init();
482
+ this.logger.debug(
483
+ { Category: 'sp00ky-client::Sp00kyClient::init' },
484
+ 'FeatureFlagModule initialized'
485
+ );
486
+
487
+ this.logger.info(
488
+ { Category: 'sp00ky-client::Sp00kyClient::init' },
489
+ 'Sp00kyClient initialization completed successfully'
490
+ );
491
+ } catch (e) {
492
+ this.logger.error(
493
+ { error: e, Category: 'sp00ky-client::Sp00kyClient::init' },
494
+ 'Sp00kyClient initialization failed'
495
+ );
496
+ throw e;
497
+ }
498
+ }
499
+
500
+ // Serializes bucket switches from rapid auth flips; `pendingBucketTarget`
501
+ // makes intermediate targets collapse (A→anon→B never opens the anon bucket).
502
+ private bucketSwitchChain: Promise<void> = Promise.resolve();
503
+ private pendingBucketTarget: string | null = null;
504
+
505
+ /**
506
+ * Ensure the local store is this user's bucket, switching if needed. Called
507
+ * from the auth listener on every auth flip; concurrent calls are chained
508
+ * and superseded intermediates are skipped (latest target wins).
509
+ */
510
+ private ensureLocalBucket(userId: string | null): Promise<void> {
511
+ const target = bucketIdForUser(userId);
512
+ this.pendingBucketTarget = target;
513
+ this.bucketSwitchChain = this.bucketSwitchChain.then(async () => {
514
+ if (this.pendingBucketTarget !== target) return; // superseded by a newer flip
515
+ if (this.local.currentBucketId === target) return;
516
+ await this.doSwitchBucket(target);
517
+ });
518
+ // Isolate chain failures per-caller: a failed switch must not poison every
519
+ // future switch. The caller (auth listener) logs it.
520
+ const result = this.bucketSwitchChain;
521
+ this.bucketSwitchChain = this.bucketSwitchChain.catch(() => {});
522
+ return result;
523
+ }
524
+
525
+ /**
526
+ * The bucket-switch choreography: drain → swap → rebind.
527
+ *
528
+ * Drain: sync quiesced (poll/LIVE stopped, in-flight round awaited so its
529
+ * outbox delete lands in the OLD bucket, debounce timers cancelled),
530
+ * DataModule timers cleared, CRDT fields closed WITHOUT their final flush
531
+ * (the remote session already belongs to the next user).
532
+ *
533
+ * Swap: gate closes so any local query issued mid-switch (sibling auth
534
+ * subscribers, FeatureFlagModule) waits and then runs against the NEW
535
+ * bucket; store swaps open-new-before-close-old; schema provisions
536
+ * (no-op for a returning bucket); stale `_00_query` rows are wiped (dead
537
+ * sessionId-salted hashes with stale arrays — record bodies stay warm);
538
+ * SSP resets to a fresh circuit with re-seeded permissions.
539
+ *
540
+ * Rebind: auth token re-persisted (the surrealdb persistence client wrote it
541
+ * into the OLD bucket's `_00_kv` before this listener ran), active queries
542
+ * re-homed keeping their hashes, sync resumed on the new bucket's own
543
+ * outbox, and every query re-registered remotely to refill from the server.
544
+ */
545
+ private async doSwitchBucket(target: string): Promise<void> {
546
+ this.logger.info(
547
+ { target, from: this.local.currentBucketId, Category: 'sp00ky-client::Sp00kyClient::doSwitchBucket' },
548
+ 'Switching local bucket'
549
+ );
550
+
551
+ await this.sync.prepareBucketSwitch();
552
+ this.dataModule.quiesce();
553
+ this.crdtManager.closeAll({ flush: false });
554
+
555
+ const reopen = this.local.beginSwitch();
556
+ try {
557
+ await this.local.switchStore(target);
558
+ if (this.local.usesSurqlSchema) {
559
+ await this.migrator.provision(this.config.schemaSurql);
560
+ }
561
+ await this.local.queryUngated('DELETE _00_query;');
562
+ this.streamProcessor.setStateKeySuffix(target);
563
+ await this.streamProcessor.reset();
564
+ this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
565
+ this.cache.clearVersionLookups();
566
+ } finally {
567
+ reopen();
568
+ }
569
+
570
+ if (this.auth.token) {
571
+ try {
572
+ await this.persistenceClient.set('sp00ky_auth_token', this.auth.token);
573
+ } catch (e) {
574
+ this.logger.warn(
575
+ { error: e, Category: 'sp00ky-client::Sp00kyClient::doSwitchBucket' },
576
+ 'Failed to re-persist auth token into the new bucket'
577
+ );
578
+ }
579
+ }
580
+
581
+ const hashes = await this.dataModule.rebindAfterBucketSwitch();
582
+ await this.sync.completeBucketSwitch();
583
+ for (const hash of hashes) {
584
+ this.sync.enqueueDownEvent({ type: 'register', payload: { hash } });
585
+ }
586
+
587
+ this.logger.info(
588
+ { target, queries: hashes.length, Category: 'sp00ky-client::Sp00kyClient::doSwitchBucket' },
589
+ 'Local bucket switch complete'
590
+ );
591
+ }
592
+
593
+ async close() {
594
+ await this.featureFlags.closeAll();
595
+ this.crdtManager.closeAll();
596
+ await this.local.close();
597
+ await this.remote.close();
598
+ }
599
+
600
+ /**
601
+ * Subscribe to a feature flag for the current user. Returns a
602
+ * `FeatureFlagHandle` whose `variant()`, `payload()` and `enabled()`
603
+ * accessors reflect the latest assignment from `_00_user_feature`,
604
+ * and whose `subscribe(cb)` fires whenever that assignment changes.
605
+ *
606
+ * Permissions are enforced by SurrealDB: a client can only ever see
607
+ * its own row, and cannot create or modify assignments.
608
+ */
609
+ feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle {
610
+ return this.featureFlags.feature(key, options);
611
+ }
612
+
613
+ authenticate(token: string) {
614
+ return this.remote.getClient().authenticate(token);
615
+ }
616
+
617
+ /**
618
+ * Open a CRDT field for collaborative editing.
619
+ * Returns a CrdtField with a LoroDoc that can be bound to any editor.
620
+ * Also starts a LIVE SELECT on the parent table for real-time sync;
621
+ * incoming events trigger a subquery fetch of `_00_crdt` / `_00_cursor`.
622
+ */
623
+ async openCrdtField(
624
+ table: string,
625
+ recordId: string,
626
+ field: string,
627
+ fallbackText?: string,
628
+ ): Promise<CrdtField> {
629
+ return this.crdtManager.open(table, recordId, field, fallbackText);
630
+ }
631
+
632
+ /**
633
+ * Close a CRDT field when editing is done.
634
+ */
635
+ closeCrdtField(table: string, recordId: string, field: string): void {
636
+ this.crdtManager.close(table, recordId, field);
637
+ }
638
+
639
+ deauthenticate() {
640
+ return this.remote.getClient().invalidate();
641
+ }
642
+
643
+ query<Table extends TableNames<S>>(
644
+ table: Table,
645
+ options: QueryOptions<TableModel<GetTable<S, Table>>, false>,
646
+ ttl: QueryTimeToLive = '10m'
647
+ ): QueryBuilder<S, Table, Sp00kyQueryResultPromise> {
648
+ return new QueryBuilder<S, Table, Sp00kyQueryResultPromise>(
649
+ this.config.schema,
650
+ table,
651
+ async (q) => ({
652
+ hash: await this.initQuery(table, q, ttl),
653
+ }),
654
+ options
655
+ );
656
+ }
657
+
658
+ private async initQuery<Table extends TableNames<S>>(
659
+ table: Table,
660
+ q: InnerQuery<any, any, any>,
661
+ ttl: QueryTimeToLive
662
+ ) {
663
+ const tableSchema = this.config.schema.tables.find((t) => t.name === table);
664
+ if (!tableSchema) {
665
+ throw new Error(`Table ${table} not found`);
666
+ }
667
+
668
+ const params = parseParams(tableSchema.columns, q.selectQuery.vars ?? {});
669
+ const hash = await this.dataModule.query(
670
+ table,
671
+ q.selectQuery.query,
672
+ params,
673
+ ttl,
674
+ q.selectQuery.plan
675
+ );
676
+
677
+ // Instant-hydrate: for a cold query, fetch its rows one-shot from the remote
678
+ // (its own surql, run directly like useRemote) and display them NOW; the full
679
+ // realtime registration proceeds below. Hydrated rows carry their `_00_rv`
680
+ // versions so the registration's syncRecords skips re-pulling unchanged bodies.
681
+ // Best-effort: any failure (e.g. offline) just falls through to registration.
682
+ if (this.config.instantHydrate !== false && this.dataModule.isCold(hash)) {
683
+ try {
684
+ const [rows] = await this.remote.query<[RecordWithId[]]>(q.selectQuery.query, params);
685
+ await this.dataModule.applyHydration(hash, rows ?? []);
686
+ } catch (err) {
687
+ this.logger.warn(
688
+ { err, hash, Category: 'sp00ky-client::Sp00kyClient::instantHydrate' },
689
+ 'Instant hydrate failed; proceeding with registration'
690
+ );
691
+ }
692
+ }
693
+
694
+ await this.sync.enqueueDownEvent({
695
+ type: 'register',
696
+ payload: {
697
+ hash,
698
+ },
699
+ });
700
+
701
+ return hash;
702
+ }
703
+
704
+ async queryRaw(sql: string, params: Record<string, any>, ttl: QueryTimeToLive) {
705
+ const tableName = sql.split('FROM ')[1].split(' ')[0];
706
+ return this.dataModule.query(tableName, sql, params, ttl);
707
+ }
708
+
709
+ async subscribe(
710
+ queryHash: string,
711
+ callback: (records: Record<string, any>[]) => void,
712
+ options?: { immediate?: boolean }
713
+ ): Promise<() => void> {
714
+ return this.dataModule.subscribe(queryHash, callback, options);
715
+ }
716
+
717
+ /**
718
+ * Opt-in eager teardown for a query whose last subscriber has gone away
719
+ * (e.g. a viewport-windowed list cancelling an off-screen window). No-op
720
+ * while any subscriber remains. Tears down the remote `_00_query` view +
721
+ * local WASM view instead of waiting for the TTL sweep. Default behavior
722
+ * (no call here) keeps the view resident for cheap re-subscription.
723
+ */
724
+ deregisterQuery(queryHash: string): void {
725
+ this.dataModule.deregisterQuery(queryHash);
726
+ }
727
+
728
+ /**
729
+ * Subscribe to a query's fetch-status changes (idle/fetching). With
730
+ * `{ immediate: true }` the callback fires synchronously with the current
731
+ * status. Powers the `useQuery` hook's `isFetching()` accessor.
732
+ */
733
+ subscribeQueryStatus(
734
+ queryHash: string,
735
+ callback: QueryStatusCallback,
736
+ options?: { immediate?: boolean }
737
+ ): () => void {
738
+ return this.dataModule.subscribeStatus(queryHash, callback, options);
739
+ }
740
+
741
+ /**
742
+ * Report the frontend processing time (ms) a client framework spent applying
743
+ * an update for a query (e.g. `useQuery`'s `reconcile()`), so DevTools/MCP can
744
+ * surface the "frontend" phase of the per-query timing breakdown.
745
+ */
746
+ reportFrontendTiming(queryHash: string, ms: number): void {
747
+ this.dataModule.recordFrontendTiming(queryHash, ms);
748
+ }
749
+
750
+ run<
751
+ B extends BackendNames<S>,
752
+ R extends BackendRoutes<S, B>,
753
+ >(backend: B, path: R, payload: RoutePayload<S, B, R>, options?: RunOptions) {
754
+ return this.dataModule.run(backend, path, payload, options);
755
+ }
756
+
757
+ bucket<B extends BucketNames<S>>(name: B): BucketHandle {
758
+ return new BucketHandle(name, this.remote);
759
+ }
760
+
761
+ create(id: string, data: Record<string, unknown>) {
762
+ return this.dataModule.create(id, data);
763
+ }
764
+
765
+ update(table: string, id: string, data: Record<string, unknown>, options?: UpdateOptions) {
766
+ return this.dataModule.update(table, id, data, options);
767
+ }
768
+
769
+ delete(table: string, id: string) {
770
+ return this.dataModule.delete(table, id);
771
+ }
772
+
773
+ async useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T> {
774
+ return fn(this.remote.getClient());
775
+ }
776
+
777
+ /**
778
+ * Fetch SurrealDB's `session::id()` as a string. Used as a salt for
779
+ * query-id hashing so two sessions for the same user get distinct
780
+ * `_00_query` rows. Returns empty string if the query fails (we still
781
+ * boot, just without session scoping for IDs).
782
+ */
783
+ private async fetchSessionId(): Promise<string> {
784
+ try {
785
+ const [sid] = await this.remote.query<[string]>('RETURN <string>session::id()');
786
+ return typeof sid === 'string' ? sid : '';
787
+ } catch (e) {
788
+ this.logger.warn(
789
+ { error: e, Category: 'sp00ky-client::Sp00kyClient::fetchSessionId' },
790
+ 'Failed to fetch session::id() — proceeding with empty salt'
791
+ );
792
+ return '';
793
+ }
794
+ }
795
+ }