@spooky-sync/core 0.0.1-canary.153 → 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 (43) hide show
  1. package/dist/index.d.ts +151 -0
  2. package/dist/index.js +1694 -126
  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 +55 -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 +82 -0
  15. package/src/modules/devtools/storage-info.test.ts +79 -0
  16. package/src/modules/devtools/storage-info.ts +116 -0
  17. package/src/modules/sync/queue/queue-up.forwarded.test.ts +51 -0
  18. package/src/modules/sync/queue/queue-up.ts +74 -38
  19. package/src/modules/sync/scheduler.ts +4 -2
  20. package/src/modules/sync/sync.ts +186 -8
  21. package/src/services/database/cache-engine.ts +8 -0
  22. package/src/services/database/engine-factory.ts +3 -2
  23. package/src/services/database/sqlite-cache-engine.test.ts +214 -85
  24. package/src/services/database/sqlite-cache-engine.ts +345 -76
  25. package/src/services/database/sqlite-open.ts +36 -1
  26. package/src/services/database/sqlite-select.test.ts +13 -46
  27. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  28. package/src/services/database/sqlite-transport.ts +219 -0
  29. package/src/services/database/sqlite-worker.ts +340 -55
  30. package/src/services/database/surreal-cache-engine.ts +2 -0
  31. package/src/services/stream-processor/index.ts +11 -2
  32. package/src/services/tabs/broker-client.ts +283 -0
  33. package/src/services/tabs/broker.test.ts +278 -0
  34. package/src/services/tabs/coordinator.test.ts +192 -0
  35. package/src/services/tabs/coordinator.ts +567 -0
  36. package/src/services/tabs/fake-ports.fixture.ts +70 -0
  37. package/src/services/tabs/leader-locks.ts +75 -0
  38. package/src/services/tabs/protocol.ts +239 -0
  39. package/src/services/tabs/support.ts +36 -0
  40. package/src/services/tabs/tabs-broker-worker.ts +581 -0
  41. package/src/sp00ky.ts +164 -6
  42. package/src/types.ts +25 -0
  43. package/tsdown.config.ts +19 -10
@@ -22,6 +22,9 @@ import type { CacheModule } from '../cache/index';
22
22
  import type { DataModule } from '../data/index';
23
23
  import { classifySyncError, encodeRecordId, extractIdPart, extractTablePart, surql } from '../../utils/index';
24
24
  import { ANON_USER_ID, DEFAULT_REF_MODE, listRefTableFor, RefMode } from '../ref-tables';
25
+ import { mutationOwnerTabId } from '../data/mutation-id';
26
+ import type { LeaderSyncHub, SyncForwarder } from '../../services/tabs/coordinator';
27
+ import { parseRecordIdString } from '../../utils/index';
25
28
 
