@spooky-sync/core 0.0.1-canary.103 → 0.0.1-canary.104

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.
@@ -62,4 +62,31 @@ describe('Sp00kyClient.auth.subscribe ordering invariant', () => {
62
62
  ).toBeLessThan(firstAwaitIdx);
63
63
  }
64
64
  });
65
+
66
+ it('writes the boot-bucket hint synchronously and switches buckets before sync.setCurrentUserId', () => {
67
+ const match = source.match(
68
+ /this\.auth\.subscribe\(\s*async\s*\(\s*userId\s*[^)]*\)\s*=>\s*\{([\s\S]*?)\n {6}\}\s*\)/
69
+ );
70
+ expect(match).not.toBeNull();
71
+ const stripped = match![1]
72
+ .split('\n')
73
+ .map((line) => line.replace(/\/\/.*$/, ''))
74
+ .join('\n');
75
+
76
+ const hintIdx = stripped.indexOf('writeBootBucketHint(');
77
+ const bucketIdx = stripped.indexOf('this.ensureLocalBucket(userId)');
78
+ const syncUserIdx = stripped.indexOf('this.sync.setCurrentUserId(userId)');
79
+ const firstAwaitIdx = stripped.search(/\bawait\b/);
80
+
81
+ // The hint must be written before the first await: a reload landing
82
+ // mid-switch has to boot straight into the target bucket.
83
+ expect(hintIdx, 'writeBootBucketHint must appear in the auth.subscribe body').toBeGreaterThanOrEqual(0);
84
+ expect(hintIdx, 'writeBootBucketHint must run before the first await').toBeLessThan(firstAwaitIdx);
85
+
86
+ // The bucket switch must complete before the sync module re-registers
87
+ // LIVE/poll for the new user — those immediately write to the local store.
88
+ expect(bucketIdx, 'ensureLocalBucket must appear in the auth.subscribe body').toBeGreaterThanOrEqual(0);
89
+ expect(syncUserIdx, 'sync.setCurrentUserId must appear in the auth.subscribe body').toBeGreaterThanOrEqual(0);
90
+ expect(bucketIdx, 'ensureLocalBucket must run before sync.setCurrentUserId').toBeLessThan(syncUserIdx);
91
+ });
65
92
  });
package/src/sp00ky.ts CHANGED
@@ -42,6 +42,7 @@ import { CrdtManager, CrdtField } from './modules/crdt/index';
42
42
  import { FeatureFlagModule, FeatureFlagHandle } from './modules/feature-flag/index';
43
43
  import type { FeatureFlagOptions } from './modules/feature-flag/index';
44
44
  import { LocalStoragePersistenceClient } from './services/persistence/localstorage';
45
+ import { ANON_USER_ID, bucketIdForUser } from './modules/ref-tables';
45
46
  import { parseParams, encodeRecordId } from './utils/index';
46
47
  import { SurrealDBPersistenceClient } from './services/persistence/surrealdb';
47
48
  import { ResilientPersistenceClient } from './services/persistence/resilient';
@@ -87,6 +88,33 @@ export class BucketHandle {
87
88
  }
88
89
  }
89
90
 
