@spooky-sync/core 0.0.1-canary.154 → 0.0.1-canary.155

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 (39) hide show
  1. package/dist/index.d.ts +151 -0
  2. package/dist/index.js +1509 -120
  3. package/dist/{sqlite-plan-sql.js → sqlite-open.js} +83 -1
  4. package/dist/sqlite-worker.js +268 -108
  5. package/dist/tabs-broker-worker.d.ts +8 -0
  6. package/dist/tabs-broker-worker.js +434 -0
  7. package/dist/types.d.ts +25 -0
  8. package/package.json +4 -4
  9. package/scripts/check-broker-bundle.mjs +33 -0
  10. package/src/modules/cache/index.ts +35 -0
  11. package/src/modules/data/index.ts +13 -3
  12. package/src/modules/data/mutation-id.test.ts +25 -0
  13. package/src/modules/data/mutation-id.ts +35 -0
  14. package/src/modules/devtools/index.ts +9 -0
  15. package/src/modules/sync/queue/queue-up.forwarded.test.ts +51 -0
  16. package/src/modules/sync/queue/queue-up.ts +74 -38
  17. package/src/modules/sync/scheduler.ts +4 -2
  18. package/src/modules/sync/sync.ts +186 -8
  19. package/src/services/database/engine-factory.ts +3 -2
  20. package/src/services/database/sqlite-cache-engine.test.ts +160 -103
  21. package/src/services/database/sqlite-cache-engine.ts +285 -72
  22. package/src/services/database/sqlite-open.ts +36 -1
  23. package/src/services/database/sqlite-select.test.ts +13 -46
  24. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  25. package/src/services/database/sqlite-transport.ts +219 -0
  26. package/src/services/database/sqlite-worker.ts +340 -55
  27. package/src/services/stream-processor/index.ts +11 -2
  28. package/src/services/tabs/broker-client.ts +283 -0
  29. package/src/services/tabs/broker.test.ts +278 -0
  30. package/src/services/tabs/coordinator.test.ts +192 -0
  31. package/src/services/tabs/coordinator.ts +567 -0
  32. package/src/services/tabs/fake-ports.fixture.ts +70 -0
  33. package/src/services/tabs/leader-locks.ts +75 -0
  34. package/src/services/tabs/protocol.ts +239 -0
  35. package/src/services/tabs/support.ts +36 -0
  36. package/src/services/tabs/tabs-broker-worker.ts +581 -0
  37. package/src/sp00ky.ts +164 -6
  38. package/src/types.ts +25 -0
  39. package/tsdown.config.ts +19 -10