26
29
  /**
27
30
  * Tunables for `Sp00kySync` construction.
@@ -75,6 +78,15 @@ export class Sp00kySync<S extends SchemaStructure> {
75
78
  // subscription in `Sp00kyClient`; null when unauthenticated.
76
79
  private currentUserId: string | null = null;
77
80
 
81
+ // ---- shared-tabs role state ----
82
+ // Followers keep their own remote WS (registration, per-query sync, poll)
83
+ // but must never drain the shared outbox or hold a second list_ref LIVE.
84
+ // The leader relays its LIVE events and routes rollbacks by mutation owner.
85
+ private tabRole: 'solo' | 'leader' | 'follower' = 'solo';
86
+ private tabId: string | null = null;
87
+ private hub: LeaderSyncHub | null = null;
88
+ private forwarder: SyncForwarder | null = null;
89
+
78
90
  private refMode: RefMode = DEFAULT_REF_MODE;
79
91
 
80
92
  // When true, an unauthenticated client still runs the `_00_list_ref` poll
@@ -366,7 +378,7 @@ export class Sp00kySync<S extends SchemaStructure> {
366
378
  public async init() {
367
379
  if (this.isInit) throw new Error('Sp00kySync is already initialized');
368
380
  this.isInit = true;
369
- await this.scheduler.init();
381
+ await this.scheduler.init({ loadOutbox: this.tabRole !== 'follower' });
370
382
  this.subscribeToReconnect();
371
383
  void this.scheduler.syncUp();
372
384
  void this.scheduler.syncDown();
@@ -393,6 +405,116 @@ export class Sp00kySync<S extends SchemaStructure> {
393
405
  }
394
406
  }
395
407
 
408
+ // ---- shared-tabs roles ------------------------------------------------------
409
+
410
+ /** Set BEFORE init(): shapes what init boots (a follower loads no outbox and
411
+ * never starts LIVE; its own registration/poll paths stay untouched). */
412
+ public setTabContext(role: 'solo' | 'leader' | 'follower', tabId: string | null): void {
413
+ this.tabRole = role;
414
+ this.tabId = tabId;
415
+ }
416
+
417
+ /** Leader duties: drain the shared outbox, own the single list_ref LIVE,
418
+ * relay LIVE events and rollbacks to followers via `hub`. Idempotent for a
419
+ * boot-time leader; a runtime promotion (failover) reloads the outbox,
420
+ * which now holds EVERY tab's rows, and restarts LIVE under this session. */
421
+ public async promoteToLeader(hub: LeaderSyncHub): Promise<void> {
422
+ this.tabRole = 'leader';
423
+ this.hub = hub;
424
+ this.forwarder = null;
425
+ hub.onFollowerMessage = (tabId, msg) => {
426
+ void tabId;
427
+ switch (msg.type) {
428
+ case 'sync-hello':
429
+ break;
430
+ case 'mutation-enqueued':
431
+ void this.enqueueForwardedMutation(msg.mutationId);
432
+ break;
433
+ case 'request-poll':
434
+ this.listRefIdleStreak = 0;
435
+ break;
436
+ }
437
+ };
438
+ if (this.isInit) {
439
+ await this.upQueue.loadFromDatabase();
440
+ void this.scheduler.syncUp();
441
+ if (this.currentUserId || this.anonLiveEnabled) {
442
+ this.startListRefPoll();
443
+ await this.restartRefLiveQuery().catch((err) => {
444
+ this.logger.warn(
445
+ { err, Category: 'sp00ky-client::Sp00kySync::promoteToLeader' },
446
+ 'LIVE restart failed on promotion; poll fallback covers it'
447
+ );
448
+ });
449
+ }
450
+ }
451
+ }
452
+
453
+ /** Follower duties: no outbox drain, no LIVE. Mutations forward to the
454
+ * leader; everything else (registration, per-query sync, poll) runs
455
+ * against this tab's own remote session as usual. */
456
+ public demoteToFollower(forwarder: SyncForwarder): void {
457
+ this.tabRole = 'follower';
458
+ this.hub = null;
459
+ this.forwarder = forwarder;
460
+ void this.killRefLiveQuery();
461
+ forwarder.onLeaderMessage = (msg) => {
462
+ switch (msg.type) {
463
+ case 'list-ref-change':
464
+ void this.applyRelayedListRefChange(msg).catch((err) => {
465
+ this.logger.error(
466
+ { err, Category: 'sp00ky-client::Sp00kySync::relay' },
467
+ 'Relayed list_ref change failed'
468
+ );
469
+ });
470
+ break;
471
+ case 'mutation-rolled-back':
472
+ this.events.emit(SyncEventTypes.MutationRolledBack, {
473
+ eventType: msg.eventType,
474
+ recordId: msg.recordId,
475
+ error: msg.error,
476
+ });
477
+ break;
478
+ default:
479
+ // db-ready and ingest-relay are handled by the engine/cache wiring
480
+ // in Sp00kyClient before messages reach this handler.
481
+ break;
482
+ }
483
+ };
484
+ }
485
+
486
+ /** A forwarded outbox row from a follower: load + drain it. Idempotent. */
487
+ public async enqueueForwardedMutation(mutationId: string): Promise<void> {
488
+ if (this.tabRole !== 'leader') return;
489
+ await this.upQueue.enqueueFromDatabase(mutationId);
490
+ }
491
+
492
+ /** A relayed `_00_list_ref` LIVE event: resolve against THIS tab's queries
493
+ * and run the exact same handling the LIVE subscription would have. */
494
+ private async applyRelayedListRefChange(msg: {
495
+ action: 'CREATE' | 'UPDATE' | 'DELETE';
496
+ queryId: string;
497
+ recordId: string;
498
+ version: number;
499
+ parent: boolean;
500
+ }): Promise<void> {
501
+ const queryId = parseRecordIdString(msg.queryId);
502
+ // Foreign query (another tab's session-salted hash): not ours, ignore.
503
+ if (!this.dataModule.getQueryById(queryId)) return;
504
+ const recordId = parseRecordIdString(msg.recordId);
505
+ if (msg.parent) {
506
+ await this.handleRemoteSubqueryChange(msg.action, queryId, recordId, msg.version);
507
+ } else {
508
+ await this.handleRemoteListRefChange(msg.action, queryId, recordId, msg.version);
509
+ }
510
+ }
511
+
512
+ /** One immediate poll cycle (failover convergence). */
513
+ public async forcePollRound(): Promise<void> {
514
+ this.listRefIdleStreak = 0;
515
+ await this.pollListRefForActiveQueries().catch(() => false);
516
+ }
517
+
396
518
  /**
397
519
  * Quiesce all sync activity ahead of a local-bucket switch. After this
398
520
  * resolves, nothing in the sync module writes to the local store: the poll
@@ -772,6 +894,9 @@ export class Sp00kySync<S extends SchemaStructure> {
772
894
  }
773
895
 
774
896
  private async startRefLiveQueries() {
897
+ // Shared-tabs follower: exactly one LIVE per user exists, on the leader;
898
+ // its events reach this tab through the relay.
899
+ if (this.tabRole === 'follower') return;
775
900
  const tableName = this.listRefTable();
776
901
  this.logger.debug(
777
902
  { tableName, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
@@ -837,6 +962,20 @@ export class Sp00kySync<S extends SchemaStructure> {
837
962
  this.lastLiveEventAt = Date.now();
838
963
  this.listRefIdleStreak = 0;
839
964
 
965
+ // Shared-tabs leader: the list_ref table is USER-scoped, so this LIVE also
966
+ // carries events for FOLLOWER tabs' queries (their own session-salted
967
+ // hashes). Relay every primary event; each follower resolves the queryId
968
+ // against its own DataModule and ignores foreign ones. Then continue with
969
+ // this tab's own handling below.
970
+ this.hub?.broadcast({
971
+ type: 'list-ref-change',
972
+ action,
973
+ queryId: encodeRecordId(queryId),
974
+ recordId: encodeRecordId(recordId),
975
+ version,
976
+ parent: false,
977
+ });
978
+
840
979
  // NOTE: DELETE is handled like CREATE/UPDATE below. When another window (or
841
980
  // this one) deletes a record, the server's SSP removes it from `_00_list_ref`
842
981
  // and the LIVE subscription delivers a DELETE here — `createDiffFromDbOp`
@@ -846,13 +985,17 @@ export class Sp00kySync<S extends SchemaStructure> {
846
985
  const existing = this.dataModule.getQueryById(queryId);
847
986
 
848
987
  if (!existing) {
849
- this.logger.warn(
850
- {
851
- queryId: queryId.toString(),
852
- Category: 'sp00ky-client::Sp00kySync::handleRemoteListRefChange',
853
- },
854
- 'Received remote update for unknown local query'
855
- );
988
+ // With a hub attached, an unknown query is the NORMAL case (it belongs
989
+ // to a follower tab); without one it still warrants the warning.
990
+ if (!this.hub) {
991
+ this.logger.warn(
992
+ {
993
+ queryId: queryId.toString(),
994
+ Category: 'sp00ky-client::Sp00kySync::handleRemoteListRefChange',
995
+ },
996
+ 'Received remote update for unknown local query'
997
+ );
998
+ }
856
999
  return;
857
1000
  }
858
1001
 
@@ -899,6 +1042,16 @@ export class Sp00kySync<S extends SchemaStructure> {
899
1042
 
900
1043
  if (action === 'DELETE') return;
901
1044
 
1045
+ // Relay child-edge events too (see handleRemoteListRefChange).
1046
+ this.hub?.broadcast({
1047
+ type: 'list-ref-change',
1048
+ action,
1049
+ queryId: encodeRecordId(queryId),
1050
+ recordId: encodeRecordId(childId),
1051
+ version,
1052
+ parent: true,
1053
+ });
1054
+
902
1055
  const existing = this.dataModule.getQueryById(queryId);
903
1056
  if (!existing) return;
904
1057
 
@@ -1013,6 +1166,21 @@ export class Sp00kySync<S extends SchemaStructure> {
1013
1166
  recordId,
1014
1167
  error: error.message,
1015
1168
  });
1169
+
1170
+ // Shared-tabs: the store rollback above already propagated to every tab
1171
+ // via the ingest relay; additionally deliver the EVENT to the tab that
1172
+ // owns the mutation so its UI (toasts, subscribeToRollbacks) fires there.
1173
+ const mutationId = encodeRecordId(event.mutation_id);
1174
+ const owner = mutationOwnerTabId(mutationId);
1175
+ if (this.hub && owner && this.tabId && owner !== this.tabId) {
1176
+ this.hub.sendTo(owner, {
1177
+ type: 'mutation-rolled-back',
1178
+ mutationId,
1179
+ recordId,
1180
+ eventType: event.type,
1181
+ error: error.message,
1182
+ });
1183
+ }
1016
1184
  }
1017
1185
 
1018
1186
  private async processDownEvent(event: DownEvent) {
@@ -1171,6 +1339,16 @@ export class Sp00kySync<S extends SchemaStructure> {
1171
1339
  * @param mutations Array of UpEvents (create/update/delete) to enqueue.
1172
1340
  */