91
+ /**
92
+ * Boot hint for which local bucket to open before auth resolves. Written to
93
+ * PLAIN localStorage (never the configured persistenceClient): the surrealdb
94
+ * persistence client stores its keys INSIDE a bucket, and the whole point of
95
+ * the hint is to pick the bucket before any bucket is open. A warm reload of a
96
+ * signed-in user thus opens their own bucket immediately — zero switches.
97
+ * Losing the hint is fail-closed: boot lands on the anon bucket and the auth
98
+ * callback switches to the user's bucket (cache + outbox intact).
99
+ */
100
+ const LAST_BUCKET_KEY = 'sp00ky:last_bucket';
101
+
102
+ function readBootBucketHint(): string | null {
103
+ try {
104
+ return typeof localStorage !== 'undefined' ? localStorage.getItem(LAST_BUCKET_KEY) : null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ function writeBootBucketHint(bucketId: string): void {
111
+ try {
112
+ if (typeof localStorage !== 'undefined') localStorage.setItem(LAST_BUCKET_KEY, bucketId);
113
+ } catch {
114
+ /* private-mode storage errors: boot just falls back to the anon bucket */
115
+ }
116
+ }
117
+
90
118
  export class Sp00kyClient<S extends SchemaStructure> {
91
119
  private local: LocalDatabaseService;
92
120
  private remote: RemoteDatabaseService;
@@ -344,9 +372,12 @@ export class Sp00kyClient<S extends SchemaStructure> {
344
372
  'Sp00kyClient initialization started'
345
373
  );
346
374
  try {
347
- await this.local.connect();
375
+ // Open the bucket the last session used (per-user local stores). If auth
376
+ // resolves to a different user below, the auth callback switches buckets.
377
+ const bootBucket = readBootBucketHint() ?? ANON_USER_ID;
378
+ await this.local.connect(bootBucket);
348
379
  this.logger.debug(
349
- { Category: 'sp00ky-client::Sp00kyClient::init' },
380
+ { bootBucket, Category: 'sp00ky-client::Sp00kyClient::init' },
350
381
  'Local database connected'
351
382
  );
352
383
 
@@ -359,6 +390,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
359
390
  'Remote database connected'
360
391
  );
361
392
 
393
+ this.streamProcessor.setStateKeySuffix(bootBucket);
362
394
  await this.streamProcessor.init();
363
395
  // Seed table `select` permissions from the schema before any query is
364
396
  // registered — otherwise the SSP default-denies every non-`_00_` table.
@@ -414,6 +446,13 @@ export class Sp00kyClient<S extends SchemaStructure> {
414
446
  this.auth.currentUser?.id ? encodeRecordId(this.auth.currentUser.id) : null,
415
447
  this.auth.access
416
448
  );
449
+ // Record the target bucket synchronously (still before the first
450
+ // `await`) so a reload mid-switch boots straight into the right store.
451
+ writeBootBucketHint(bucketIdForUser(userId));
452
+ // FIRST await: swap the local store to this user's bucket. Serialized
453
+ // + latest-target-wins internally; no-op when the bucket already
454
+ // matches (the boot-hint warm path).
455
+ await this.ensureLocalBucket(userId);
417
456
  const next = await this.fetchSessionId();
418
457
  this.dataModule.setSessionId(next);
419
458
  this.crdtManager.setSessionId(next);
@@ -449,6 +488,97 @@ export class Sp00kyClient<S extends SchemaStructure> {
449
488
  }
450
489
  }
451
490
 
491
+ // Serializes bucket switches from rapid auth flips; `pendingBucketTarget`
492
+ // makes intermediate targets collapse (A→anon→B never opens the anon bucket).
493
+ private bucketSwitchChain: Promise<void> = Promise.resolve();
494
+ private pendingBucketTarget: string | null = null;
495
+
496
+ /**
497
+ * Ensure the local store is this user's bucket, switching if needed. Called
498
+ * from the auth listener on every auth flip; concurrent calls are chained
499
+ * and superseded intermediates are skipped (latest target wins).
500
+ */
501
+ private ensureLocalBucket(userId: string | null): Promise<void> {
502
+ const target = bucketIdForUser(userId);
503
+ this.pendingBucketTarget = target;
504
+ this.bucketSwitchChain = this.bucketSwitchChain.then(async () => {
505
+ if (this.pendingBucketTarget !== target) return; // superseded by a newer flip
506
+ if (this.local.currentBucketId === target) return;
507
+ await this.doSwitchBucket(target);
508
+ });
509
+ // Isolate chain failures per-caller: a failed switch must not poison every
510
+ // future switch. The caller (auth listener) logs it.
511
+ const result = this.bucketSwitchChain;
512
+ this.bucketSwitchChain = this.bucketSwitchChain.catch(() => {});
513
+ return result;
514
+ }
515
+
516
+ /**
517
+ * The bucket-switch choreography: drain → swap → rebind.
518
+ *
519
+ * Drain: sync quiesced (poll/LIVE stopped, in-flight round awaited so its
520
+ * outbox delete lands in the OLD bucket, debounce timers cancelled),
521
+ * DataModule timers cleared, CRDT fields closed WITHOUT their final flush
522
+ * (the remote session already belongs to the next user).
523
+ *
524
+ * Swap: gate closes so any local query issued mid-switch (sibling auth
525
+ * subscribers, FeatureFlagModule) waits and then runs against the NEW
526
+ * bucket; store swaps open-new-before-close-old; schema provisions
527
+ * (no-op for a returning bucket); stale `_00_query` rows are wiped (dead
528
+ * sessionId-salted hashes with stale arrays — record bodies stay warm);
529
+ * SSP resets to a fresh circuit with re-seeded permissions.
530
+ *
531
+ * Rebind: auth token re-persisted (the surrealdb persistence client wrote it
532
+ * into the OLD bucket's `_00_kv` before this listener ran), active queries
533
+ * re-homed keeping their hashes, sync resumed on the new bucket's own
534
+ * outbox, and every query re-registered remotely to refill from the server.
535
+ */
536
+ private async doSwitchBucket(target: string): Promise<void> {
537
+ this.logger.info(
538
+ { target, from: this.local.currentBucketId, Category: 'sp00ky-client::Sp00kyClient::doSwitchBucket' },
539
+ 'Switching local bucket'
540
+ );
541
+
542
+ await this.sync.prepareBucketSwitch();
543
+ this.dataModule.quiesce();
544
+ this.crdtManager.closeAll({ flush: false });
545
+
546
+ const reopen = this.local.beginSwitch();
547
+ try {
548
+ await this.local.switchStore(target);
549
+ await this.migrator.provision(this.config.schemaSurql);
550
+ await this.local.queryUngated('DELETE _00_query;');
551
+ this.streamProcessor.setStateKeySuffix(target);
552
+ await this.streamProcessor.reset();
553
+ this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
554
+ this.cache.clearVersionLookups();
555
+ } finally {
556
+ reopen();
557
+ }
558
+
559
+ if (this.auth.token) {
560
+ try {
561
+ await this.persistenceClient.set('sp00ky_auth_token', this.auth.token);
562
+ } catch (e) {
563
+ this.logger.warn(
564
+ { error: e, Category: 'sp00ky-client::Sp00kyClient::doSwitchBucket' },
565
+ 'Failed to re-persist auth token into the new bucket'
566
+ );
567
+ }
568
+ }
569
+
570
+ const hashes = await this.dataModule.rebindAfterBucketSwitch();
571
+ await this.sync.completeBucketSwitch();
572
+ for (const hash of hashes) {
573
+ this.sync.enqueueDownEvent({ type: 'register', payload: { hash } });
574
+ }
575
+
576
+ this.logger.info(
577
+ { target, queries: hashes.length, Category: 'sp00ky-client::Sp00kyClient::doSwitchBucket' },
578
+ 'Local bucket switch complete'
579
+ );
580
+ }
581
+
452
582
  async close() {
453
583
  await this.featureFlags.closeAll();
454
584
  this.crdtManager.closeAll();
package/src/types.ts CHANGED
@@ -255,10 +255,13 @@ export type QueryConfigRecord = QueryConfig & { id: string };
255
255
 
256
256
  /**
257
257
  * Runtime fetch status of a live query.
258
- * - `idle`: not currently fetching missing records.
259
- * - `fetching`: the sync engine is fetching/ingesting missing records for this
260
- * query. UI notifications are coalesced so the result lands as a single
261
- * update once fetching completes.
258
+ * - `idle`: registered, initial sync completed, and not currently fetching
259
+ * missing records the materialized rows are authoritative (a windowed
260
+ * query's short result really is the end of the list).
261
+ * - `fetching`: the query is registering (a query is born `fetching` until its
262
+ * initial remote sync completes) or the sync engine is fetching/ingesting
263
+ * missing records for it. Any pending debounced result is flushed BEFORE the
264
+ * flip back to `idle`, so idle status never races ahead of the rows.
262
265
  */
263
266
  export type QueryStatus = 'idle' | 'fetching';
264
267
 
@@ -273,12 +276,20 @@ export interface QueryState {
273
276
  /** Set once `applyHydration` has run for this query, so the cold instant-hydrate
274
277
  * path fires at most once per query (see DataModule.isCold/applyHydration). */
275
278
  hydrated?: boolean;
279
+ /** Set once `notifyQuerySynced` has emitted for this registration lifetime.
280
+ * Ephemeral (unlike the persisted `updateCount`), so a re-registered query
281
+ * always emits at least once even when its records are unchanged — otherwise
282
+ * an empty re-registered window would never notify and stay "loading". */
283
+ syncNotified?: boolean;
276
284
  /** Timer for TTL expiration. */
277
285
  ttlTimer: NodeJS.Timeout | null;
278
286
  /** TTL duration in milliseconds. */
279
287
  ttlDurationMs: number;
280
288
  /** Number of times the query has been updated. */
281
289
  updateCount: number;
290
+ /** Timestamp (ms) of the last user-visible update, or null before the first
291
+ * one. Surfaced to DevTools as `lastUpdate` — must NOT be stamped on read. */
292
+ lastUpdatedAt: number | null;
282
293
  /**
283
294
  * Rolling window of the most recent materialization-step latencies (ms).
284
295
  * Capped at MATERIALIZATION_SAMPLE_WINDOW; used to recompute p55/p90/p99