package/dist/index.d.ts CHANGED
@@ -139,6 +139,7 @@ declare class StreamProcessorService {
139
139
  private sessionAuth;
140
140
  private stateKeySuffix;
141
141
  private stateGeneration;
142
+ private persistState;
142
143
  constructor(events: EventSystem<StreamProcessorEvents>, db: LocalStore, persistenceClient: PersistenceClient, logger: Logger);
143
144
  /**
144
145
  * Add a receiver for stream updates.
@@ -199,6 +200,8 @@ declare class StreamProcessorService {
199
200
  * afterwards (a fresh circuit default-denies every table).
200
201
  */
201
202
  reset(): Promise<void>;
203
+ /** Toggle circuit-state persistence (shared-tabs follower/leader role). */
204
+ setPersistenceEnabled(enabled: boolean): void;
202
205
  loadState(): Promise<void>;
203
206
  /**
204
207
  * Seed per-table `select` permission predicates ({ [table]: whereText }).
@@ -261,18 +264,39 @@ interface CacheRecord {
261
264
  * Single responsibility: Handle all local storage operations and DBSP ingestion.
262
265
  * This module acts as the bridge between data operations and persistence.
263
266
  */
267
+ /** One ingested change, in exactly the shape `ingestMany` consumes. Shared
268
+ * with the tabs protocol so a leader can relay its ingests to followers. */
269
+ interface CacheIngestTuple {
270
+ table: string;
271
+ op: 'CREATE' | 'UPDATE' | 'DELETE';
272
+ id: string;
273
+ record: Record<string, unknown>;
274
+ }
264
275
  declare class CacheModule implements StreamUpdateReceiver {
265
276
  private local;
266
277
  private streamProcessor;
267
278
  private logger;
268
279
  private streamUpdateCallback;
269
280
  private versionLookups;
281
+ /** Shared-tabs leader: fan every committed ingest out to follower circuits.
282
+ * Fired AFTER the local tx (the rows are already in the shared store, so a
283
+ * follower only needs the circuit feed). Never set on followers. */
284
+ private ingestRelay;
270
285
  constructor(local: LocalStore, streamProcessor: StreamProcessorService, streamUpdateCallback: (update: StreamUpdate) => void, logger: Logger$1);
271
286
  /**
272
287
  * Implements StreamUpdateReceiver interface
273
288
  * Called directly by StreamProcessor when views change
274
289
  */
275
290
  onStreamUpdate(update: StreamUpdate): void;
291
+ setIngestRelay(cb: ((tuples: CacheIngestTuple[]) => void) | null): void;
292
+ /**
293
+ * Shared-tabs follower: feed relayed tuples into THIS tab's circuit only.
294
+ * The rows are already in the shared store (the leader wrote them), so no
295
+ * local write happens here; the normal chain then runs: SSP -> stream update
296
+ * -> DataModule debounce -> materializeRecords (re-reads via the port
297
+ * transport) -> this tab's subscriptions fire with this tab's hashes.
298
+ */
299
+ applyRelayedIngest(tuples: CacheIngestTuple[]): void;
276
300
  lookup(recordId: string): number;
277
301
  /** Drop the version cache on a bucket switch — a stale version would make
278
302
  * the sync diff skip fetching a body the new bucket legitimately needs. */
@@ -322,6 +346,9 @@ declare class DataModule<S extends SchemaStructure> {
322
346
  private local;
323
347
  private schema;
324
348
  private streamDebounceTime;
349
+ /** Tab identity baked into mutation ids (shared-tabs rollback routing);
350
+ * undefined in solo mode, where mutation-id falls back to a session id. */
351
+ private tabId;
325
352
  private activeQueries;
326
353
  private pendingQueries;
327
354
  private subscriptions;
@@ -367,6 +394,9 @@ declare class DataModule<S extends SchemaStructure> {
367
394
  * registered queries will get fresh, session-scoped IDs.
368
395
  */
369
396
  setSessionId(sessionId: string): void;
397
+ /** Shared-tabs: bake this tab's identity into mutation ids so a rollback of
398
+ * a follower's mutation routes back to the tab that made it. */
399
+ setTabId(tabId: string): void;
370
400
  /**
371
401
  * Update the authenticated user record id. Pass `null` on sign-out.
372
402
  * Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
@@ -625,6 +655,98 @@ declare class DataModule<S extends SchemaStructure> {
625
655
  * Parse update options to generate push event options
626
656
  */
627
657
  //#endregion
658
+ //#region src/services/tabs/protocol.d.ts
659
+ type TabId = string;
660
+ type TabRole = 'solo' | 'leader' | 'follower';
661
+ /** Broker pings every tab at this cadence. */
662
+
663
+ /** Matches `CacheIngestTuple` (modules/cache): exactly what `ingestMany`
664
+ * consumes, so relayed batches feed follower circuits without reshaping. */
665
+ interface IngestTuple {
666
+ table: string;
667
+ op: 'CREATE' | 'UPDATE' | 'DELETE';
668
+ id: string;
669
+ record: Record<string, unknown>;
670
+ }
671
+ type FollowerToLeaderMessage = {
672
+ type: 'sync-hello';
673
+ tabId: TabId;
674
+ }
675
+ /** The follower committed an outbox row (through the shared store) and the
676
+ * leader should drain it. Idempotent; a new leader's loadFromDatabase is
677
+ * the backstop for a notify lost in a failover window. */ | {
678
+ type: 'mutation-enqueued';
679
+ mutationId: string;
680
+ } | {
681
+ type: 'request-poll';
682
+ };
683
+ type LeaderToFollowerMessage = {
684
+ type: 'db-ready';
685
+ leadershipId: number;
686
+ bucketId: string;
687
+ storageHealth: StorageHealth;
688
+ }
689
+ /** Every ingest the leader's CacheModule committed, so follower circuits
690
+ * stay live without their own fetch. seq detects gaps. */ | {
691
+ type: 'ingest-relay';
692
+ tuples: IngestTuple[];
693
+ leadershipId: number;
694
+ seq: number;
695
+ }
696
+ /** A `_00_list_ref` LIVE event, relayed verbatim. Each follower resolves the
697
+ * queryId against its own DataModule and ignores foreign queries. */ | {
698
+ type: 'list-ref-change';
699
+ action: 'CREATE' | 'UPDATE' | 'DELETE';
700
+ queryId: string;
701
+ recordId: string;
702
+ version: number;
703
+ parent: boolean;
704
+ }
705
+ /** The leader's drain rolled back a mutation owned by this tab. */ | {
706
+ type: 'mutation-rolled-back';
707
+ mutationId: string;
708
+ recordId: string;
709
+ eventType: 'create' | 'update' | 'delete';
710
+ error: string;
711
+ };
712
+ //#endregion
713
+ //#region src/services/tabs/coordinator.d.ts
714
+ /** Leader-side fan-out surface handed to the sync layer. The sync router
715
+ * (modules/sync/tab-router.ts) registers itself as the message handler. */
716
+ declare class LeaderSyncHub {
717
+ readonly leadershipId: number;
718
+ private logger;
719
+ private followers;
720
+ private seq;
721
+ onFollowerMessage: ((tabId: TabId, msg: FollowerToLeaderMessage) => void) | null;
722
+ onFollowerDetached: ((tabId: TabId) => void) | null;
723
+ constructor(leadershipId: number, logger: Logger$1);
724
+ attach(tabId: TabId, port: MessagePort): void;
725
+ detach(tabId: TabId): void;
726
+ detachAll(): void;
727
+ sendTo(tabId: TabId, msg: LeaderToFollowerMessage): void;
728
+ broadcast(msg: LeaderToFollowerMessage, exceptTabId?: TabId): void;
729
+ /** Stamped ingest relay; seq lets followers detect gaps. */
730
+ relayIngest(tuples: IngestTuple[], exceptTabId?: TabId): void;
731
+ get followerCount(): number;
732
+ get relayedBatches(): number;
733
+ }
734
+ /** Follower half of the syncPort. Queues while detached (leaderless window)
735
+ * and flushes on rebind; a lost-in-flight mutation notify is additionally
736
+ * backstopped by the new leader reloading the shared outbox from the store. */
737
+ declare class SyncForwarder {
738
+ private tabId;
739
+ private port;
740
+ private queued;
741
+ onLeaderMessage: ((msg: LeaderToFollowerMessage) => void) | null;
742
+ constructor(tabId: TabId);
743
+ rebind(port: MessagePort): void;
744
+ unbind(): void;
745
+ private post;
746
+ mutationEnqueued(mutationId: string): void;
747
+ requestPoll(): void;
748
+ }
749
+ //#endregion
628
750
  //#region src/modules/sync/sync.d.ts
629
751
  /**
630
752
  * Tunables for `Sp00kySync` construction.
@@ -675,6 +797,10 @@ declare class Sp00kySync<S extends SchemaStructure> {
675
797
  private wasDisconnected;
676
798
  events: SyncEventSystem;
677
799
  private currentUserId;
800
+ private tabRole;
801
+ private tabId;
802
+ private hub;
803
+ private forwarder;
678
804
  private refMode;
679
805
  private readonly anonLiveEnabled;
680
806
  private currentLiveQueryUuid;
@@ -732,6 +858,25 @@ declare class Sp00kySync<S extends SchemaStructure> {
732
858
  * @throws Error if already initialized.
733
859
  */
734
860
  init(): Promise<void>;
861
+ /** Set BEFORE init(): shapes what init boots (a follower loads no outbox and
862
+ * never starts LIVE; its own registration/poll paths stay untouched). */
863
+ setTabContext(role: 'solo' | 'leader' | 'follower', tabId: string | null): void;
864
+ /** Leader duties: drain the shared outbox, own the single list_ref LIVE,
865
+ * relay LIVE events and rollbacks to followers via `hub`. Idempotent for a
866
+ * boot-time leader; a runtime promotion (failover) reloads the outbox,
867
+ * which now holds EVERY tab's rows, and restarts LIVE under this session. */
868
+ promoteToLeader(hub: LeaderSyncHub): Promise<void>;
869
+ /** Follower duties: no outbox drain, no LIVE. Mutations forward to the
870
+ * leader; everything else (registration, per-query sync, poll) runs
871
+ * against this tab's own remote session as usual. */
872
+ demoteToFollower(forwarder: SyncForwarder): void;
873
+ /** A forwarded outbox row from a follower: load + drain it. Idempotent. */
874
+ enqueueForwardedMutation(mutationId: string): Promise<void>;
875
+ /** A relayed `_00_list_ref` LIVE event: resolve against THIS tab's queries
876
+ * and run the exact same handling the LIVE subscription would have. */
877
+ private applyRelayedListRefChange;
878
+ /** One immediate poll cycle (failover convergence). */
879
+ forcePollRound(): Promise<void>;
735
880
  /**
736
881
  * Quiesce all sync activity ahead of a local-bucket switch. After this
737
882
  * resolves, nothing in the sync module writes to the local store: the poll
@@ -1248,6 +1393,10 @@ declare class Sp00kyClient<S extends SchemaStructure> {
1248
1393
  private logger;
1249
1394
  auth: AuthService<S>;
1250
1395
  streamProcessor: StreamProcessorService;
1396
+ private tabsCoordinator;
1397
+ private sharedActive;
1398
+ /** Current shared-tabs role, or null when the feature is off/fell back. */
1399
+ get tabRole(): TabRole | null;
1251
1400
  get remoteClient(): surrealdb0.Surreal;
1252
1401
  get localClient(): unknown;
1253
1402
  get pendingMutationCount(): number;
@@ -1275,6 +1424,8 @@ declare class Sp00kyClient<S extends SchemaStructure> {
1275
1424
  */
1276
1425
  subscribeToStorageHealth(cb: (health: StorageHealth) => void): () => void;
1277
1426
  constructor(config: Sp00kyConfig<S>);
1427
+ /** The shared-tabs role machinery, wired to this client's modules. */
1428
+ private buildTabsCoordinator;
1278
1429
  /**
1279
1430
  * Setup direct callbacks instead of event subscriptions
1280
1431
  */