@spooky-sync/core 0.0.1-canary.16 → 0.0.1-canary.161

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