1173
1341
  public async enqueueMutation(mutations: UpEvent[]) {
1342
+ // Follower: the outbox rows are already committed in the SHARED store (the
1343
+ // mutation tx went through the leader's worker); only the leader drains,
1344
+ // so hand over the ids instead of queueing locally. A notify lost in a
1345
+ // failover window is covered by the new leader's loadFromDatabase.
1346
+ if (this.tabRole === 'follower') {
1347
+ for (const m of mutations) {
1348
+ this.forwarder?.mutationEnqueued(encodeRecordId(m.mutation_id));
1349
+ }
1350
+ return;
1351
+ }
1174
1352
  this.scheduler.enqueueMutation(mutations);
1175
1353
  }
1176
1354
 
@@ -2,6 +2,7 @@ import type { QueryPlan, RelationPlan, WhereNode } from '@spooky-sync/query-buil
2
2
  import type { SealedQuery } from '../../utils/surql';
3
3
  import type { DatabaseEventSystem } from './events/index';
4
4
  import type { Sp00kyConfig, StorageHealth } from '../../types';
5
+ import type { EngineStorageDiagnostics } from '../../modules/devtools/storage-info';
5
6
 
6
7
  /**
7
8
  * A materialized row. Keys are field names; values are already decoded to the
@@ -121,6 +122,13 @@ export interface LocalStore extends LocalCacheEngine {
121
122
  getClient(): unknown;
122
123
  getConfig(): Sp00kyConfig<any>['database'];
123
124
  readonly currentBucketId: string;
125
+ /** Which built-in backend this is. OPTIONAL: absent (custom engines) is
126
+ * reported as `'custom'` by DevTools. More robust than `instanceof` for
127
+ * engines constructed outside this package. */
128
+ readonly engineKind?: 'surrealdb' | 'sqlite';
129
+ /** Engine-specific storage numbers for DevTools (DB file size, per-table
130
+ * row counts). OPTIONAL: only engines with something to report implement it. */
131
+ getStorageDiagnostics?(opts?: { tableCounts?: boolean }): Promise<EngineStorageDiagnostics>;
124
132
  /**
125
133
  * Durability of this engine's local store. OPTIONAL: engines that don't
126
134
  * report it (SurrealDB, custom engines) are treated as `'unknown'` by the
@@ -16,7 +16,8 @@ import { SqliteCacheEngine } from './sqlite-cache-engine';
16
16
  export function createLocalEngine(
17
17
  choice: LocalEngineChoice | undefined,
18
18
  config: Sp00kyConfig<any>['database'],
19
- logger: Logger
19
+ logger: Logger,
20
+ opts: { shared?: boolean } = {}
20
21
  ): LocalStore {
21
22
  if (choice === undefined || choice === 'surrealdb') {
22
23
  return new SurrealCacheEngine(config, logger);
@@ -25,7 +26,7 @@ export function createLocalEngine(
25
26
  // Mirror the SurrealDB engine's `store` semantics: 'memory' → in-memory
26
27
  // SQLite (transient), 'indexeddb' → OPFS-backed (durable).
27
28
  const useOpfs = (config.store ?? 'memory') !== 'memory';
28
- return new SqliteCacheEngine(config, logger, { useOpfs });
29
+ return new SqliteCacheEngine(config, logger, { useOpfs, shared: opts.shared });
29
30
  }
30
31
  // Custom engine instance — trust it to satisfy LocalStore.
31
32
  return choice as unknown as LocalStore;
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { RecordId } from 'surrealdb';
3
3
  import { pureWriteOpResult, SqliteCacheEngine } from './sqlite-cache-engine';
4
+ import { stubTransport } from './sqlite-transport.fixture';
4
5
  import { translateSurql } from './surql-translate';
5
6
  import type { SqlOp } from './surql-translate';
6
7
  import { surql } from '../../utils/surql';
@@ -13,42 +14,31 @@ function makeLogger(): any {
13
14
  }
14
15
 
15
16
  /**
16
- * A fake Worker that models the real SQLite worker's open/closed lifecycle:
17
- * `open` opens the DB, `close` closes it, and `exec`/`run` REJECT with
18
- * "sqlite: DB not open" when the DB isn't currently open — exactly the failure
19
- * the fix targets. Records every message `type` (across worker generations) so
20
- * tests can also inspect dispatch order.
17
+ * A fake transport handler that models the real SQLite worker's open/closed
18
+ * lifecycle: `open` opens the DB, `close` closes it, and `exec`/`run` REJECT
19
+ * with "sqlite: DB not open" when the DB isn't currently open — exactly the
20
+ * failure the fix targets. Records every message `type` (across transport
21
+ * generations) so tests can also inspect dispatch order.
21
22
  */
