@spooky-sync/core 0.0.1-canary.102 → 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.
package/dist/index.d.ts CHANGED
@@ -54,9 +54,62 @@ declare abstract class AbstractDatabaseService {
54
54
  declare class LocalDatabaseService extends AbstractDatabaseService {
55
55
  private config;
56
56
  protected eventType: "DATABASE_LOCAL_QUERY";
57
+ /** Bucket currently open. Set by `connect`/`switchStore`. */
58
+ private bucketId;
59
+ /**
60
+ * Monotonic store generation. Bumped on every `switchStore`. Async chains
61
+ * that read from the store, await something remote, and then write back
62
+ * (sync poll, SSP stream updates) capture this at chain start and drop
63
+ * their write when it no longer matches — a stale-epoch write would land
64
+ * another user's data in the new bucket.
65
+ */
66
+ private storeEpoch;
67
+ /** Gate that `query()`/`execute()` await; closed for the switch window. */
68
+ private gate;
69
+ /** The incoming client while a switch is in flight (for unload cleanup). */
70
+ private pendingSwitchClient;
57
71
  constructor(config: Sp00kyConfig<any>['database'], logger: Logger$1);
58
72
  getConfig(): Sp00kyConfig<any>['database'];
59
- connect(): Promise<void>;
73
+ get currentBucketId(): string;
74
+ get epoch(): number;
75
+ /**
76
+ * Close the query gate for a bucket switch. Every `query()`/`execute()`
77
+ * issued after this waits until the returned release fn runs — so work
78
+ * triggered mid-switch (sibling auth subscribers registering queries)
79
+ * lands on the NEW bucket instead of racing the swap. The migrator uses
80
+ * `queryUngated()` to provision the new bucket while the gate is closed.
81
+ */
82
+ beginSwitch(): () => void;
83
+ query<T extends unknown[]>(query: string, vars?: Record<string, unknown>, opts?: {
84
+ epoch?: number;
85
+ }): Promise<T>;
86
+ execute<T>(query: SealedQuery<T>, vars?: Record<string, unknown>, opts?: {
87
+ epoch?: number;
88
+ }): Promise<T>;
89
+ /** Gate-bypassing query — ONLY for the switch path itself (schema
90
+ * provisioning must run while the gate is closed, or it deadlocks). */
91
+ queryUngated<T extends unknown[]>(query: string, vars?: Record<string, unknown>): Promise<T>;
92
+ connect(bucketId?: string): Promise<void>;
93
+ /**
94
+ * Switch the local store to another user's bucket. Opens the NEW bucket on a
95
+ * second client first (with the same 3-tier recovery), then atomically swaps
96
+ * `this.client` and closes the old one — a failed open never leaves the
97
+ * service on a dead client. Bumps the store epoch so in-flight old-bucket
98
+ * async chains can detect they're stale.
99
+ *
100
+ * Callers own the drain/rebind choreography (close the gate, quiesce sync +
101
+ * timers BEFORE calling this; re-provision + rebind AFTER).
102
+ */
103
+ switchStore(bucketId: string): Promise<void>;
104
+ /**
105
+ * Open `storeUrl` on `client` with tiered recovery:
106
+ * tier 1 retries the same store (transient idb-handle races — preserves the
107
+ * cache), tier 2 drops THIS bucket's IndexedDB store and reconnects fresh,
108
+ * tier 3 falls back to `mem://` for the session. Only ever drops the bucket
109
+ * being opened — other users' buckets hold their own caches AND un-pushed
110
+ * mutation outboxes, which must survive another bucket's corruption.
111
+ */
112
+ private openWithRecovery;
60
113
  private unloadCloseRegistered;
61
114
  /**
62
115
  * Close the local DB on page unload so the SurrealDB-WASM worker releases its
@@ -67,6 +120,7 @@ declare class LocalDatabaseService extends AbstractDatabaseService {
67
120
  * reload, making warm loads as slow as cold ones. `pagehide` is the reliable
68
121
  * unload signal (fires on bfcache + normal navigation); `close()` is async but
69
122
  * the WASM worker initiates the IndexedDB connection teardown synchronously.
123
+ * Also closes a mid-switch incoming client so its fresh handle doesn't linger.
70
124
  */
71
125
  private registerUnloadClose;
72
126
  private openStore;
@@ -187,6 +241,8 @@ declare class StreamProcessorService {
187
241
  private batching;
188
242
  private batchBuffer;
189
243
  private sessionAuth;
244
+ private stateKeySuffix;
245
+ private stateGeneration;
190
246
  constructor(events: EventSystem<StreamProcessorEvents>, db: LocalDatabaseService, persistenceClient: PersistenceClient, logger: Logger);
191
247
  /**
192
248
  * Add a receiver for stream updates.
@@ -234,6 +290,19 @@ declare class StreamProcessorService {
234
290
  * This must be called before using other methods.
235
291
  */
236
292
  init(): Promise<void>;
293
+ /** Route the persisted circuit snapshot to a per-bucket key. */
294
+ setStateKeySuffix(bucketId: string): void;
295
+ private stateKey;
296
+ /**
297
+ * Drop the current WASM processor and start a fresh, empty circuit. Used on
298
+ * local-bucket switches: the old circuit holds the previous user's rows AND
299
+ * views registered with the previous `$auth` context, so neither may survive.
300
+ * Deliberately does NOT `loadState()` — a persisted snapshot references views
301
+ * under a dead sessionId salt; the DataModule rebind re-registers every live
302
+ * view against this fresh processor. Caller must re-seed `setPermissions`
303
+ * afterwards (a fresh circuit default-denies every table).
304
+ */
305
+ reset(): Promise<void>;
237
306
  loadState(): Promise<void>;
238
307
  /**
239
308
  * Seed per-table `select` permission predicates ({ [table]: whereText }).
@@ -309,6 +378,9 @@ declare class CacheModule implements StreamUpdateReceiver {
309
378
  */
310
379
  onStreamUpdate(update: StreamUpdate): void;
311
380
  lookup(recordId: string): number;
381
+ /** Drop the version cache on a bucket switch — a stale version would make
382
+ * the sync diff skip fetching a body the new bucket legitimately needs. */
383
+ clearVersionLookups(): void;
312
384
  /**
313
385
  * Save a single record to local DB and ingest into DBSP
314
386
  * Used by mutations (create/update)
@@ -360,6 +432,8 @@ declare class DataModule<S extends SchemaStructure> {
360
432
  private statusSubscriptions;
361
433
  private mutationCallbacks;
362
434
  private debounceTimers;
435
+ private pendingStreamUpdates;
436
+ private fetchDepth;
363
437
  private logger;
364
438
  /**
365
439
  * Optional observer notified whenever a query's fetch status changes.
@@ -432,6 +506,15 @@ declare class DataModule<S extends SchemaStructure> {
432
506
  * query is unknown.
433
507
  */
434
508
  setQueryStatus(queryHash: string, status: QueryStatus): void;
509
+ /**
510
+ * Enter a fetch cycle for a query. Refcounted: registration and concurrent
511
+ * poll/LIVE sync rounds can overlap on the same hash, and only the OUTERMOST
512
+ * cycle may flip the status — 0→1 emits `fetching`, and `endFetching`'s 1→0
513
+ * emits `idle`. Always pair with `endFetching` in a `finally`.
514
+ */
515
+ beginFetching(queryHash: string): void;
516
+ /** Leave a fetch cycle started with {@link beginFetching}; emits `idle` on the last exit. */
517
+ endFetching(queryHash: string): void;
435
518
  /**
436
519
  * Subscribe to mutations (for sync)
437
520
  */
@@ -440,6 +523,14 @@ declare class DataModule<S extends SchemaStructure> {
440
523
  * Handle stream updates from DBSP (via CacheModule)
441
524
  */
442
525
  onStreamUpdate(update: StreamUpdate): Promise<void>;
526
+ /**
527
+ * Process a query's pending (debounced) stream update NOW instead of on the
528
+ * trailing edge. Called by the sync engine before it flips a query back to
529
+ * `idle`, so the status change never races ahead of the rows it fetched.
530
+ * No-op when nothing is pending. The pending entry is removed before the
531
+ * await so a concurrently-firing timer can't process it twice.
532
+ */
533
+ flushPendingStreamUpdate(queryHash: string): Promise<void>;
443
534
  private materializeRecords;
444
535
  private processStreamUpdate;
445
536
  /**
@@ -528,6 +619,28 @@ declare class DataModule<S extends SchemaStructure> {
528
619
  getActiveQueryHashes(): QueryHash[];
529
620
  updateQueryLocalArray(id: string, localArray: RecordVersionArray): Promise<void>;
530
621
  updateQueryRemoteArray(hash: string, remoteArray: RecordVersionArray): Promise<void>;
622
+ /**
623
+ * Cancel every armed timer ahead of a local-bucket switch: stream-update
624
+ * debounce timers (their pending updates carry the OLD bucket's id-sets) and
625
+ * per-query TTL heartbeats (they'd refresh the previous user's remote
626
+ * `_00_query` rows under the new session). The rebind re-arms heartbeats.
627
+ */
628
+ quiesce(): void;
629
+ /**
630
+ * Re-home every active query in a freshly-opened bucket, KEEPING its hash —
631
+ * `useQuery` subscriptions are keyed by hash and don't re-register on auth
632
+ * changes, so the hooks must stay attached. Per query:
633
+ * 1. reset the sync arrays + hydration flag and drop the previous user's
634
+ * records, notifying subscribers with the new-bucket materialization
635
+ * (usually empty) so their rows leave the UI immediately;
636
+ * 2. recreate the `_00_query` row in the new bucket;
637
+ * 3. re-register the SSP view on the (fresh, post-reset) processor — this
638
+ * also rebinds the view to the NEW `$auth` context;
639
+ * 4. restart the TTL heartbeat.
640
+ * Returns the hashes so the caller can enqueue remote re-registration, which
641
+ * refills records from the server via the normal register→sync→notify path.
642
+ */
643
+ rebindAfterBucketSwitch(): Promise<QueryHash[]>;
531
644
  /**
532
645
  * Called after a query's initial sync completes.
533
646
  * Ensures subscribers are notified even if no stream updates fired (e.g. empty result set).
@@ -624,6 +737,7 @@ declare class Sp00kySync<S extends SchemaStructure> {
624
737
  private liveQueryUnsubscribe;
625
738
  private listRefPollTimer;
626
739
  private listRefPollRunning;
740
+ private listRefPollInFlight;
627
741
  readonly refSyncIntervalMs: number;
628
742
  private listRefIdleStreak;
629
743
  private stillRemoteStreaks;
@@ -674,6 +788,25 @@ declare class Sp00kySync<S extends SchemaStructure> {
674
788
  * @throws Error if already initialized.
675
789
  */
676
790
  init(): Promise<void>;
791
+ /**
792
+ * Quiesce all sync activity ahead of a local-bucket switch. After this
793
+ * resolves, nothing in the sync module writes to the local store: the poll
794
+ * loop is stopped AND its in-flight tick awaited, LIVE is killed, debounce
795
+ * timers are cancelled (their outbox rows are already persisted), and the
796
+ * scheduler has drained its in-flight queue item — including that item's
797
+ * outbox-row delete, which must land in the OLD bucket. Queued down-events
798
+ * are dropped (they reference old-bucket query rows; the post-switch rebind
799
+ * re-enqueues registrations). The old user's un-pushed outbox is deliberately
800
+ * NOT drained: the remote session already belongs to the next user.
801
+ */
802
+ prepareBucketSwitch(): Promise<void>;
803
+ /**
804
+ * Resume syncing against the freshly-opened bucket: reload the mutation
805
+ * outbox from ITS `_00_pending_mutations` (the new user's own un-pushed
806
+ * offline work) and restart the scheduler. LIVE + the list_ref poll restart
807
+ * via the `setCurrentUserId` call that follows in the auth listener.
808
+ */
809
+ completeBucketSwitch(): Promise<void>;
677
810
  /**
678
811
  * Push the authenticated user's record id from the parent client's
679
812
  * auth subscription. Tears down the existing `_00_list_ref` LIVE (if
@@ -894,7 +1027,15 @@ declare class CrdtField {
894
1027
  /** Whether the LoroDoc was loaded from saved CRDT state */
895
1028
  hasContent(): boolean;
896
1029
  startSync(local: LocalDatabaseService, remote: RemoteDatabaseService, recordId: string, sessionId: string, debounceMs: number): void;
897
- stopSync(): void;
1030
+ /**
1031
+ * Stop syncing this field. Flushes one final remote push by default so the
1032
+ * last keystrokes aren't lost. Pass `{ flush: false }` on a bucket switch —
1033
+ * the remote session already belongs to the NEXT user, and pushing this
1034
+ * (previous user's) snapshot under it would clobber the record remotely.
1035
+ */
1036
+ stopSync(options?: {
1037
+ flush?: boolean;
1038
+ }): void;
898
1039
  importRemote(state: Uint8Array): void;
899
1040
  exportSnapshot(): Uint8Array;
900
1041
  /** Push this session's cursor blob into the parent row at
@@ -969,7 +1110,14 @@ declare class CrdtManager {
969
1110
  */
970
1111
  open(table: string, recordId: string, field: string, fallbackText?: string): Promise<CrdtField>;
971
1112
  close(table: string, recordId: string, field: string): void;
972
- closeAll(): void;
1113
+ /**
1114
+ * Close every open field + table LIVE. Fields flush a final remote push by
1115
+ * default; pass `{ flush: false }` on a bucket switch, where that flush
1116
+ * would push the previous user's snapshot under the next user's session.
1117
+ */
1118
+ closeAll(options?: {
1119
+ flush?: boolean;
1120
+ }): void;
973
1121
  /** Ensure a single `LIVE SELECT * FROM <table>` is running, shared across
974
1122
  * every open CrdtField on `table`. */
975
1123
  private ensureTableSubscription;
@@ -1115,6 +1263,35 @@ declare class Sp00kyClient<S extends SchemaStructure> {
1115
1263
  */
1116
1264
  private setupCallbacks;
1117
1265
  init(): Promise<void>;
1266
+ private bucketSwitchChain;
1267
+ private pendingBucketTarget;
1268
+ /**
1269
+ * Ensure the local store is this user's bucket, switching if needed. Called
1270
+ * from the auth listener on every auth flip; concurrent calls are chained
1271
+ * and superseded intermediates are skipped (latest target wins).
1272
+ */
1273
+ private ensureLocalBucket;
1274
+ /**
1275
+ * The bucket-switch choreography: drain → swap → rebind.
1276
+ *
1277
+ * Drain: sync quiesced (poll/LIVE stopped, in-flight round awaited so its
1278
+ * outbox delete lands in the OLD bucket, debounce timers cancelled),
1279
+ * DataModule timers cleared, CRDT fields closed WITHOUT their final flush
1280
+ * (the remote session already belongs to the next user).
1281
+ *
1282
+ * Swap: gate closes so any local query issued mid-switch (sibling auth
1283
+ * subscribers, FeatureFlagModule) waits and then runs against the NEW
1284
+ * bucket; store swaps open-new-before-close-old; schema provisions
1285
+ * (no-op for a returning bucket); stale `_00_query` rows are wiped (dead
1286
+ * sessionId-salted hashes with stale arrays — record bodies stay warm);
1287
+ * SSP resets to a fresh circuit with re-seeded permissions.
1288
+ *
1289
+ * Rebind: auth token re-persisted (the surrealdb persistence client wrote it
1290
+ * into the OLD bucket's `_00_kv` before this listener ran), active queries
1291
+ * re-homed keeping their hashes, sync resumed on the new bucket's own
1292
+ * outbox, and every query re-registered remotely to refill from the server.
1293
+ */
1294
+ private doSwitchBucket;
1118
1295
  close(): Promise<void>;
1119
1296
  /**
1120
1297
  * Subscribe to a feature flag for the current user. Returns a