@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
@@ -18,7 +18,15 @@ import {
18
18
  } from './events/index';
19
19
  import { StaleEpochError } from './local';
20
20
  import { translateSurql, tableOf, setPath, getPath, type SqlOp } from './surql-translate';
21
+ import {
22
+ BrokerPortClosedError,
23
+ PortSqliteTransport,
24
+ WorkerSqliteTransport,
25
+ type SqliteTransport,
26
+ } from './sqlite-transport';
27
+ import { PROMOTION_OPEN_OPTIONS } from './sqlite-open';
21
28
  import type { EngineTx, Id, LocalStore, OrderBy, RelationFetch, Row } from './cache-engine';
29
+ import type { EngineStorageDiagnostics } from '../../modules/devtools/storage-info';
22
30
 
23
31
  /**
24
32
  * The statement result a pure-write op contributes to a query's results array.
@@ -82,9 +90,9 @@ export function pureWriteOpResult(op: SqlOp): unknown {
82
90
  * oracle E2E in the browser.
83
91
  */
84
92
  export class SqliteCacheEngine implements LocalStore {
85
- private worker: Worker | null = null;
86
- private seq = 0;
87
- private pending = new Map<number, { resolve: (v: any) => void; reject: (e: any) => void }>();
93
+ /** The wire to the SQLite worker: an owned dedicated Worker (solo/leader) or
94
+ * a MessagePort into another tab's worker (follower). See sqlite-transport. */
95
+ private transport: SqliteTransport | null = null;
88
96
  private storeEpoch = 0;
89
97
  private knownTables = new Set<string>();
90
98
  private useOpfs: boolean;
@@ -92,6 +100,9 @@ export class SqliteCacheEngine implements LocalStore {
92
100
  * Flipped off at runtime if the worker script predates the `select` op
93
101
  * (stale cached bundle) — degrade to the legacy multi-hop path, don't break. */
94
102
  private workerSelect: boolean;
103
+ /** What `workerSelect` was at construction, so DevTools can tell a runtime
104
+ * downgrade (configured true, effective false) from a configured-off. */
105
+ private workerSelectConfigured: boolean;
95
106
  private events: DatabaseEventSystem = createDatabaseEventSystem();
96
107
  private bucketId = 'anon';
97
108
  /** Durability of the local store, set on every open. A plain Set of callbacks
@@ -102,13 +113,25 @@ export class SqliteCacheEngine implements LocalStore {
102
113
  /** Schemaless — tables are created lazily on first write; no migrator. */
103
114
  readonly usesSurqlSchema = false;
104
115
 
116
+ readonly engineKind = 'sqlite' as const;
117
+
118
+ /** Shared-tabs mode: the engine's transport is swapped at runtime by the
119
+ * TabsCoordinator (owner worker as leader, leader's port as follower). */
120
+ private shared: boolean;
121
+ /** Leaderless parking (shared mode): ops entering the opQueue await this
122
+ * gate until a new role lands or the timeout rejects them. */
123
+ private roleGate: { promise: Promise<void>; release: () => void } | null = null;
124
+ private roleGateTimer: ReturnType<typeof setTimeout> | null = null;
125
+
105
126
  constructor(
106
127
  private config: Sp00kyConfig<any>['database'],
107
128
  private logger: Logger,
108
- opts: { useOpfs?: boolean; workerSelect?: boolean } = {}
129
+ opts: { useOpfs?: boolean; workerSelect?: boolean; shared?: boolean } = {}
109
130
  ) {
110
131
  this.useOpfs = opts.useOpfs ?? true;
111
132
  this.workerSelect = opts.workerSelect ?? config.workerSelect ?? true;
133
+ this.workerSelectConfigured = this.workerSelect;
134
+ this.shared = opts.shared ?? false;
112
135
  }
113
136
 
114
137
  get epoch(): number {
@@ -143,6 +166,51 @@ export class SqliteCacheEngine implements LocalStore {
143
166
  return this.config;
144
167
  }
145
168
 
169
+ /**
170
+ * Storage numbers for the DevTools Storage tab. Uses {@link call} so the
171
+ * reads serialize with regular traffic (no SQLITE_BUSY). Never throws — the
172
+ * worker may be mid bucket-switch; a failure lands in `error` instead.
173
+ */
174
+ async getStorageDiagnostics(opts?: { tableCounts?: boolean }): Promise<EngineStorageDiagnostics> {
175
+ const diag: EngineStorageDiagnostics = {
176
+ engine: 'sqlite',
177
+ bucketId: this.bucketId,
178
+ useOpfs: this.useOpfs,
179
+ workerSelectConfigured: this.workerSelectConfigured,
180
+ workerSelectEffective: this.workerSelect,
181
+ };
182
+ try {
183
+ const { rows } = await this.call<{ rows: { bytes: number; freelist: number }[] }>('exec', {
184
+ sql:
185
+ 'SELECT (SELECT * FROM pragma_page_count()) * (SELECT * FROM pragma_page_size()) AS bytes, ' +
186
+ '(SELECT * FROM pragma_freelist_count()) * (SELECT * FROM pragma_page_size()) AS freelist',
187
+ });
188
+ diag.dbSizeBytes = rows?.[0]?.bytes;
189
+ diag.freelistBytes = rows?.[0]?.freelist;
190
+ if (opts?.tableCounts) {
191
+ const { rows: tables } = await this.call<{ rows: { name: string }[] }>('exec', {
192
+ sql: "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
193
+ });
194
+ const names = (tables ?? []).map((r) => r.name);
195
+ if (names.length) {
196
+ // Names come from sqlite_master itself; double-quoting is enough.
197
+ const sql = names
198
+ .map((n) => `SELECT '${n.replace(/'/g, "''")}' AS t, COUNT(*) AS n FROM "${n.replace(/"/g, '""')}"`)
199
+ .join(' UNION ALL ');
200
+ const { rows: counts } = await this.call<{ rows: { t: string; n: number }[] }>('exec', {
201
+ sql,
202
+ });
203
+ diag.tableCounts = (counts ?? []).map((r) => ({ table: r.t, rows: r.n }));
204
+ } else {
205
+ diag.tableCounts = [];
206
+ }
207
+ }
208
+ } catch (e) {
209
+ diag.error = e instanceof Error ? e.message : String(e);
210
+ }
211
+ return diag;
212
+ }
213
+
146
214
  getEvents(): DatabaseEventSystem {
147
215
  return this.events;
148
216
  }
@@ -163,47 +231,19 @@ export class SqliteCacheEngine implements LocalStore {
163
231
 
164
232
  // ---- worker plumbing -----------------------------------------------------
165
233
 
166
- private spawnWorker(): Worker {
167
- // Source references the `.ts` so the monorepo's src-bundling consumers
168
- // (e.g. the example app, which aliases `@spooky-sync/core` to `src`) resolve
169
- // it — Vite handles `.ts` workers. For the published package, the tsdown
170
- // build rewrites this to `./sqlite-worker.js` (the top-level emitted entry;
171
- // see tsdown.config.ts), which the flat `dist/index.js` resolves. The worker
172
- // (+ `@sqlite.org/sqlite-wasm`) still loads lazily — only when `localEngine:
173
- // 'sqlite'` is used.
174
- const worker = new Worker(new URL('./sqlite-worker.ts', import.meta.url), { type: 'module' });
175
- worker.onmessage = (ev: MessageEvent) => {
176
- const { id, ok, error, ...rest } = ev.data ?? {};
177
- const p = this.pending.get(id);
178
- if (!p) return;
179
- this.pending.delete(id);
180
- if (ok) p.resolve(rest);
181
- else p.reject(new Error(error));
182
- };
183
- // Surface a worker crash (wasm abort / OOM) instead of leaving every pending
184
- // call hung forever — reject them all with a clear error.
185
- const failAll = (msg: string) => {
186
- const err = new Error(`SQLite worker crashed: ${msg}`);
187
- this.logger.error(
188
- { err, Category: 'sp00ky-client::SqliteCacheEngine::worker' },
189
- 'Worker error'
190
- );
191
- for (const [, p] of this.pending) p.reject(err);
192
- this.pending.clear();
193
- };
194
- worker.onerror = (e: ErrorEvent) => failAll(e.message || 'onerror');
195
- worker.onmessageerror = () => failAll('messageerror');
196
- return worker;
197
- }
198
-
199
234
  /** Serializes every worker op so reads/writes never overlap at the VFS layer
200
235
  * (overlapping ops trip SQLITE_BUSY). Mirrors the SurrealDB engine's
201
- * single-flight query queue. */
236
+ * single-flight query queue. (The worker keeps its own chain too, for
237
+ * multi-client mode; this one additionally provides the queue-wait stat and
238
+ * the boot/switch atomicity below.) */
202
239
  private opQueue: Promise<unknown> = Promise.resolve();
203
240
 
204
241
  private call<T = any>(type: string, payload?: unknown): Promise<T> {
205
242
  const enqueuedAt = performance.now();
206
- const run = () => {
243
+ const run = async () => {
244
+ // Leaderless window (shared mode): park behind the role gate instead of
245
+ // failing; a new leader releases it, the timeout rejects it.
246
+ if (this.roleGate) await this.roleGate.promise;
207
247
  // Time spent waiting behind other ops in the queue, not doing work.
208
248
  getStats().queueWaitMs += performance.now() - enqueuedAt;
209
249
  return this.rawCall<T>(type, payload);
@@ -218,8 +258,7 @@ export class SqliteCacheEngine implements LocalStore {
218
258
  }
219
259
 
220
260
  private rawCall<T = any>(type: string, payload?: unknown): Promise<T> {
221
- if (!this.worker) throw new Error('SqliteCacheEngine: not connected');
222
- const id = ++this.seq;
261
+ if (!this.transport) throw new Error('SqliteCacheEngine: not connected');
223
262
  // --- instrumentation: live, inspectable via `globalThis.__sqliteStats` ---
224
263
  const s = getStats();
225
264
  s.roundTrips++;
@@ -230,30 +269,25 @@ export class SqliteCacheEngine implements LocalStore {
230
269
  }
231
270
  s.inFlight++;
232
271
  s.maxInFlight = Math.max(s.maxInFlight, s.inFlight);
233
- const done = () => {
234
- s.inFlight--;
235
- };
272
+ if (this.transport.kind === 'port') s.proxiedOps = (s.proxiedOps ?? 0) + 1;
236
273
  const sentAt = performance.now();
237
- return new Promise<T>((resolve, reject) => {
238
- this.pending.set(id, {
239
- resolve: (v: T) => {
240
- done();
241
- // Split the round-trip: `wt` is time inside the worker's handler,
242
- // the remainder is postMessage + scheduling overhead.
243
- const wt = (v as { wt?: unknown } | null)?.wt;
244
- if (typeof wt === 'number') {
245
- s.workerMs += wt;
246
- s.rpcOverheadMs += Math.max(0, performance.now() - sentAt - wt);
247
- }
248
- resolve(v);
249
- },
250
- reject: (e: unknown) => {
251
- done();
252
- reject(e);
253
- },
254
- });
255
- this.worker!.postMessage({ id, type, payload });
256
- });
274
+ return this.transport.call<T>(type, payload).then(
275
+ (v: T) => {
276
+ s.inFlight--;
277
+ // Split the round-trip: `wt` is time inside the worker's handler,
278
+ // the remainder is postMessage + scheduling overhead.
279
+ const wt = (v as { wt?: unknown } | null)?.wt;
280
+ if (typeof wt === 'number') {
281
+ s.workerMs += wt;
282
+ s.rpcOverheadMs += Math.max(0, performance.now() - sentAt - wt);
283
+ }
284
+ return v;
285
+ },
286
+ (e: unknown) => {
287
+ s.inFlight--;
288
+ throw e;
289
+ }
290
+ );
257
291
  }
258
292
 
259
293
  /**
@@ -261,8 +295,30 @@ export class SqliteCacheEngine implements LocalStore {
261
295
  * {@link call}) so it can run as the body of an already-queued opQueue entry
262
296
  * without re-queuing onto itself. Callers must run it through the opQueue.
263
297
  */
264
- private async openInternal(bucketId: string): Promise<void> {
265
- this.worker = this.spawnWorker();
298
+ /** Seam for tests: swap in a fake transport instead of a real Worker. */
299
+ protected createTransport(): SqliteTransport {
300
+ return new WorkerSqliteTransport(this.logger);
301
+ }
302
+
303
+ private async openInternal(
304
+ bucketId: string,
305
+ extras?: {
306
+ workerLockName?: string;
307
+ openOptions?: { maxAttempts?: number; backoffMs?: number[]; disallowMemoryFallback?: boolean };
308
+ }
309
+ ): Promise<void> {
310
+ if (!this.transport || this.transport.kind !== 'worker' || !this.transport.connected) {
311
+ this.transport = this.createTransport();
312
+ if (this.transport instanceof WorkerSqliteTransport) {
313
+ // The worker fencing itself (leadership stolen from a frozen tab that
314
+ // thawed) is a leader-loss: park ops until the broker re-adopts us.
315
+ this.transport.onLockLost = (reason) => {
316
+ this.transport = null;
317
+ this.storeEpoch++;
318
+ this.openRoleGate(`worker fenced: ${reason}`);
319
+ };
320
+ }
321
+ }
266
322
  // Seed the `_00_*` system tables as part of `open` (worker-side, one round
267
323
  // trip). The LocalMigrator DEFINEs them, but `translateSurql` lowers every
268
324
  // DEFINE to a noop on this engine (SQLite has no DDL vocabulary), so
@@ -281,6 +337,8 @@ export class SqliteCacheEngine implements LocalStore {
281
337
  dbName: bucketId,
282
338
  useOpfs: this.useOpfs,
283
339
  systemTables: SYSTEM_TABLES,
340
+ ...(extras?.workerLockName ? { workerLockName: extras.workerLockName } : {}),
341
+ ...(extras?.openOptions ? { openOptions: extras.openOptions } : {}),
284
342
  });
285
343
  this.knownTables.clear();
286
344
  for (const t of SYSTEM_TABLES) this.knownTables.add(t);
@@ -290,14 +348,19 @@ export class SqliteCacheEngine implements LocalStore {
290
348
  // (the worker also console.errors, since host apps may run pino at `fatal`)
291
349
  // and publish it so the app can warn the user.
292
350
  const fellBack = this.useOpfs && !persisted;
293
- this.setStorageHealth({
351
+ // Omit `error` rather than setting it to `undefined`: the devtools
352
+ // serializer renders an undefined value as the STRING 'undefined'.
353
+ const health: StorageHealth = {
294
354
  status: persisted ? 'persistent' : 'memory',
295
355
  fallback: fellBack,
296
- error: fellBack ? opfsError : undefined,
297
- });
356
+ };
357
+ if (fellBack && opfsError) health.error = opfsError;
358
+ if (this.shared) health.role = this.roleLabel;
359
+ this.setStorageHealth(health);
298
360
  const stats = getStats();
299
361
  stats.persisted = persisted;
300
- stats.opfsError = fellBack ? opfsError : undefined;
362
+ if (fellBack && opfsError) stats.opfsError = opfsError;
363
+ else delete stats.opfsError;
301
364
  if (fellBack) {
302
365
  this.logger.error(
303
366
  { bucketId, opfsError, Category: 'sp00ky-client::SqliteCacheEngine::connect' },
@@ -337,28 +400,230 @@ export class SqliteCacheEngine implements LocalStore {
337
400
  // As a single entry, every other op runs fully before the close or after
338
401
  // the reopen — never against a closed DB.
339
402
  await this.enqueue(async () => {
340
- if (this.worker) {
403
+ if (this.transport) {
341
404
  try {
342
405
  await this.rawCall('close');
343
406
  } catch {
344
407
  /* ignore */
345
408
  }
346
- this.worker.terminate();
347
- this.worker = null;
409
+ this.transport.close('bucket switch');
410
+ this.transport = null;
348
411
  }
349
412
  await this.openInternal(bucketId);
350
413
  });
351
414
  }
352
415
 
353
416
  async close(): Promise<void> {
354
- if (!this.worker) return;
417
+ if (!this.transport) return;
355
418
  try {
356
419
  await this.call('close');
357
420
  } catch {
358
421
  /* ignore */
359
422
  }
360
- this.worker.terminate();
361
- this.worker = null;
423
+ this.transport.close('engine closed');
424
+ this.transport = null;
425
+ }
426
+
427
+ // ---- shared-tabs role modes ------------------------------------------------
428
+ // The TabsCoordinator drives these; solo mode never touches them. The engine
429
+ // object is never replaced across role changes, so `storeEpoch` stays one
430
+ // monotonic per-tab counter and every existing fencing consumer keeps
431
+ // working unchanged.
432
+
433
+ /** Which role the current transport represents, for StorageHealth. */
434
+ private roleLabel: 'leader' | 'follower' | 'solo' = 'solo';
435
+ /** True once this engine has had a usable store at least once; role changes
436
+ * after that point invalidate in-flight reads and must bump the epoch. */
437
+ private hadStore = false;
438
+
439
+ /**
440
+ * Role transitions run on their OWN chain, never on the opQueue: parked ops
441
+ * sit INSIDE opQueue entries waiting for the role gate, so a transition
442
+ * queued behind them could never run to release them (deadlock). Transitions
443
+ * are safe off-queue because in-flight ops on a dead transport were already
444
+ * rejected, parked ops only resume after the transition completes, and the
445
+ * worker serializes everything worker-side anyway.
446
+ */
447
+ private transitionChain: Promise<unknown> = Promise.resolve();
448
+
449
+ private chainTransition<T>(fn: () => Promise<T>): Promise<T> {
450
+ const result = this.transitionChain.then(fn, fn);
451
+ this.transitionChain = result.then(
452
+ () => undefined,
453
+ () => undefined
454
+ );
455
+ return result;
456
+ }
457
+
458
+ private openRoleGate(reason: string): void {
459
+ if (this.roleGate) return;
460
+ let release!: () => void;
461
+ let rejectFn!: (e: Error) => void;
462
+ const promise = new Promise<void>((res, rej) => {
463
+ release = res;
464
+ rejectFn = rej;
465
+ });
466
+ // Swallow the timeout rejection for waiters that already resolved.
467
+ promise.catch(() => {});
468
+ this.roleGate = { promise, release };
469
+ this.roleGateTimer = setTimeout(() => {
470
+ // Nothing adopted us in time: reject the parked ops (retryable) and drop
471
+ // the gate so later calls fail fast with 'not connected'.
472
+ rejectFn(new BrokerPortClosedError(`no leader adopted this tab: ${reason}`));
473
+ this.roleGate = null;
474
+ this.roleGateTimer = null;
475
+ }, 20_000);
476
+ }
477
+
478
+ private closeRoleGate(): void {
479
+ if (this.roleGateTimer) clearTimeout(this.roleGateTimer);
480
+ this.roleGateTimer = null;
481
+ this.roleGate?.release();
482
+ this.roleGate = null;
483
+ }
484
+
485
+ /**
486
+ * Become the store owner (leader). Boot and failover share this path; a
487
+ * failover (a previous transport existed) bumps the epoch FIRST so every
488
+ * in-flight chain that captured the old epoch fences itself. `resumeHeld`
489
+ * keeps the live worker after a broker restart and only rolls the
490
+ * per-leadership lock forward.
491
+ */
492
+ async adoptOwner(
493
+ bucketId: string,
494
+ opts: {
495
+ workerLockName: string;
496
+ allowMemoryFallback: boolean;
497
+ resumeHeld: boolean;
498
+ }
499
+ ): Promise<StorageHealth> {
500
+ this.roleLabel = 'leader';
501
+ return this.chainTransition(async () => {
502
+ if (
503
+ opts.resumeHeld &&
504
+ this.transport?.kind === 'worker' &&
505
+ this.transport.connected &&
506
+ this.bucketId === bucketId
507
+ ) {
508
+ await this.rawCall('relock', { workerLockName: opts.workerLockName });
509
+ this.closeRoleGate();
510
+ return this.storageHealthValue;
511
+ }
512
+ if (this.hadStore) this.storeEpoch++;
513
+ if (this.transport) {
514
+ try {
515
+ if (this.transport.kind === 'worker') await this.rawCall('close');
516
+ } catch {
517
+ /* ignore */
518
+ }
519
+ this.transport.close('adopting ownership');
520
+ this.transport = null;
521
+ }
522
+ await this.openInternal(bucketId, {
523
+ workerLockName: opts.workerLockName,
524
+ openOptions: {
525
+ ...PROMOTION_OPEN_OPTIONS,
526
+ disallowMemoryFallback: !opts.allowMemoryFallback,
527
+ },
528
+ });
529
+ // Leader wipe-on-pool-open: local `_00_query` rows from earlier sessions
530
+ // are dead (query hashes are session-salted) and this is the one moment
531
+ // no other tab is attached, so clearing here replaces the per-switch
532
+ // DELETE that solo mode does in doSwitchBucket. Followers never wipe.
533
+ try {
534
+ await this.rawCall('run', { sql: 'DELETE FROM "_00_query"' });
535
+ } catch {
536
+ /* fresh bucket: table just seeded, nothing to wipe */
537
+ }
538
+ getStats().roleChanges = (getStats().roleChanges ?? 0) + 1;
539
+ this.hadStore = true;
540
+ this.closeRoleGate();
541
+ return this.storageHealthValue;
542
+ });
543
+ }
544
+
545
+ /** Attach to a leader's worker through `dbPort` (follower). */
546
+ async adoptAttached(
547
+ dbPort: MessagePort,
548
+ snapshot: { bucketId: string; storageHealth: StorageHealth },
549
+ onPortDead: (reason: string) => void
550
+ ): Promise<void> {
551
+ this.roleLabel = 'follower';
552
+ return this.chainTransition(async () => {
553
+ if (this.hadStore) this.storeEpoch++;
554
+ this.transport?.close('adopting leader port');
555
+ this.transport = new PortSqliteTransport(dbPort, onPortDead, this.logger);
556
+ this.bucketId = snapshot.bucketId;
557
+ // The shared store exists and is seeded; mirror the owner's bookkeeping.
558
+ this.knownTables.clear();
559
+ for (const t of SYSTEM_TABLES) this.knownTables.add(t);
560
+ const health: StorageHealth = { ...snapshot.storageHealth, role: 'follower' };
561
+ this.setStorageHealth(health);
562
+ const stats = getStats();
563
+ stats.persisted = snapshot.storageHealth.status === 'persistent';
564
+ stats.roleChanges = (stats.roleChanges ?? 0) + 1;
565
+ delete stats.opfsError;
566
+ this.hadStore = true;
567
+ this.closeRoleGate();
568
+ });
569
+ }
570
+
571
+ /** Demoted while owning the store (zombie thaw, stale promotion): tear the
572
+ * worker down so its OPFS handles free up, then park until re-adopted. */
573
+ async releaseOwnership(): Promise<void> {
574
+ await this.chainTransition(async () => {
575
+ if (this.transport) {
576
+ try {
577
+ if (this.transport.kind === 'worker') {
578
+ await (this.transport as WorkerSqliteTransport).shutdown();
579
+ }
580
+ } catch {
581
+ /* worker may already be fenced/dead */
582
+ }
583
+ this.transport.close('ownership released');
584
+ this.transport = null;
585
+ }
586
+ this.storeEpoch++;
587
+ this.openRoleGate('ownership released');
588
+ });
589
+ }
590
+
591
+ /** The leader (or its port) died. Called from the port-dead callback and the
592
+ * coordinator; NOT enqueued, so in-flight ops reject immediately instead of
593
+ * waiting behind whatever is stuck. */
594
+ onLeaderLost(reason: string): void {
595
+ if (this.transport?.kind === 'port') {
596
+ this.transport.close(reason);
597
+ this.transport = null;
598
+ }
599
+ this.storeEpoch++;
600
+ this.openRoleGate(reason);
601
+ }
602
+
603
+ /** Leader side: forward a follower's dbPort into the owned worker. */
604
+ async exposeClientPort(clientId: string, port: MessagePort): Promise<void> {
605
+ if (this.transport?.kind !== 'worker') {
606
+ throw new Error('SqliteCacheEngine: not the store owner');
607
+ }
608
+ await (this.transport as WorkerSqliteTransport).addClientPort(clientId, port);
609
+ }
610
+
611
+ async removeClientPort(clientId: string): Promise<void> {
612
+ if (this.transport?.kind !== 'worker') return;
613
+ await (this.transport as WorkerSqliteTransport).removeClientPort(clientId);
614
+ }
615
+
616
+ /** Graceful pagehide as owner: release OPFS handles NOW so the next leader
617
+ * does not race the browser's worker GC. */
618
+ async shutdownOwnedWorker(): Promise<void> {
619
+ if (this.transport?.kind !== 'worker') return;
620
+ try {
621
+ await (this.transport as WorkerSqliteTransport).shutdown();
622
+ } catch {
623
+ /* ignore */
624
+ }
625
+ this.transport.close('shutdown');
626
+ this.transport = null;
362
627
  }
363
628
 
364
629
  private async ensureTable(table: string): Promise<void> {
@@ -799,6 +1064,10 @@ interface SqliteStats {
799
1064
  persisted?: boolean;
800
1065
  /** Why OPFS persistence failed, when it did. */
801
1066
  opfsError?: string;
1067
+ /** Shared-tabs follower: ops that crossed the leader's MessagePort. */
1068
+ proxiedOps?: number;
1069
+ /** Times this tab's engine changed hands (promotions + attachments). */
1070
+ roleChanges?: number;
802
1071
  }
803
1072
 
804
1073
  const EMPTY_STATS: SqliteStats = {
@@ -27,12 +27,22 @@ export interface SqliteDbHandle {
27
27
  close: () => void;
28
28
  }
29
29
 
30
+ /** The slice of the OpfsSAHPoolUtil the worker needs for teardown. */
31
+ export interface SqlitePoolHandle {
32
+ /** Unregisters the VFS and releases every sync access handle, leaving the
33
+ * files intact, so another worker can open the pool without waiting for
34
+ * this worker to be garbage collected. Throws while files are open. */
35
+ pauseVfs?: () => unknown;
36
+ }
37
+
30
38
  export interface OpenDbResult {
31
39
  db: SqliteDbHandle;
32
40
  /** True only when the handle is backed by OPFS and survives a reload. */
33
41
  persisted: boolean;
34
42
  /** Why persistence failed. Set only when OPFS was requested and fell back. */
35
43
  opfsError?: string;
44
+ /** Pool util, present only for an OPFS-backed handle. */
45
+ pool?: SqlitePoolHandle;
36
46
  }
37
47
 
38
48
  export interface OpenDbOptions {
@@ -40,6 +50,14 @@ export interface OpenDbOptions {
40
50
  maxAttempts?: number;
41
51
  /** Delay before each retry; the last entry repeats. Default [250, 500]. */
42
52
  backoffMs?: number[];
53
+ /**
54
+ * Throw (`opfs-unavailable: <reason>`) instead of falling back to memory.
55
+ * Used by shared-tabs leader promotion: a silently-in-memory LEADER would
56
+ * put every tab's data in RAM, so promotion prefers failing the election
57
+ * (the broker retries, possibly on another tab) over degrading. The broker
58
+ * grants an explicit memory fallback only after repeated failed cycles.
59
+ */
60
+ disallowMemoryFallback?: boolean;
43
61
  /** Injectable for tests. */
44
62
  sleep?: (ms: number) => Promise<void>;
45
63
  }
@@ -48,6 +66,17 @@ const DEFAULT_MAX_ATTEMPTS = 3;
48
66
  /** Bounded on purpose: this runs on the boot path, before the first query. */
49
67
  const DEFAULT_BACKOFF_MS = [250, 500];
50
68
 
69
+ /**
70
+ * Leader-promotion profile (~5.8s worst case). A dead leader's sync access
71
+ * handles release when the browser garbage-collects its worker, typically
72
+ * well under a second but not synchronously with the Web Lock release the
73
+ * election observed, so promotion retries longer than a cold boot.
74
+ */
75
+ export const PROMOTION_OPEN_OPTIONS: Pick<OpenDbOptions, 'maxAttempts' | 'backoffMs'> = {
76
+ maxAttempts: 10,
77
+ backoffMs: [50, 100, 200, 400, 800, 1000],
78
+ };
79
+
51
80
  /** Failures no retry can fix: the APIs aren't there at all (insecure context,
52
81
  * or a browser without sync access handles). Fall back immediately. */
53
82
  const UNRETRYABLE = ['Missing required OPFS APIs'];
@@ -96,6 +125,9 @@ export async function openDb(
96
125
  if (!useOpfs) return { db: new sqlite3.oo1.DB(':memory:', 'c'), persisted: false };
97
126
 
98
127
  if (!sqlite3.installOpfsSAHPoolVfs) {
128
+ if (opts.disallowMemoryFallback) {
129
+ throw new Error('opfs-unavailable: sqlite-wasm build has no installOpfsSAHPoolVfs');
130
+ }
99
131
  return fallbackToMemory(sqlite3, dbName, 'sqlite-wasm build has no installOpfsSAHPoolVfs', 0);
100
132
  }
101
133
 
@@ -118,12 +150,15 @@ export async function openDb(
118
150
  // doesn't ask for a real re-init just replays the same rejection.
119
151
  ...(attempt > 1 ? { forceReinitIfPreviouslyFailed: true } : {}),
120
152
  });
121
- return { db: new pool.OpfsSAHPoolDb(`/${dbName}.sqlite3`), persisted: true };
153
+ return { db: new pool.OpfsSAHPoolDb(`/${dbName}.sqlite3`), persisted: true, pool };
122
154
  } catch (e) {
123
155
  lastError = errMessage(e);
124
156
  if (attempt === maxAttempts || UNRETRYABLE.some((m) => lastError.includes(m))) break;
125
157
  await sleep(backoffMs[Math.min(attempt - 1, backoffMs.length - 1)] ?? 0);
126
158
  }
127
159
  }
160
+ if (opts.disallowMemoryFallback) {
161
+ throw new Error(`opfs-unavailable: ${lastError} (after ${attempts} attempts)`);
162
+ }
128
163
  return fallbackToMemory(sqlite3, dbName, lastError, attempts);
129
164
  }