22
- class FakeWorker {
23
- onmessage: ((ev: any) => void) | null = null;
24
- onerror: any = null;
25
- onmessageerror: any = null;
26
- private dbOpen = false;
27
- constructor(private log: string[]) {}
28
- postMessage(msg: any) {
29
- this.log.push(msg.type);
30
- Promise.resolve().then(() => {
31
- let ok = true;
32
- let error: string | undefined;
33
- let rest: Record<string, unknown> = {};
34
- if (msg.type === 'open') {
35
- this.dbOpen = true;
36
- rest = { persisted: true };
37
- } else if (msg.type === 'close') {
38
- this.dbOpen = false;
39
- } else if (msg.type === 'exec') {
40
- if (!this.dbOpen) { ok = false; error = 'sqlite: DB not open'; }
41
- else rest = { rows: [] };
42
- } else if (msg.type === 'select') {
43
- if (!this.dbOpen) { ok = false; error = 'sqlite: DB not open'; }
44
- else rest = { rows: [], relationFetches: 0 };
45
- } else if (msg.type === 'run' || msg.type === 'batch') {
46
- if (!this.dbOpen) { ok = false; error = 'sqlite: DB not open'; }
47
- }
48
- this.onmessage?.({ data: { id: msg.id, ok, error, ...rest } });
49
- });
50
- }
51
- terminate() {}
23
+ function lifecycleHandler(log: string[]) {
24
+ let dbOpen = false;
25
+ return (type: string) => {
26
+ log.push(type);
27
+ if (type === 'open') {
28
+ dbOpen = true;
29
+ return { persisted: true };
30
+ }
31
+ if (type === 'close') {
32
+ dbOpen = false;
33
+ return {};
34
+ }
35
+ if (type === 'exec' || type === 'select' || type === 'run' || type === 'batch') {
36
+ if (!dbOpen) throw new Error('sqlite: DB not open');
37
+ if (type === 'exec') return { rows: [] };
38
+ if (type === 'select') return { rows: [], relationFetches: 0 };
39
+ }
40
+ return {};
41
+ };
52
42
  }
53
43
 
54
44
  // Regression: switchBucket must run close → reopen as a single serialized
@@ -60,19 +50,7 @@ describe('SqliteCacheEngine.switchBucket serialization', () => {
60
50
  it('never dispatches an op to a closed DB during a bucket switch', async () => {
61
51
  const log: string[] = [];
62
52
  const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
63
- // Replicate the real spawnWorker's onmessage wiring (resolve/reject pending).
64
- (engine as any).spawnWorker = () => {
65
- const w = new FakeWorker(log);
66
- w.onmessage = (ev: any) => {
67
- const { id, ok, error, ...rest } = ev.data ?? {};
68
- const p = (engine as any).pending.get(id);
69
- if (!p) return;
70
- (engine as any).pending.delete(id);
71
- if (ok) p.resolve(rest);
72
- else p.reject(new Error(error));
73
- };
74
- return w as unknown as Worker;
75
- };
53
+ stubTransport(engine, lifecycleHandler(log));
76
54
 
77
55
  await engine.connect('anon');
78
56
  expect(log).toContain('open');
@@ -105,28 +83,10 @@ describe('SqliteCacheEngine system-table seeding', () => {
105
83
  it('passes the _00_* system tables to the worker open (fresh-bucket safe)', async () => {
106
84
  const msgs: any[] = [];
107
85
  const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
108
- (engine as any).spawnWorker = () => {
109
- const w: any = { onmessage: null, onerror: null, onmessageerror: null, terminate() {} };
110
- w.postMessage = (msg: any) => {
111
- msgs.push(msg);
112
- Promise.resolve().then(() => {
113
- const rest = msg.type === 'open' ? { persisted: true } : {};
114
- w.onmessage?.({ data: { id: msg.id, ok: true, ...rest } });
115
- });
116
- };
117
- // Wire pending resolution like the real spawnWorker.
118
- const inner = w.onmessage;
119
- w.onmessage = (ev: any) => {
120
- const { id, ok, error, ...rest } = ev.data ?? {};
121
- const p = (engine as any).pending.get(id);
122
- if (!p) return;
123
- (engine as any).pending.delete(id);
124
- if (ok) p.resolve(rest);
125
- else p.reject(new Error(error));
126
- void inner;
127
- };
128
- return w as unknown as Worker;
129
- };
86
+ stubTransport(engine, (type, payload) => {
87
+ msgs.push({ type, payload });
88
+ return type === 'open' ? { persisted: true } : {};
89
+ });
130
90
 
131
91
  await engine.connect('user:fresh');
132
92
  const open = msgs.find((m) => m.type === 'open');
@@ -154,21 +114,7 @@ describe('SqliteCacheEngine storage health', () => {
154
114
  logger,
155
115
  opts ?? {}
156
116
  );
157
- (engine as any).spawnWorker = () => {
158
- const w: any = { onmessage: null, onerror: null, onmessageerror: null, terminate() {} };
159
- w.postMessage = (msg: any) => {
160
- Promise.resolve().then(() => {
161
- const rest = msg.type === 'open' ? openReply : {};
162
- const { id, ok, error, ...payload } = { id: msg.id, ok: true, error: undefined, ...rest };
163
- const p = (engine as any).pending.get(id);
164
- if (!p) return;
165
- (engine as any).pending.delete(id);
166
- if (ok) p.resolve(payload);
167
- else p.reject(new Error(error));
168
- });
169
- };
170
- return w as unknown as Worker;
171
- };
117
+ stubTransport(engine, (type) => (type === 'open' ? openReply : {}));
172
118
  return { engine, logs };
173
119
  }
174
120
 
@@ -222,6 +168,189 @@ describe('SqliteCacheEngine storage health', () => {
222
168
  });
223
169
  });
224
170
 
171
+ // Storage numbers for the DevTools Storage tab: DB size via the pragmas, row
172
+ // counts on demand, and the configured-vs-effective workerSelect split. Errors
173
+ // must land in `error` (the worker may be mid bucket-switch), never throw.
174
+ describe('SqliteCacheEngine.getStorageDiagnostics', () => {
175
+ function makeEngine(execRows: (sql: string) => unknown[]) {
176
+ const noop = () => {};
177
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
178
+ logger.child = () => logger;
179
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, logger);
180
+ stubTransport(engine, (type, payload) =>
181
+ type === 'open'
182
+ ? { persisted: true }
183
+ : type === 'exec'
184
+ ? { rows: execRows(payload.sql) }
185
+ : {}
186
+ );
187
+ return engine;
188
+ }
189
+
190
+ it('reports size, freelist, and per-table counts', async () => {
191
+ const engine = makeEngine((sql) => {
192
+ if (sql.includes('pragma_page_count')) return [{ bytes: 40960, freelist: 4096 }];
193
+ if (sql.includes('sqlite_master')) return [{ name: '_00_query' }, { name: 'game' }];
194
+ if (sql.includes('COUNT(*)'))
195
+ return [
196
+ { t: '_00_query', n: 3 },
197
+ { t: 'game', n: 12 },
198
+ ];
199
+ return [];
200
+ });
201
+ await engine.connect('user:abc');
202
+
203
+ const diag = await engine.getStorageDiagnostics({ tableCounts: true });
204
+ expect(diag.engine).toBe('sqlite');
205
+ expect(diag.bucketId).toBe('user:abc');
206
+ expect(diag.dbSizeBytes).toBe(40960);
207
+ expect(diag.freelistBytes).toBe(4096);
208
+ expect(diag.tableCounts).toEqual([
209
+ { table: '_00_query', rows: 3 },
210
+ { table: 'game', rows: 12 },
211
+ ]);
212
+ // Default config: workerSelect on, never downgraded.
213
+ expect(diag.workerSelectConfigured).toBe(true);
214
+ expect(diag.workerSelectEffective).toBe(true);
215
+ });
216
+
217
+ it('skips table counts unless asked and never throws on a dead worker', async () => {
218
+ const engine = makeEngine(() => [{ bytes: 8192, freelist: 0 }]);
219
+ await engine.connect('anon');
220
+
221
+ const diag = await engine.getStorageDiagnostics();
222
+ expect(diag.tableCounts).toBeUndefined();
223
+
224
+ // No worker at all → the failure lands in `error`, not as a throw.
225
+ const cold = makeEngine(() => []);
226
+ const coldDiag = await cold.getStorageDiagnostics();
227
+ expect(coldDiag.error).toContain('not connected');
228
+ expect(coldDiag.bucketId).toBe('anon');
229
+ });
230
+ });
231
+
232
+ // Shared-tabs engine role modes: the engine object survives every role change
233
+ // (one monotonic epoch per tab), followers speak the same protocol over a
234
+ // MessagePort, and the leaderless window parks ops instead of failing the UI.
235
+ describe('SqliteCacheEngine role modes', () => {
236
+ function makeSharedEngine() {
237
+ const log: string[] = [];
238
+ const engine = new SqliteCacheEngine(
239
+ { namespace: 'n', database: 'd' } as any,
240
+ makeLogger(),
241
+ { shared: true, useOpfs: true }
242
+ );
243
+ stubTransport(engine, lifecycleHandler(log));
244
+ return { engine, log };
245
+ }
246
+
247
+ /** A fake follower dbPort: replies like the worker over postMessage. */
248
+ function fakeLeaderPort() {
249
+ const port: any = {
250
+ onmessage: null as null | ((ev: { data: any }) => void),
251
+ onmessageerror: null,
252
+ started: false,
253
+ closed: false,
254
+ start() {
255
+ this.started = true;
256
+ },
257
+ close() {
258
+ this.closed = true;
259
+ },
260
+ postMessage(msg: any) {
261
+ queueMicrotask(() => {
262
+ const rest =
263
+ msg.type === 'exec'
264
+ ? { rows: [] }
265
+ : msg.type === 'select'
266
+ ? { rows: [], relationFetches: 0 }
267
+ : {};
268
+ port.onmessage?.({ data: { id: msg.id, ok: true, wt: 0, ...rest } });
269
+ });
270
+ },
271
+ };
272
+ return port;
273
+ }
274
+
275
+ it('adoptOwner opens under the worker lock and wipes stale _00_query rows', async () => {
276
+ const { engine, log } = makeSharedEngine();
277
+ const health = await engine.adoptOwner('anon', {
278
+ workerLockName: 'sp00ky-tabs:fp:anon:worker:1',
279
+ allowMemoryFallback: false,
280
+ resumeHeld: false,
281
+ });
282
+ expect(health.status).toBe('persistent');
283
+ expect(engine.storageHealth.role).toBe('leader');
284
+ expect(log[0]).toBe('open');
285
+ expect(log).toContain('run'); // the DELETE _00_query wipe
286
+ expect(engine.currentBucketId).toBe('anon');
287
+ });
288
+
289
+ it('adoptAttached serves reads over the leader port and reports follower role', async () => {
290
+ const engine = new SqliteCacheEngine(
291
+ { namespace: 'n', database: 'd' } as any,
292
+ makeLogger(),
293
+ { shared: true }
294
+ );
295
+ const port = fakeLeaderPort();
296
+ await engine.adoptAttached(
297
+ port,
298
+ { bucketId: 'anon', storageHealth: { status: 'persistent', fallback: false } },
299
+ () => {}
300
+ );
301
+ expect(port.started).toBe(true);
302
+ expect(engine.storageHealth).toMatchObject({ status: 'persistent', role: 'follower' });
303
+ await expect(engine.getById('game', 'game:1')).resolves.toBeNull();
304
+ expect((globalThis as any).__sqliteStats.proxiedOps).toBeGreaterThan(0);
305
+ });
306
+
307
+ it('parks ops through a leader loss and releases them on promotion', async () => {
308
+ const { engine } = makeSharedEngine();
309
+ const port = fakeLeaderPort();
310
+ await engine.adoptAttached(
311
+ port,
312
+ { bucketId: 'anon', storageHealth: { status: 'persistent', fallback: false } },
313
+ () => {}
314
+ );
315
+ const epochBefore = engine.epoch;
316
+ engine.onLeaderLost('leader tab closed');
317
+ expect(engine.epoch).toBe(epochBefore + 1);
318
+
319
+ // Issued during the leaderless window: must not reject immediately.
320
+ const read = engine.getById('_00_query', 'h1');
321
+ let settled = false;
322
+ void read.finally(() => {
323
+ settled = true;
324
+ });
325
+ await new Promise((r) => setTimeout(r, 10));
326
+ expect(settled).toBe(false);
327
+
328
+ await engine.adoptOwner('anon', {
329
+ workerLockName: 'sp00ky-tabs:fp:anon:worker:2',
330
+ allowMemoryFallback: false,
331
+ resumeHeld: false,
332
+ });
333
+ await expect(read).resolves.toBeNull();
334
+ expect(engine.storageHealth.role).toBe('leader');
335
+ });
336
+
337
+ it('bumps the epoch on promotion after having had a store (fences in-flight chains)', async () => {
338
+ const { engine } = makeSharedEngine();
339
+ await engine.adoptOwner('anon', {
340
+ workerLockName: 'sp00ky-tabs:fp:anon:worker:1',
341
+ allowMemoryFallback: false,
342
+ resumeHeld: false,
343
+ });
344
+ const before = engine.epoch;
345
+ await engine.adoptOwner('anon', {
346
+ workerLockName: 'sp00ky-tabs:fp:anon:worker:3',
347
+ allowMemoryFallback: false,
348
+ resumeHeld: false,
349
+ });
350
+ expect(engine.epoch).toBeGreaterThan(before);
351
+ });
352
+ });
353
+
225
354
  // `pureWriteOpResult` is the single source of truth for what a pure-write op
226
355
  // contributes to a query's per-statement results. The batched fast path in
227
356
  // `query()` and the per-op `execOp` path BOTH route through it, so a caller that