@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
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as serializeRow, i as reviveRow, n as renderOrderSql, o as resolveRelations, r as renderWhereSql, s as stableKey, t as project } from "./sqlite-plan-sql.js";
1
+ import { a as renderWhereSql, c as resolveRelations, i as renderOrderSql, l as stableKey, o as reviveRow, r as project, s as serializeRow, t as PROMOTION_OPEN_OPTIONS } from "./sqlite-open.js";
2
2
  import { DateTime, Duration, RecordId, Surreal, Uuid, applyDiagnostics, createRemoteEngines } from "surrealdb";
3
3
  import { QueryBuilder, RecordId as RecordId$1, cyrb53 } from "@spooky-sync/query-builder";
4
4
  import pino from "pino";
@@ -1141,6 +1141,7 @@ function renderRelationFetchSurql(req) {
1141
1141
  var SurrealCacheEngine = class extends LocalDatabaseService {
1142
1142
  /** SurrealDB needs its SurrealQL schema provisioned locally. */
1143
1143
  usesSurqlSchema = true;
1144
+ engineKind = "surrealdb";
1144
1145
  /** {@link LocalCacheEngine} alias for {@link LocalDatabaseService.switchStore}. */
1145
1146
  switchBucket(bucketId) {
1146
1147
  return this.switchStore(bucketId);
@@ -1441,6 +1442,171 @@ function setPath(obj, path, value) {
1441
1442
  cur[parts[parts.length - 1]] = value;
1442
1443
  }
1443
1444
 
1445
+ //#endregion
1446
+ //#region src/services/database/sqlite-transport.ts
1447
+ /** Thrown into pending follower calls when the leader (or its port) goes away.
1448
+ * Callers treat it like a transient error: the op may be retried once a new
1449
+ * leader is attached; nothing was necessarily executed. */
1450
+ var BrokerPortClosedError = class extends Error {
1451
+ retryable = true;
1452
+ constructor(reason) {
1453
+ super(`sqlite transport lost: ${reason}`);
1454
+ this.name = "BrokerPortClosedError";
1455
+ }
1456
+ };
1457
+ /** Shared request/response bookkeeping over any postMessage-shaped channel. */
1458
+ var BaseTransport = class {
1459
+ pending = /* @__PURE__ */ new Map();
1460
+ seq = 0;
1461
+ closed = false;
1462
+ constructor(logger) {
1463
+ this.logger = logger;
1464
+ }
1465
+ get connected() {
1466
+ return !this.closed;
1467
+ }
1468
+ handleMessage(data) {
1469
+ const { id, ok, error, ...rest } = data ?? {};
1470
+ const p = this.pending.get(id);
1471
+ if (!p) return;
1472
+ this.pending.delete(id);
1473
+ if (ok) p.resolve(rest);
1474
+ else p.reject(new Error(error));
1475
+ }
1476
+ call(type, payload) {
1477
+ if (this.closed) return Promise.reject(this.makeError("transport closed"));
1478
+ const id = ++this.seq;
1479
+ return new Promise((resolve, reject) => {
1480
+ this.pending.set(id, {
1481
+ resolve,
1482
+ reject
1483
+ });
1484
+ try {
1485
+ this.post({
1486
+ id,
1487
+ type,
1488
+ payload
1489
+ });
1490
+ } catch (e) {
1491
+ this.pending.delete(id);
1492
+ reject(e);
1493
+ }
1494
+ });
1495
+ }
1496
+ failAll(reason) {
1497
+ if (this.pending.size === 0) return;
1498
+ const err = this.makeError(reason);
1499
+ for (const [, p] of this.pending) p.reject(err);
1500
+ this.pending.clear();
1501
+ }
1502
+ close(reason = "closed") {
1503
+ if (this.closed) return;
1504
+ this.closed = true;
1505
+ this.failAll(reason);
1506
+ }
1507
+ };
1508
+ var WorkerSqliteTransport = class extends BaseTransport {
1509
+ kind = "worker";
1510
+ worker;
1511
+ /** Fired when the worker reports it fenced itself after a leadership steal
1512
+ * (see sqlite-worker.js). The engine must treat the store as gone. */
1513
+ onLockLost = null;
1514
+ constructor(logger) {
1515
+ super(logger);
1516
+ this.worker = new Worker(new URL("./sqlite-worker.js", import.meta.url), { type: "module" });
1517
+ this.worker.onmessage = (ev) => {
1518
+ if (ev.data?.type === "lock-lost") {
1519
+ this.logger.error({
1520
+ reason: ev.data.reason,
1521
+ Category: "sp00ky-client::SqliteCacheEngine::worker"
1522
+ }, "SQLite worker fenced after leadership loss");
1523
+ this.failAll("worker fenced");
1524
+ this.onLockLost?.(String(ev.data.reason ?? "lock-lost"));
1525
+ return;
1526
+ }
1527
+ this.handleMessage(ev.data);
1528
+ };
1529
+ const crash = (msg) => {
1530
+ this.logger.error({
1531
+ err: this.makeError(msg),
1532
+ Category: "sp00ky-client::SqliteCacheEngine::worker"
1533
+ }, "Worker error");
1534
+ this.failAll(msg);
1535
+ };
1536
+ this.worker.onerror = (e) => crash(e.message || "onerror");
1537
+ this.worker.onmessageerror = () => crash("messageerror");
1538
+ }
1539
+ post(msg, transfer) {
1540
+ if (transfer) this.worker.postMessage(msg, transfer);
1541
+ else this.worker.postMessage(msg);
1542
+ }
1543
+ makeError(reason) {
1544
+ return /* @__PURE__ */ new Error(`SQLite worker crashed: ${reason}`);
1545
+ }
1546
+ /** Forward a follower's MessagePort into the worker as an extra client. */
1547
+ addClientPort(clientId, port) {
1548
+ if (this.closed) return Promise.reject(this.makeError("transport closed"));
1549
+ const id = ++this.seq;
1550
+ return new Promise((resolve, reject) => {
1551
+ this.pending.set(id, {
1552
+ resolve: () => resolve(),
1553
+ reject
1554
+ });
1555
+ this.worker.postMessage({
1556
+ id,
1557
+ type: "add-client",
1558
+ payload: { clientId }
1559
+ }, [port]);
1560
+ });
1561
+ }
1562
+ removeClientPort(clientId) {
1563
+ return this.call("remove-client", { clientId }).then(() => void 0);
1564
+ }
1565
+ /** Ask the worker to close + pauseVfs + self-close (graceful pagehide). */
1566
+ shutdown() {
1567
+ return this.call("shutdown").then(() => void 0);
1568
+ }
1569
+ close(reason = "closed") {
1570
+ if (this.closed) return;
1571
+ super.close(reason);
1572
+ this.worker.terminate();
1573
+ }
1574
+ };
1575
+ var PortSqliteTransport = class extends BaseTransport {
1576
+ kind = "port";
1577
+ constructor(port, onPortDead, logger) {
1578
+ super(logger);
1579
+ this.port = port;
1580
+ this.onPortDead = onPortDead;
1581
+ port.onmessage = (ev) => this.handleMessage(ev.data);
1582
+ port.onmessageerror = () => this.dead("messageerror");
1583
+ port.start?.();
1584
+ }
1585
+ /** The broker/coordinator learned the leader is gone; the port itself has no
1586
+ * close event, so the coordinator calls this explicitly. */
1587
+ markDead(reason) {
1588
+ this.dead(reason);
1589
+ }
1590
+ dead(reason) {
1591
+ if (this.closed) return;
1592
+ this.closed = true;
1593
+ this.failAll(reason);
1594
+ try {
1595
+ this.port.close();
1596
+ } catch {}
1597
+ this.onPortDead(reason);
1598
+ }
1599
+ post(msg) {
1600
+ this.port.postMessage(msg);
1601
+ }
1602
+ makeError(reason) {
1603
+ return new BrokerPortClosedError(reason);
1604
+ }
1605
+ close(reason = "closed") {
1606
+ this.dead(reason);
1607
+ }
1608
+ };
1609
+
1444
1610
  //#endregion
1445
1611
  //#region src/services/database/sqlite-cache-engine.ts
1446
1612
  /**
@@ -1499,9 +1665,9 @@ function pureWriteOpResult(op) {
1499
1665
  * oracle E2E in the browser.
1500
1666
  */
1501
1667
  var SqliteCacheEngine = class {
1502
- worker = null;
1503
- seq = 0;
1504
- pending = /* @__PURE__ */ new Map();
1668
+ /** The wire to the SQLite worker: an owned dedicated Worker (solo/leader) or
1669
+ * a MessagePort into another tab's worker (follower). See sqlite-transport. */
1670
+ transport = null;
1505
1671
  storeEpoch = 0;
1506
1672
  knownTables = /* @__PURE__ */ new Set();
1507
1673
  useOpfs;
@@ -1509,6 +1675,9 @@ var SqliteCacheEngine = class {
1509
1675
  * Flipped off at runtime if the worker script predates the `select` op
1510
1676
  * (stale cached bundle) — degrade to the legacy multi-hop path, don't break. */
1511
1677
  workerSelect;
1678
+ /** What `workerSelect` was at construction, so DevTools can tell a runtime
1679
+ * downgrade (configured true, effective false) from a configured-off. */
1680
+ workerSelectConfigured;
1512
1681
  events = createDatabaseEventSystem();
1513
1682
  bucketId = "anon";
1514
1683
  /** Durability of the local store, set on every open. A plain Set of callbacks
@@ -1521,11 +1690,21 @@ var SqliteCacheEngine = class {
1521
1690
  storageHealthSubs = /* @__PURE__ */ new Set();
1522
1691
  /** Schemaless — tables are created lazily on first write; no migrator. */
1523
1692
  usesSurqlSchema = false;
1693
+ engineKind = "sqlite";
1694
+ /** Shared-tabs mode: the engine's transport is swapped at runtime by the
1695
+ * TabsCoordinator (owner worker as leader, leader's port as follower). */
1696
+ shared;
1697
+ /** Leaderless parking (shared mode): ops entering the opQueue await this
1698
+ * gate until a new role lands or the timeout rejects them. */
1699
+ roleGate = null;
1700
+ roleGateTimer = null;
1524
1701
  constructor(config, logger, opts = {}) {
1525
1702
  this.config = config;
1526
1703
  this.logger = logger;
1527
1704
  this.useOpfs = opts.useOpfs ?? true;
1528
1705
  this.workerSelect = opts.workerSelect ?? config.workerSelect ?? true;
1706
+ this.workerSelectConfigured = this.workerSelect;
1707
+ this.shared = opts.shared ?? false;
1529
1708
  }
1530
1709
  get epoch() {
1531
1710
  return this.storeEpoch;
@@ -1553,6 +1732,40 @@ var SqliteCacheEngine = class {
1553
1732
  getConfig() {
1554
1733
  return this.config;
1555
1734
  }
1735
+ /**
1736
+ * Storage numbers for the DevTools Storage tab. Uses {@link call} so the
1737
+ * reads serialize with regular traffic (no SQLITE_BUSY). Never throws — the
1738
+ * worker may be mid bucket-switch; a failure lands in `error` instead.
1739
+ */
1740
+ async getStorageDiagnostics(opts) {
1741
+ const diag = {
1742
+ engine: "sqlite",
1743
+ bucketId: this.bucketId,
1744
+ useOpfs: this.useOpfs,
1745
+ workerSelectConfigured: this.workerSelectConfigured,
1746
+ workerSelectEffective: this.workerSelect
1747
+ };
1748
+ try {
1749
+ const { rows } = await this.call("exec", { sql: "SELECT (SELECT * FROM pragma_page_count()) * (SELECT * FROM pragma_page_size()) AS bytes, (SELECT * FROM pragma_freelist_count()) * (SELECT * FROM pragma_page_size()) AS freelist" });
1750
+ diag.dbSizeBytes = rows?.[0]?.bytes;
1751
+ diag.freelistBytes = rows?.[0]?.freelist;
1752
+ if (opts?.tableCounts) {
1753
+ const { rows: tables } = await this.call("exec", { sql: "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" });
1754
+ const names = (tables ?? []).map((r) => r.name);
1755
+ if (names.length) {
1756
+ const sql = names.map((n) => `SELECT '${n.replace(/'/g, "''")}' AS t, COUNT(*) AS n FROM "${n.replace(/"/g, "\"\"")}"`).join(" UNION ALL ");
1757
+ const { rows: counts } = await this.call("exec", { sql });
1758
+ diag.tableCounts = (counts ?? []).map((r) => ({
1759
+ table: r.t,
1760
+ rows: r.n
1761
+ }));
1762
+ } else diag.tableCounts = [];
1763
+ }
1764
+ } catch (e) {
1765
+ diag.error = e instanceof Error ? e.message : String(e);
1766
+ }
1767
+ return diag;
1768
+ }
1556
1769
  getEvents() {
1557
1770
  return this.events;
1558
1771
  }
@@ -1567,36 +1780,16 @@ var SqliteCacheEngine = class {
1567
1780
  beginSwitch() {
1568
1781
  return () => {};
1569
1782
  }
1570
- spawnWorker() {
1571
- const worker = new Worker(new URL("./sqlite-worker.js", import.meta.url), { type: "module" });
1572
- worker.onmessage = (ev) => {
1573
- const { id, ok, error, ...rest } = ev.data ?? {};
1574
- const p = this.pending.get(id);
1575
- if (!p) return;
1576
- this.pending.delete(id);
1577
- if (ok) p.resolve(rest);
1578
- else p.reject(new Error(error));
1579
- };
1580
- const failAll = (msg) => {
1581
- const err = /* @__PURE__ */ new Error(`SQLite worker crashed: ${msg}`);
1582
- this.logger.error({
1583
- err,
1584
- Category: "sp00ky-client::SqliteCacheEngine::worker"
1585
- }, "Worker error");
1586
- for (const [, p] of this.pending) p.reject(err);
1587
- this.pending.clear();
1588
- };
1589
- worker.onerror = (e) => failAll(e.message || "onerror");
1590
- worker.onmessageerror = () => failAll("messageerror");
1591
- return worker;
1592
- }
1593
1783
  /** Serializes every worker op so reads/writes never overlap at the VFS layer
1594
1784
  * (overlapping ops trip SQLITE_BUSY). Mirrors the SurrealDB engine's
1595
- * single-flight query queue. */
1785
+ * single-flight query queue. (The worker keeps its own chain too, for
1786
+ * multi-client mode; this one additionally provides the queue-wait stat and
1787
+ * the boot/switch atomicity below.) */
1596
1788
  opQueue = Promise.resolve();
1597
1789
  call(type, payload) {
1598
1790
  const enqueuedAt = performance.now();
1599
- const run = () => {
1791
+ const run = async () => {
1792
+ if (this.roleGate) await this.roleGate.promise;
1600
1793
  getStats().queueWaitMs += performance.now() - enqueuedAt;
1601
1794
  return this.rawCall(type, payload);
1602
1795
  };
@@ -1605,8 +1798,7 @@ var SqliteCacheEngine = class {
1605
1798
  return result;
1606
1799
  }
1607
1800
  rawCall(type, payload) {
1608
- if (!this.worker) throw new Error("SqliteCacheEngine: not connected");
1609
- const id = ++this.seq;
1801
+ if (!this.transport) throw new Error("SqliteCacheEngine: not connected");
1610
1802
  const s = getStats();
1611
1803
  s.roundTrips++;
1612
1804
  s.byType[type] = (s.byType[type] ?? 0) + 1;
@@ -1616,31 +1808,19 @@ var SqliteCacheEngine = class {
1616
1808
  }
1617
1809
  s.inFlight++;
1618
1810
  s.maxInFlight = Math.max(s.maxInFlight, s.inFlight);
1619
- const done = () => {
1620
- s.inFlight--;
1621
- };
1811
+ if (this.transport.kind === "port") s.proxiedOps = (s.proxiedOps ?? 0) + 1;
1622
1812
  const sentAt = performance.now();
1623
- return new Promise((resolve, reject) => {
1624
- this.pending.set(id, {
1625
- resolve: (v) => {
1626
- done();
1627
- const wt = v?.wt;
1628
- if (typeof wt === "number") {
1629
- s.workerMs += wt;
1630
- s.rpcOverheadMs += Math.max(0, performance.now() - sentAt - wt);
1631
- }
1632
- resolve(v);
1633
- },
1634
- reject: (e) => {
1635
- done();
1636
- reject(e);
1637
- }
1638
- });
1639
- this.worker.postMessage({
1640
- id,
1641
- type,
1642
- payload
1643
- });
1813
+ return this.transport.call(type, payload).then((v) => {
1814
+ s.inFlight--;
1815
+ const wt = v?.wt;
1816
+ if (typeof wt === "number") {
1817
+ s.workerMs += wt;
1818
+ s.rpcOverheadMs += Math.max(0, performance.now() - sentAt - wt);
1819
+ }
1820
+ return v;
1821
+ }, (e) => {
1822
+ s.inFlight--;
1823
+ throw e;
1644
1824
  });
1645
1825
  }
1646
1826
  /**
@@ -1648,25 +1828,41 @@ var SqliteCacheEngine = class {
1648
1828
  * {@link call}) so it can run as the body of an already-queued opQueue entry
1649
1829
  * without re-queuing onto itself. Callers must run it through the opQueue.
1650
1830
  */
1651
- async openInternal(bucketId) {
1652
- this.worker = this.spawnWorker();
1831
+ /** Seam for tests: swap in a fake transport instead of a real Worker. */
1832
+ createTransport() {
1833
+ return new WorkerSqliteTransport(this.logger);
1834
+ }
1835
+ async openInternal(bucketId, extras) {
1836
+ if (!this.transport || this.transport.kind !== "worker" || !this.transport.connected) {
1837
+ this.transport = this.createTransport();
1838
+ if (this.transport instanceof WorkerSqliteTransport) this.transport.onLockLost = (reason) => {
1839
+ this.transport = null;
1840
+ this.storeEpoch++;
1841
+ this.openRoleGate(`worker fenced: ${reason}`);
1842
+ };
1843
+ }
1653
1844
  const { persisted, opfsError } = await this.rawCall("open", {
1654
1845
  dbName: bucketId,
1655
1846
  useOpfs: this.useOpfs,
1656
- systemTables: SYSTEM_TABLES
1847
+ systemTables: SYSTEM_TABLES,
1848
+ ...extras?.workerLockName ? { workerLockName: extras.workerLockName } : {},
1849
+ ...extras?.openOptions ? { openOptions: extras.openOptions } : {}
1657
1850
  });
1658
1851
  this.knownTables.clear();
1659
1852
  for (const t of SYSTEM_TABLES) this.knownTables.add(t);
1660
1853
  this.bucketId = bucketId;
1661
1854
  const fellBack = this.useOpfs && !persisted;
1662
- this.setStorageHealth({
1855
+ const health = {
1663
1856
  status: persisted ? "persistent" : "memory",
1664
- fallback: fellBack,
1665
- error: fellBack ? opfsError : void 0
1666
- });
1857
+ fallback: fellBack
1858
+ };
1859
+ if (fellBack && opfsError) health.error = opfsError;
1860
+ if (this.shared) health.role = this.roleLabel;
1861
+ this.setStorageHealth(health);
1667
1862
  const stats = getStats();
1668
1863
  stats.persisted = persisted;
1669
- stats.opfsError = fellBack ? opfsError : void 0;
1864
+ if (fellBack && opfsError) stats.opfsError = opfsError;
1865
+ else delete stats.opfsError;
1670
1866
  if (fellBack) this.logger.error({
1671
1867
  bucketId,
1672
1868
  opfsError,
@@ -1691,23 +1887,174 @@ var SqliteCacheEngine = class {
1691
1887
  async switchBucket(bucketId) {
1692
1888
  this.storeEpoch++;
1693
1889
  await this.enqueue(async () => {
1694
- if (this.worker) {
1890
+ if (this.transport) {
1695
1891
  try {
1696
1892
  await this.rawCall("close");
1697
1893
  } catch {}
1698
- this.worker.terminate();
1699
- this.worker = null;
1894
+ this.transport.close("bucket switch");
1895
+ this.transport = null;
1700
1896
  }
1701
1897
  await this.openInternal(bucketId);
1702
1898
  });
1703
1899
  }
1704
1900
  async close() {
1705
- if (!this.worker) return;
1901
+ if (!this.transport) return;
1706
1902
  try {
1707
1903
  await this.call("close");
1708
1904
  } catch {}
1709
- this.worker.terminate();
1710
- this.worker = null;
1905
+ this.transport.close("engine closed");
1906
+ this.transport = null;
1907
+ }
1908
+ /** Which role the current transport represents, for StorageHealth. */
1909
+ roleLabel = "solo";
1910
+ /** True once this engine has had a usable store at least once; role changes
1911
+ * after that point invalidate in-flight reads and must bump the epoch. */
1912
+ hadStore = false;
1913
+ /**
1914
+ * Role transitions run on their OWN chain, never on the opQueue: parked ops
1915
+ * sit INSIDE opQueue entries waiting for the role gate, so a transition
1916
+ * queued behind them could never run to release them (deadlock). Transitions
1917
+ * are safe off-queue because in-flight ops on a dead transport were already
1918
+ * rejected, parked ops only resume after the transition completes, and the
1919
+ * worker serializes everything worker-side anyway.
1920
+ */
1921
+ transitionChain = Promise.resolve();
1922
+ chainTransition(fn) {
1923
+ const result = this.transitionChain.then(fn, fn);
1924
+ this.transitionChain = result.then(() => void 0, () => void 0);
1925
+ return result;
1926
+ }
1927
+ openRoleGate(reason) {
1928
+ if (this.roleGate) return;
1929
+ let release;
1930
+ let rejectFn;
1931
+ const promise = new Promise((res, rej) => {
1932
+ release = res;
1933
+ rejectFn = rej;
1934
+ });
1935
+ promise.catch(() => {});
1936
+ this.roleGate = {
1937
+ promise,
1938
+ release
1939
+ };
1940
+ this.roleGateTimer = setTimeout(() => {
1941
+ rejectFn(new BrokerPortClosedError(`no leader adopted this tab: ${reason}`));
1942
+ this.roleGate = null;
1943
+ this.roleGateTimer = null;
1944
+ }, 2e4);
1945
+ }
1946
+ closeRoleGate() {
1947
+ if (this.roleGateTimer) clearTimeout(this.roleGateTimer);
1948
+ this.roleGateTimer = null;
1949
+ this.roleGate?.release();
1950
+ this.roleGate = null;
1951
+ }
1952
+ /**
1953
+ * Become the store owner (leader). Boot and failover share this path; a
1954
+ * failover (a previous transport existed) bumps the epoch FIRST so every
1955
+ * in-flight chain that captured the old epoch fences itself. `resumeHeld`
1956
+ * keeps the live worker after a broker restart and only rolls the
1957
+ * per-leadership lock forward.
1958
+ */
1959
+ async adoptOwner(bucketId, opts) {
1960
+ this.roleLabel = "leader";
1961
+ return this.chainTransition(async () => {
1962
+ if (opts.resumeHeld && this.transport?.kind === "worker" && this.transport.connected && this.bucketId === bucketId) {
1963
+ await this.rawCall("relock", { workerLockName: opts.workerLockName });
1964
+ this.closeRoleGate();
1965
+ return this.storageHealthValue;
1966
+ }
1967
+ if (this.hadStore) this.storeEpoch++;
1968
+ if (this.transport) {
1969
+ try {
1970
+ if (this.transport.kind === "worker") await this.rawCall("close");
1971
+ } catch {}
1972
+ this.transport.close("adopting ownership");
1973
+ this.transport = null;
1974
+ }
1975
+ await this.openInternal(bucketId, {
1976
+ workerLockName: opts.workerLockName,
1977
+ openOptions: {
1978
+ ...PROMOTION_OPEN_OPTIONS,
1979
+ disallowMemoryFallback: !opts.allowMemoryFallback
1980
+ }
1981
+ });
1982
+ try {
1983
+ await this.rawCall("run", { sql: "DELETE FROM \"_00_query\"" });
1984
+ } catch {}
1985
+ getStats().roleChanges = (getStats().roleChanges ?? 0) + 1;
1986
+ this.hadStore = true;
1987
+ this.closeRoleGate();
1988
+ return this.storageHealthValue;
1989
+ });
1990
+ }
1991
+ /** Attach to a leader's worker through `dbPort` (follower). */
1992
+ async adoptAttached(dbPort, snapshot, onPortDead) {
1993
+ this.roleLabel = "follower";
1994
+ return this.chainTransition(async () => {
1995
+ if (this.hadStore) this.storeEpoch++;
1996
+ this.transport?.close("adopting leader port");
1997
+ this.transport = new PortSqliteTransport(dbPort, onPortDead, this.logger);
1998
+ this.bucketId = snapshot.bucketId;
1999
+ this.knownTables.clear();
2000
+ for (const t of SYSTEM_TABLES) this.knownTables.add(t);
2001
+ const health = {
2002
+ ...snapshot.storageHealth,
2003
+ role: "follower"
2004
+ };
2005
+ this.setStorageHealth(health);
2006
+ const stats = getStats();
2007
+ stats.persisted = snapshot.storageHealth.status === "persistent";
2008
+ stats.roleChanges = (stats.roleChanges ?? 0) + 1;
2009
+ delete stats.opfsError;
2010
+ this.hadStore = true;
2011
+ this.closeRoleGate();
2012
+ });
2013
+ }
2014
+ /** Demoted while owning the store (zombie thaw, stale promotion): tear the
2015
+ * worker down so its OPFS handles free up, then park until re-adopted. */
2016
+ async releaseOwnership() {
2017
+ await this.chainTransition(async () => {
2018
+ if (this.transport) {
2019
+ try {
2020
+ if (this.transport.kind === "worker") await this.transport.shutdown();
2021
+ } catch {}
2022
+ this.transport.close("ownership released");
2023
+ this.transport = null;
2024
+ }
2025
+ this.storeEpoch++;
2026
+ this.openRoleGate("ownership released");
2027
+ });
2028
+ }
2029
+ /** The leader (or its port) died. Called from the port-dead callback and the
2030
+ * coordinator; NOT enqueued, so in-flight ops reject immediately instead of
2031
+ * waiting behind whatever is stuck. */
2032
+ onLeaderLost(reason) {
2033
+ if (this.transport?.kind === "port") {
2034
+ this.transport.close(reason);
2035
+ this.transport = null;
2036
+ }
2037
+ this.storeEpoch++;
2038
+ this.openRoleGate(reason);
2039
+ }
2040
+ /** Leader side: forward a follower's dbPort into the owned worker. */
2041
+ async exposeClientPort(clientId, port) {
2042
+ if (this.transport?.kind !== "worker") throw new Error("SqliteCacheEngine: not the store owner");
2043
+ await this.transport.addClientPort(clientId, port);
2044
+ }
2045
+ async removeClientPort(clientId) {
2046
+ if (this.transport?.kind !== "worker") return;
2047
+ await this.transport.removeClientPort(clientId);
2048
+ }
2049
+ /** Graceful pagehide as owner: release OPFS handles NOW so the next leader
2050
+ * does not race the browser's worker GC. */
2051
+ async shutdownOwnedWorker() {
2052
+ if (this.transport?.kind !== "worker") return;
2053
+ try {
2054
+ await this.transport.shutdown();
2055
+ } catch {}
2056
+ this.transport.close("shutdown");
2057
+ this.transport = null;
1711
2058
  }
1712
2059
  async ensureTable(table) {
1713
2060
  if (this.knownTables.has(table)) return;
@@ -2098,9 +2445,12 @@ function normalizePlanForClone(plan) {
2098
2445
  * `this.local` through the SurrealQL-vocabulary shim + verb surface.
2099
2446
  * - a custom object → used as-is (must satisfy {@link LocalStore}).
2100
2447
  */
2101
- function createLocalEngine(choice, config, logger) {
2448
+ function createLocalEngine(choice, config, logger, opts = {}) {
2102
2449
  if (choice === void 0 || choice === "surrealdb") return new SurrealCacheEngine(config, logger);
2103
- if (choice === "sqlite") return new SqliteCacheEngine(config, logger, { useOpfs: (config.store ?? "memory") !== "memory" });
2450
+ if (choice === "sqlite") return new SqliteCacheEngine(config, logger, {
2451
+ useOpfs: (config.store ?? "memory") !== "memory",
2452
+ shared: opts.shared
2453
+ });
2104
2454
  return choice;
2105
2455
  }
2106
2456
 
@@ -2225,6 +2575,35 @@ function readNumberAfter(sql, from) {
2225
2575
  return m ? parseInt(m[0], 10) : null;
2226
2576
  }
2227
2577
 
2578
+ //#endregion
2579
+ //#region src/modules/data/mutation-id.ts
2580
+ /**
2581
+ * Outbox mutation ids. Previously `_00_pending_mutations:${Date.now()}`, which
2582
+ * (a) collides when two mutations land in the same millisecond, a real case
2583
+ * once multiple tabs share one store, and (b) never actually ordered the
2584
+ * drain, because `loadFromDatabase` sorted by a `created_at` field that does
2585
+ * not exist on the table. The new shape is sortable AND collision-free:
2586
+ *
2587
+ * <13-digit zero-padded ms timestamp>_<4-digit base36 seq>_<tabId>
2588
+ *
2589
+ * Lexicographic order == chronological order (per tab; cross-tab within-ms
2590
+ * ordering is arbitrary, which matches reality), so `ORDER BY id` in the
2591
+ * drain is now meaningful. The tabId suffix also routes rollbacks back to the
2592
+ * tab that owns the mutation in shared-tabs mode.
2593
+ */
2594
+ let seq = 0;
2595
+ /** Tab identity for mutation ids in SOLO mode, minted once per session. In
2596
+ * shared-tabs mode the coordinator's tabId is passed in instead. */
2597
+ const FALLBACK_TAB_ID = Math.random().toString(36).slice(2, 8);
2598
+ function mintMutationId(tabId) {
2599
+ return `_00_pending_mutations:${Date.now().toString().padStart(13, "0")}_${(seq = (seq + 1) % 36 ** 4).toString(36).padStart(4, "0")}_${tabId ?? FALLBACK_TAB_ID}`;
2600
+ }
2601
+ /** The tab that created the mutation, or null for a legacy numeric id. */
2602
+ function mutationOwnerTabId(mutationId) {
2603
+ const parts = (mutationId.startsWith("_00_pending_mutations:") ? mutationId.slice(22) : mutationId).split("_");
2604
+ return parts.length >= 3 ? parts[parts.length - 1] : null;
2605
+ }
2606
+
2228
2607
  //#endregion
2229
2608
  //#region src/modules/data/index.ts
2230
2609
  /** Push a timing sample (ms) into a rolling window, capped at the sample window. */
@@ -2258,6 +2637,9 @@ function phaseStatOf(samples, lastMs) {
2258
2637
  * Uses CacheModule for all storage operations.
2259
2638
  */
2260
2639
  var DataModule = class {
2640
+ /** Tab identity baked into mutation ids (shared-tabs rollback routing);
2641
+ * undefined in solo mode, where mutation-id falls back to a session id. */
2642
+ tabId;
2261
2643
  activeQueries = /* @__PURE__ */ new Map();
2262
2644
  pendingQueries = /* @__PURE__ */ new Map();
2263
2645
  subscriptions = /* @__PURE__ */ new Map();
@@ -2317,6 +2699,11 @@ var DataModule = class {
2317
2699
  setSessionId(sessionId) {
2318
2700
  this.sessionId = sessionId;
2319
2701
  }
2702
+ /** Shared-tabs: bake this tab's identity into mutation ids so a rollback of
2703
+ * a follower's mutation routes back to the tab that made it. */
2704
+ setTabId(tabId) {
2705
+ this.tabId = tabId;
2706
+ }
2320
2707
  /**
2321
2708
  * Update the authenticated user record id. Pass `null` on sign-out.
2322
2709
  * Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
@@ -3069,7 +3456,7 @@ var DataModule = class {
3069
3456
  if (!tableSchema) throw new Error(`Table ${tableName} not found`);
3070
3457
  const rid = parseRecordIdString(id);
3071
3458
  const params = parseParams(tableSchema.columns, data);
3072
- const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
3459
+ const mutationId = parseRecordIdString(mintMutationId(this.tabId));
3073
3460
  const dataKeys = Object.keys(params).map((key) => ({
3074
3461
  key,
3075
3462
  variable: `data_${key}`
@@ -3112,7 +3499,7 @@ var DataModule = class {
3112
3499
  if (!tableSchema) throw new Error(`Table ${tableName} not found`);
3113
3500
  const rid = parseRecordIdString(id);
3114
3501
  const params = parseParams(tableSchema.columns, data);
3115
- const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
3502
+ const mutationId = parseRecordIdString(mintMutationId(this.tabId));
3116
3503
  const [beforeRecord] = await withRetry(this.logger, () => this.local.query("SELECT * FROM ONLY $id", { id: rid }));
3117
3504
  const query = surql.seal(surql.tx([
3118
3505
  surql.updateSet("id", [{ statement: "_00_rv += 1" }]),
@@ -3163,7 +3550,7 @@ var DataModule = class {
3163
3550
  const tableName = extractTablePart(id);
3164
3551
  if (!this.schema.tables.find((t) => t.name === tableName)) throw new Error(`Table ${tableName} not found`);
3165
3552
  const rid = parseRecordIdString(id);
3166
- const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
3553
+ const mutationId = parseRecordIdString(mintMutationId(this.tabId));
3167
3554
  const [beforeRecords] = await this.local.query("SELECT * FROM ONLY $id", { id: rid });
3168
3555
  const beforeRecord = beforeRecords ?? {};
3169
3556
  const query = surql.seal(surql.tx([surql.delete("id"), surql.createMutation("delete", "mid", "id")]));
@@ -3613,39 +4000,31 @@ var UpQueue = class {
3613
4000
  async removeEventFromDatabase(mutation_id) {
3614
4001
  return this.local.query(`DELETE $mutation_id`, { mutation_id });
3615
4002
  }
4003
+ /**
4004
+ * Shared-tabs: a follower committed an outbox row into the shared store and
4005
+ * notified the leader; enqueue exactly that row. Idempotent: a replayed
4006
+ * notify (failover) or a row already queued is a no-op, and a row that never
4007
+ * committed simply is not found (the follower's own write promise already
4008
+ * rejected in that case).
4009
+ */
4010
+ async enqueueFromDatabase(mutationId) {
4011
+ if (this.queue.some((e) => encodeUpEventId(e) === mutationId)) return;
4012
+ try {
4013
+ const [records] = await this.local.query(`SELECT * FROM $mutation_id`, { mutation_id: parseRecordIdString(mutationId) });
4014
+ const event = Array.isArray(records) && records[0] ? rowToUpEvent(records[0], this.logger) : null;
4015
+ if (event) this.addToQueue(event);
4016
+ } catch (error) {
4017
+ this.logger.error({
4018
+ error,
4019
+ mutationId,
4020
+ Category: "sp00ky-client::UpQueue::enqueueFromDatabase"
4021
+ }, "Failed to load a forwarded mutation");
4022
+ }
4023
+ }
3616
4024
  async loadFromDatabase() {
3617
4025
  try {
3618
- const [records] = await this.local.query(`SELECT * FROM _00_pending_mutations ORDER BY created_at ASC`);
3619
- this.queue = records.map((r) => {
3620
- switch (r.mutationType) {
3621
- case "create": return {
3622
- type: "create",
3623
- mutation_id: parseRecordIdString(r.id),
3624
- record_id: parseRecordIdString(r.recordId),
3625
- data: r.data,
3626
- tableName: extractTablePart(r.recordId)
3627
- };
3628
- case "update": return {
3629
- type: "update",
3630
- mutation_id: parseRecordIdString(r.id),
3631
- record_id: parseRecordIdString(r.recordId),
3632
- data: r.data,
3633
- beforeRecord: r.beforeRecord
3634
- };
3635
- case "delete": return {
3636
- type: "delete",
3637
- mutation_id: parseRecordIdString(r.id),
3638
- record_id: parseRecordIdString(r.recordId)
3639
- };
3640
- default:
3641
- this.logger.warn({
3642
- mutationType: r.mutationType,
3643
- record: r,
3644
- Category: "sp00ky-client::UpQueue::loadFromDatabase"
3645
- }, "Unknown mutation type");
3646
- return null;
3647
- }
3648
- }).filter((e) => e !== null);
4026
+ const [records] = await this.local.query(`SELECT * FROM _00_pending_mutations ORDER BY id ASC`);
4027
+ this.queue = records.map((r) => rowToUpEvent(r, this.logger)).filter((e) => e !== null);
3649
4028
  } catch (error) {
3650
4029
  this.logger.error({
3651
4030
  error,
@@ -3654,6 +4033,41 @@ var UpQueue = class {
3654
4033
  }
3655
4034
  }
3656
4035
  };
4036
+ /** The stable string id of an event's outbox row. */
4037
+ function encodeUpEventId(event) {
4038
+ return encodeRecordId(event.mutation_id);
4039
+ }
4040
+ /** Materialize one `_00_pending_mutations` row into an UpEvent. */
4041
+ function rowToUpEvent(r, logger) {
4042
+ switch (r.mutationType) {
4043
+ case "create": return {
4044
+ type: "create",
4045
+ mutation_id: parseRecordIdString(r.id),
4046
+ record_id: parseRecordIdString(r.recordId),
4047
+ data: r.data,
4048
+ tableName: extractTablePart(r.recordId)
4049
+ };
4050
+ case "update": return {
4051
+ type: "update",
4052
+ mutation_id: parseRecordIdString(r.id),
4053
+ record_id: parseRecordIdString(r.recordId),
4054
+ data: r.data,
4055
+ beforeRecord: r.beforeRecord
4056
+ };
4057
+ case "delete": return {
4058
+ type: "delete",
4059
+ mutation_id: parseRecordIdString(r.id),
4060
+ record_id: parseRecordIdString(r.recordId)
4061
+ };
4062
+ default:
4063
+ logger.warn({
4064
+ mutationType: r.mutationType,
4065
+ record: r,
4066
+ Category: "sp00ky-client::UpQueue::rowToUpEvent"
4067
+ }, "Unknown mutation type");
4068
+ return null;
4069
+ }
4070
+ }
3657
4071
 
3658
4072
  //#endregion
3659
4073
  //#region src/modules/sync/queue/queue-down.ts
@@ -4047,8 +4461,8 @@ var SyncScheduler = class {
4047
4461
  this.onRollback = onRollback;
4048
4462
  this.onSyncOutcome = onSyncOutcome;
4049
4463
  }
4050
- async init() {
4051
- await this.upQueue.loadFromDatabase();
4464
+ async init(opts = {}) {
4465
+ if (opts.loadOutbox !== false) await this.upQueue.loadFromDatabase();
4052
4466
  this.upQueue.events.subscribe(SyncQueueEventTypes.MutationEnqueued, this.syncUp.bind(this));
4053
4467
  this.downQueue.events.subscribe(SyncQueueEventTypes.QueryItemEnqueued, this.syncDown.bind(this));
4054
4468
  }
@@ -4166,6 +4580,10 @@ var Sp00kySync = class Sp00kySync {
4166
4580
  wasDisconnected = false;
4167
4581
  events = createSyncEventSystem();
4168
4582
  currentUserId = null;
4583
+ tabRole = "solo";
4584
+ tabId = null;
4585
+ hub = null;
4586
+ forwarder = null;
4169
4587
  refMode = DEFAULT_REF_MODE;
4170
4588
  anonLiveEnabled;
4171
4589
  currentLiveQueryUuid = null;
@@ -4338,7 +4756,7 @@ var Sp00kySync = class Sp00kySync {
4338
4756
  async init() {
4339
4757
  if (this.isInit) throw new Error("Sp00kySync is already initialized");
4340
4758
  this.isInit = true;
4341
- await this.scheduler.init();
4759
+ await this.scheduler.init({ loadOutbox: this.tabRole !== "follower" });
4342
4760
  this.subscribeToReconnect();
4343
4761
  this.scheduler.syncUp();
4344
4762
  this.scheduler.syncDown();
@@ -4352,6 +4770,93 @@ var Sp00kySync = class Sp00kySync {
4352
4770
  });
4353
4771
  }
4354
4772
  }
4773
+ /** Set BEFORE init(): shapes what init boots (a follower loads no outbox and
4774
+ * never starts LIVE; its own registration/poll paths stay untouched). */
4775
+ setTabContext(role, tabId) {
4776
+ this.tabRole = role;
4777
+ this.tabId = tabId;
4778
+ }
4779
+ /** Leader duties: drain the shared outbox, own the single list_ref LIVE,
4780
+ * relay LIVE events and rollbacks to followers via `hub`. Idempotent for a
4781
+ * boot-time leader; a runtime promotion (failover) reloads the outbox,
4782
+ * which now holds EVERY tab's rows, and restarts LIVE under this session. */
4783
+ async promoteToLeader(hub) {
4784
+ this.tabRole = "leader";
4785
+ this.hub = hub;
4786
+ this.forwarder = null;
4787
+ hub.onFollowerMessage = (tabId, msg) => {
4788
+ switch (msg.type) {
4789
+ case "sync-hello": break;
4790
+ case "mutation-enqueued":
4791
+ this.enqueueForwardedMutation(msg.mutationId);
4792
+ break;
4793
+ case "request-poll":
4794
+ this.listRefIdleStreak = 0;
4795
+ break;
4796
+ }
4797
+ };
4798
+ if (this.isInit) {
4799
+ await this.upQueue.loadFromDatabase();
4800
+ this.scheduler.syncUp();
4801
+ if (this.currentUserId || this.anonLiveEnabled) {
4802
+ this.startListRefPoll();
4803
+ await this.restartRefLiveQuery().catch((err) => {
4804
+ this.logger.warn({
4805
+ err,
4806
+ Category: "sp00ky-client::Sp00kySync::promoteToLeader"
4807
+ }, "LIVE restart failed on promotion; poll fallback covers it");
4808
+ });
4809
+ }
4810
+ }
4811
+ }
4812
+ /** Follower duties: no outbox drain, no LIVE. Mutations forward to the
4813
+ * leader; everything else (registration, per-query sync, poll) runs
4814
+ * against this tab's own remote session as usual. */
4815
+ demoteToFollower(forwarder) {
4816
+ this.tabRole = "follower";
4817
+ this.hub = null;
4818
+ this.forwarder = forwarder;
4819
+ this.killRefLiveQuery();
4820
+ forwarder.onLeaderMessage = (msg) => {
4821
+ switch (msg.type) {
4822
+ case "list-ref-change":
4823
+ this.applyRelayedListRefChange(msg).catch((err) => {
4824
+ this.logger.error({
4825
+ err,
4826
+ Category: "sp00ky-client::Sp00kySync::relay"
4827
+ }, "Relayed list_ref change failed");
4828
+ });
4829
+ break;
4830
+ case "mutation-rolled-back":
4831
+ this.events.emit(SyncEventTypes.MutationRolledBack, {
4832
+ eventType: msg.eventType,
4833
+ recordId: msg.recordId,
4834
+ error: msg.error
4835
+ });
4836
+ break;
4837
+ default: break;
4838
+ }
4839
+ };
4840
+ }
4841
+ /** A forwarded outbox row from a follower: load + drain it. Idempotent. */
4842
+ async enqueueForwardedMutation(mutationId) {
4843
+ if (this.tabRole !== "leader") return;
4844
+ await this.upQueue.enqueueFromDatabase(mutationId);
4845
+ }
4846
+ /** A relayed `_00_list_ref` LIVE event: resolve against THIS tab's queries
4847
+ * and run the exact same handling the LIVE subscription would have. */
4848
+ async applyRelayedListRefChange(msg) {
4849
+ const queryId = parseRecordIdString(msg.queryId);
4850
+ if (!this.dataModule.getQueryById(queryId)) return;
4851
+ const recordId = parseRecordIdString(msg.recordId);
4852
+ if (msg.parent) await this.handleRemoteSubqueryChange(msg.action, queryId, recordId, msg.version);
4853
+ else await this.handleRemoteListRefChange(msg.action, queryId, recordId, msg.version);
4854
+ }
4855
+ /** One immediate poll cycle (failover convergence). */
4856
+ async forcePollRound() {
4857
+ this.listRefIdleStreak = 0;
4858
+ await this.pollListRefForActiveQueries().catch(() => false);
4859
+ }
4355
4860
  /**
4356
4861
  * Quiesce all sync activity ahead of a local-bucket switch. After this
4357
4862
  * resolves, nothing in the sync module writes to the local store: the poll
@@ -4632,6 +5137,7 @@ var Sp00kySync = class Sp00kySync {
4632
5137
  });
4633
5138
  }
4634
5139
  async startRefLiveQueries() {
5140
+ if (this.tabRole === "follower") return;
4635
5141
  const tableName = this.listRefTable();
4636
5142
  this.logger.debug({
4637
5143
  tableName,
@@ -4665,9 +5171,17 @@ var Sp00kySync = class Sp00kySync {
4665
5171
  async handleRemoteListRefChange(action, queryId, recordId, version) {
4666
5172
  this.lastLiveEventAt = Date.now();
4667
5173
  this.listRefIdleStreak = 0;
5174
+ this.hub?.broadcast({
5175
+ type: "list-ref-change",
5176
+ action,
5177
+ queryId: encodeRecordId(queryId),
5178
+ recordId: encodeRecordId(recordId),
5179
+ version,
5180
+ parent: false
5181
+ });
4668
5182
  const existing = this.dataModule.getQueryById(queryId);
4669
5183
  if (!existing) {
4670
- this.logger.warn({
5184
+ if (!this.hub) this.logger.warn({
4671
5185
  queryId: queryId.toString(),
4672
5186
  Category: "sp00ky-client::Sp00kySync::handleRemoteListRefChange"
4673
5187
  }, "Received remote update for unknown local query");
@@ -4702,6 +5216,14 @@ var Sp00kySync = class Sp00kySync {
4702
5216
  this.lastLiveEventAt = Date.now();
4703
5217
  this.listRefIdleStreak = 0;
4704
5218
  if (action === "DELETE") return;
5219
+ this.hub?.broadcast({
5220
+ type: "list-ref-change",
5221
+ action,
5222
+ queryId: encodeRecordId(queryId),
5223
+ recordId: encodeRecordId(childId),
5224
+ version,
5225
+ parent: true
5226
+ });
4705
5227
  const existing = this.dataModule.getQueryById(queryId);
4706
5228
  if (!existing) return;
4707
5229
  const item = {
@@ -4797,6 +5319,15 @@ var Sp00kySync = class Sp00kySync {
4797
5319
  recordId,
4798
5320
  error: error.message
4799
5321
  });
5322
+ const mutationId = encodeRecordId(event.mutation_id);
5323
+ const owner = mutationOwnerTabId(mutationId);
5324
+ if (this.hub && owner && this.tabId && owner !== this.tabId) this.hub.sendTo(owner, {
5325
+ type: "mutation-rolled-back",
5326
+ mutationId,
5327
+ recordId,
5328
+ eventType: event.type,
5329
+ error: error.message
5330
+ });
4800
5331
  }
4801
5332
  async processDownEvent(event) {
4802
5333
  this.logger.debug({
@@ -4907,6 +5438,10 @@ var Sp00kySync = class Sp00kySync {
4907
5438
  * @param mutations Array of UpEvents (create/update/delete) to enqueue.
4908
5439
  */
4909
5440
  async enqueueMutation(mutations) {
5441
+ if (this.tabRole === "follower") {
5442
+ for (const m of mutations) this.forwarder?.mutationEnqueued(encodeRecordId(m.mutation_id));
5443
+ return;
5444
+ }
4910
5445
  this.scheduler.enqueueMutation(mutations);
4911
5446
  }
4912
5447
  async registerQuery(queryHash) {
@@ -5088,10 +5623,79 @@ function parseBackendInfo(raw) {
5088
5623
  };
5089
5624
  }
5090
5625
 
5626
+ //#endregion
5627
+ //#region src/modules/devtools/storage-info.ts
5628
+ /**
5629
+ * Recursively list the origin's OPFS. Sizes come from `handle.getFile()`,
5630
+ * which throws for a file another context holds an exclusive sync access
5631
+ * handle on — SAHPool does exactly that for its whole pool, so a locked file
5632
+ * (size omitted) is a live "who has the pool" signal, not a failure.
5633
+ */
5634
+ async function walkOpfs(maxEntries = 2e3, maxDepth = 8) {
5635
+ const nav = typeof navigator !== "undefined" ? navigator : void 0;
5636
+ if (!nav?.storage?.getDirectory) return {
5637
+ supported: false,
5638
+ entries: [],
5639
+ totalBytes: 0,
5640
+ truncated: false
5641
+ };
5642
+ const entries = [];
5643
+ let totalBytes = 0;
5644
+ let truncated = false;
5645
+ try {
5646
+ const root = await nav.storage.getDirectory();
5647
+ const walk = async (dir, prefix, depth) => {
5648
+ if (depth > maxDepth) return;
5649
+ for await (const [name, handle] of dir.entries()) {
5650
+ if (entries.length >= maxEntries) {
5651
+ truncated = true;
5652
+ return;
5653
+ }
5654
+ const path = prefix ? `${prefix}/${name}` : name;
5655
+ if (handle.kind === "directory") {
5656
+ entries.push({
5657
+ path,
5658
+ kind: "directory"
5659
+ });
5660
+ await walk(handle, path, depth + 1);
5661
+ } else {
5662
+ let size;
5663
+ try {
5664
+ size = (await handle.getFile()).size;
5665
+ totalBytes += size;
5666
+ } catch {}
5667
+ const entry = {
5668
+ path,
5669
+ kind: "file"
5670
+ };
5671
+ if (size !== void 0) entry.size = size;
5672
+ entries.push(entry);
5673
+ }
5674
+ }
5675
+ };
5676
+ await walk(root, "", 0);
5677
+ entries.sort((a, b) => a.path.localeCompare(b.path));
5678
+ return {
5679
+ supported: true,
5680
+ entries,
5681
+ totalBytes,
5682
+ truncated
5683
+ };
5684
+ } catch (e) {
5685
+ return {
5686
+ supported: true,
5687
+ entries,
5688
+ totalBytes,
5689
+ truncated,
5690
+ error: e instanceof Error ? e.message : String(e)
5691
+ };
5692
+ }
5693
+ }
5694
+
5091
5695
  //#endregion
5092
5696
  //#region src/modules/devtools/index.ts
5093
- const CORE_VERSION = "0.0.1-canary.153";
5094
- const WASM_VERSION = "0.0.1-canary.153";
5697
+ const CORE_VERSION = "0.0.1-canary.155";
5698
+ const WASM_VERSION = "0.0.1-canary.155";
5095
5699
  const SURREAL_VERSION = "3.0.3";
5096
5700
  var DevToolsService = class {
5097
5701
  eventsHistory = [];
@@ -5099,6 +5703,11 @@ var DevToolsService = class {
5099
5703
  version = CORE_VERSION;
5100
5704
  backendInfo = emptyBackendInfo();
5101
5705
  enabled = false;
5706
+ /** Shared-tabs snapshot for the panel, wired by Sp00kyClient when active. */
5707
+ tabsInfoProvider = null;
5708
+ setTabsInfoProvider(provider) {
5709
+ this.tabsInfoProvider = provider;
5710
+ }
5102
5711
  localTables = [];
5103
5712
  localTablesFetching = false;
5104
5713
  localTablesAt = 0;
@@ -5292,10 +5901,74 @@ var DevToolsService = class {
5292
5901
  storage: this.databaseService.storageHealth ?? {
5293
5902
  status: "unknown",
5294
5903
  fallback: false
5295
- }
5904
+ },
5905
+ tabs: this.tabsInfoProvider?.() ?? null
5296
5906
  }
5297
5907
  });
5298
5908
  }
5909
+ /**
5910
+ * Full storage diagnostics for the DevTools Storage tab. Every section is
5911
+ * gathered independently and failures land in that section's `error` field,
5912
+ * so one broken source (a mid-switch worker, a browser without OPFS) never
5913
+ * blanks the whole panel.
5914
+ */
5915
+ async getStorageInfo(opts) {
5916
+ const nav = typeof navigator !== "undefined" ? navigator : void 0;
5917
+ const info = {
5918
+ at: Date.now(),
5919
+ engine: {
5920
+ kind: this.databaseService.engineKind ?? "custom",
5921
+ store: this.databaseService.getConfig()?.store ?? "memory",
5922
+ bucketId: this.databaseService.currentBucketId
5923
+ },
5924
+ health: this.databaseService.storageHealth ?? {
5925
+ status: "unknown",
5926
+ fallback: false
5927
+ },
5928
+ browser: {},
5929
+ opfs: {
5930
+ supported: false,
5931
+ entries: [],
5932
+ totalBytes: 0,
5933
+ truncated: false
5934
+ }
5935
+ };
5936
+ try {
5937
+ if (nav?.storage?.estimate) {
5938
+ const est = await nav.storage.estimate();
5939
+ info.browser.usage = est.usage;
5940
+ info.browser.quota = est.quota;
5941
+ const details = est.usageDetails;
5942
+ if (details && typeof details === "object") info.browser.usageDetails = details;
5943
+ }
5944
+ if (nav?.storage?.persisted) info.browser.persisted = await nav.storage.persisted();
5945
+ } catch (e) {
5946
+ info.browser.error = e instanceof Error ? e.message : String(e);
5947
+ }
5948
+ info.opfs = await walkOpfs();
5949
+ const stats = globalThis.__sqliteStats;
5950
+ if (stats && typeof stats === "object") info.sqliteStats = {
5951
+ ...stats,
5952
+ byType: { ...stats.byType ?? {} }
5953
+ };
5954
+ try {
5955
+ info.engineDiagnostics = await this.databaseService.getStorageDiagnostics?.(opts);
5956
+ } catch (e) {
5957
+ this.logger.warn({
5958
+ err: e,
5959
+ Category: "sp00ky-client::DevToolsService::getStorageInfo"
5960
+ }, "Engine storage diagnostics failed");
5961
+ }
5962
+ return this.serializeForDevTools(info);
5963
+ }
5964
+ /** Ask the browser to exempt this origin's storage from eviction. */
5965
+ async requestPersistentStorage() {
5966
+ try {
5967
+ return { granted: await navigator.storage?.persist?.() ?? false };
5968
+ } catch {
5969
+ return { granted: false };
5970
+ }
5971
+ }
5299
5972
  notifyDevTools() {
5300
5973
  if (!this.enabled) return;
5301
5974
  if (typeof window !== "undefined") window.postMessage({
@@ -5319,7 +5992,10 @@ var DevToolsService = class {
5319
5992
  if (seen.has(data)) return "[Circular Object]";
5320
5993
  seen.add(data);
5321
5994
  const result = {};
5322
- for (const key in data) if (Object.prototype.hasOwnProperty.call(data, key)) result[key] = this.serializeForDevTools(data[key], seen);
5995
+ for (const key in data) if (Object.prototype.hasOwnProperty.call(data, key)) {
5996
+ if (data[key] === void 0) continue;
5997
+ result[key] = this.serializeForDevTools(data[key], seen);
5998
+ }
5323
5999
  return result;
5324
6000
  }
5325
6001
  return data;
@@ -5334,6 +6010,8 @@ var DevToolsService = class {
5334
6010
  this.notifyDevTools();
5335
6011
  },
5336
6012
  refreshVersions: () => this.refreshBackendVersions(),
6013
+ getStorageInfo: (opts) => this.getStorageInfo(opts),
6014
+ requestPersistentStorage: () => this.requestPersistentStorage(),
5337
6015
  getTableData: async (tableName) => {
5338
6016
  try {
5339
6017
  const result = await this.databaseService.query(`SELECT * FROM ${tableName}`);
@@ -5631,6 +6309,7 @@ var StreamProcessorService = class {
5631
6309
  };
5632
6310
  stateKeySuffix = "";
5633
6311
  stateGeneration = 0;
6312
+ persistState = true;
5634
6313
  constructor(events, db, persistenceClient, logger) {
5635
6314
  this.events = events;
5636
6315
  this.db = db;
@@ -5758,8 +6437,12 @@ var StreamProcessorService = class {
5758
6437
  this.processor = new Sp00kyProcessor();
5759
6438
  this.logger.info({ Category: "sp00ky-client::StreamProcessorService::reset" }, "Stream processor reset (fresh circuit)");
5760
6439
  }
6440
+ /** Toggle circuit-state persistence (shared-tabs follower/leader role). */
6441
+ setPersistenceEnabled(enabled) {
6442
+ this.persistState = enabled;
6443
+ }
5761
6444
  async loadState() {
5762
- if (!this.processor) return;
6445
+ if (!this.processor || !this.persistState) return;
5763
6446
  try {
5764
6447
  const result = await this.persistenceClient.get(this.stateKey());
5765
6448
  if (Array.isArray(result) && result.length > 0 && Array.isArray(result[0]) && result[0].length > 0 && result[0][0]?.state) {
@@ -5817,7 +6500,7 @@ var StreamProcessorService = class {
5817
6500
  }, "Session auth context updated");
5818
6501
  }
5819
6502
  async saveState() {
5820
- if (!this.processor) return;
6503
+ if (!this.processor || !this.persistState) return;
5821
6504
  const generation = this.stateGeneration;
5822
6505
  try {
5823
6506
  if (typeof this.processor.save_state === "function") {
@@ -6035,16 +6718,14 @@ function selectPredicateFromBody(body) {
6035
6718
 
6036
6719
  //#endregion
6037
6720
  //#region src/modules/cache/index.ts
6038
- /**
6039
- * CacheModule - Centralized storage and DBSP ingestion
6040
- *
6041
- * Single responsibility: Handle all local storage operations and DBSP ingestion.
6042
- * This module acts as the bridge between data operations and persistence.
6043
- */
6044
6721
  var CacheModule = class {
6045
6722
  logger;
6046
6723
  streamUpdateCallback;
6047
6724
  versionLookups = {};
6725
+ /** Shared-tabs leader: fan every committed ingest out to follower circuits.
6726
+ * Fired AFTER the local tx (the rows are already in the shared store, so a
6727
+ * follower only needs the circuit feed). Never set on followers. */
6728
+ ingestRelay = null;
6048
6729
  constructor(local, streamProcessor, streamUpdateCallback, logger) {
6049
6730
  this.local = local;
6050
6731
  this.streamProcessor = streamProcessor;
@@ -6064,6 +6745,24 @@ var CacheModule = class {
6064
6745
  }, "Stream update received");
6065
6746
  this.streamUpdateCallback(update);
6066
6747
  }
6748
+ setIngestRelay(cb) {
6749
+ this.ingestRelay = cb;
6750
+ }
6751
+ /**
6752
+ * Shared-tabs follower: feed relayed tuples into THIS tab's circuit only.
6753
+ * The rows are already in the shared store (the leader wrote them), so no
6754
+ * local write happens here; the normal chain then runs: SSP -> stream update
6755
+ * -> DataModule debounce -> materializeRecords (re-reads via the port
6756
+ * transport) -> this tab's subscriptions fire with this tab's hashes.
6757
+ */
6758
+ applyRelayedIngest(tuples) {
6759
+ for (const t of tuples) {
6760
+ const rv = t.record?._00_rv;
6761
+ if (t.op === "DELETE") delete this.versionLookups[t.id];
6762
+ else if (typeof rv === "number") this.versionLookups[t.id] = rv;
6763
+ }
6764
+ this.streamProcessor.ingestMany(tuples);
6765
+ }
6067
6766
  lookup(recordId) {
6068
6767
  return this.versionLookups[recordId] ?? 0;
6069
6768
  }
@@ -6128,6 +6827,7 @@ var CacheModule = class {
6128
6827
  };
6129
6828
  });
6130
6829
  this.streamProcessor.ingestMany(bulk);
6830
+ this.ingestRelay?.(bulk);
6131
6831
  this.logger.debug({
6132
6832
  count: records.length,
6133
6833
  Category: "sp00ky-client::CacheModule::saveBatch"
@@ -6163,6 +6863,12 @@ var CacheModule = class {
6163
6863
  if (this.local.epoch !== epoch) throw new StaleEpochError();
6164
6864
  delete this.versionLookups[id];
6165
6865
  this.streamProcessor.ingest(table, "DELETE", id, recordData);
6866
+ this.ingestRelay?.([{
6867
+ table,
6868
+ op: "DELETE",
6869
+ id,
6870
+ record: recordData
6871
+ }]);
6166
6872
  this.logger.debug({
6167
6873
  table,
6168
6874
  id,
@@ -7156,6 +7862,747 @@ var ResilientPersistenceClient = class {
7156
7862
  }
7157
7863
  };
7158
7864
 
7865
+ //#endregion
7866
+ //#region src/services/tabs/support.ts
7867
+ function detectSharedTabsSupport(config) {
7868
+ if (config.sharedTabs !== true) return {
7869
+ supported: false,
7870
+ reason: "flag-off"
7871
+ };
7872
+ if (config.localEngine !== "sqlite") return {
7873
+ supported: false,
7874
+ reason: "engine-not-sqlite"
7875
+ };
7876
+ if (typeof window === "undefined") return {
7877
+ supported: false,
7878
+ reason: "not-browser"
7879
+ };
7880
+ if (typeof SharedWorker === "undefined") return {
7881
+ supported: false,
7882
+ reason: "no-shared-worker"
7883
+ };
7884
+ if (typeof MessageChannel === "undefined") return {
7885
+ supported: false,
7886
+ reason: "no-message-channel"
7887
+ };
7888
+ const locks = navigator.locks;
7889
+ if (!locks || typeof locks.request !== "function") return {
7890
+ supported: false,
7891
+ reason: "no-web-locks"
7892
+ };
7893
+ return { supported: true };
7894
+ }
7895
+
7896
+ //#endregion
7897
+ //#region src/services/tabs/protocol.ts
7898
+ const TABS_PROTOCOL_VERSION = 1;
7899
+ /** Broker pings every tab at this cadence. */
7900
+ const PING_INTERVAL_MS = 5e3;
7901
+ /** A tab that has not ponged for this long is presumed dead and evicted.
7902
+ * MUST stay above the sqlite worker's freeze-suspect threshold (10s): a
7903
+ * freeze shorter than that never triggers a steal, which is what makes the
7904
+ * worker-side thaw gate sufficient. */
7905
+ const PONG_TIMEOUT_MS = 15e3;
7906
+ /** Deterministic JSON: objects with sorted keys, so equal inputs always
7907
+ * produce equal fingerprints regardless of construction order. */
7908
+ function stableStringify(value) {
7909
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
7910
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
7911
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`;
7912
+ }
7913
+ /** cyrb53: tiny, fast, good-enough 53-bit hash for identity strings. */
7914
+ function hash53(str, seed = 0) {
7915
+ let h1 = 3735928559 ^ seed;
7916
+ let h2 = 1103547991 ^ seed;
7917
+ for (let i = 0; i < str.length; i++) {
7918
+ const ch = str.charCodeAt(i);
7919
+ h1 = Math.imul(h1 ^ ch, 2654435761);
7920
+ h2 = Math.imul(h2 ^ ch, 1597334677);
7921
+ }
7922
+ h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
7923
+ h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
7924
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
7925
+ }
7926
+ function computeTabsFingerprint(input) {
7927
+ return hash53(stableStringify({
7928
+ v: TABS_PROTOCOL_VERSION,
7929
+ ...input
7930
+ }));
7931
+ }
7932
+ /** Lock names. The worker lock embeds the leadershipId, so after a steal the
7933
+ * exact name is never re-acquired by anyone: "not held" is an unambiguous
7934
+ * fencing check for a thawed worker, with no clientId resolution needed. */
7935
+ function tabLockName(fingerprint, bucketId) {
7936
+ return `sp00ky-tabs:${fingerprint}:${bucketId}:tab`;
7937
+ }
7938
+ function workerLockName(fingerprint, bucketId, leadershipId) {
7939
+ return `sp00ky-tabs:${fingerprint}:${bucketId}:worker:${leadershipId}`;
7940
+ }
7941
+
7942
+ //#endregion
7943
+ //#region src/services/tabs/broker-client.ts
7944
+ var TabBrokerClient = class {
7945
+ worker = null;
7946
+ brokerInstanceId = null;
7947
+ watchdog = null;
7948
+ lastPingAt = 0;
7949
+ closed = false;
7950
+ helloState = null;
7951
+ constructor(workerUrl, tabId, events, logger) {
7952
+ this.workerUrl = workerUrl;
7953
+ this.tabId = tabId;
7954
+ this.events = events;
7955
+ this.logger = logger;
7956
+ }
7957
+ /** Connect and hello. Resolves once broker-hello arrives (or rejects on
7958
+ * timeout / SharedWorker error, in which case the caller goes solo). */
7959
+ connect(hello) {
7960
+ this.helloState = hello;
7961
+ this.installLifecycleListeners();
7962
+ return this.openWorker();
7963
+ }
7964
+ openWorker() {
7965
+ if (this.closed || !this.helloState) return Promise.reject(/* @__PURE__ */ new Error("broker client closed"));
7966
+ return new Promise((resolve, reject) => {
7967
+ let settled = false;
7968
+ const settle = (fn) => {
7969
+ if (!settled) {
7970
+ settled = true;
7971
+ fn();
7972
+ }
7973
+ };
7974
+ let worker;
7975
+ try {
7976
+ worker = new SharedWorker(this.workerUrl, {
7977
+ type: "module",
7978
+ name: "sp00ky-tabs-broker"
7979
+ });
7980
+ } catch (e) {
7981
+ reject(e);
7982
+ return;
7983
+ }
7984
+ this.worker = worker;
7985
+ const timeout = setTimeout(() => settle(() => reject(/* @__PURE__ */ new Error("broker hello timeout"))), 5e3);
7986
+ worker.onerror = () => {
7987
+ clearTimeout(timeout);
7988
+ settle(() => reject(/* @__PURE__ */ new Error("SharedWorker failed to start")));
7989
+ };
7990
+ worker.port.onmessage = (ev) => {
7991
+ const msg = ev.data;
7992
+ if (!msg || typeof msg !== "object") return;
7993
+ if (msg.type === "broker-hello") {
7994
+ clearTimeout(timeout);
7995
+ this.noteInstance(msg.brokerInstanceId);
7996
+ this.lastPingAt = Date.now();
7997
+ this.startWatchdog();
7998
+ settle(resolve);
7999
+ return;
8000
+ }
8001
+ this.handleMessage(msg, ev.ports ?? []);
8002
+ };
8003
+ worker.port.onmessageerror = () => this.reconnect("messageerror");
8004
+ worker.port.start?.();
8005
+ this.send({
8006
+ type: "hello",
8007
+ tabId: this.tabId,
8008
+ fingerprint: this.helloState.fingerprint,
8009
+ bucketId: this.helloState.bucketId,
8010
+ visibility: currentVisibility(),
8011
+ heldLeadership: this.helloState.heldLeadership()
8012
+ });
8013
+ });
8014
+ }
8015
+ noteInstance(id) {
8016
+ if (this.brokerInstanceId !== null && this.brokerInstanceId !== id) this.events.onBrokerRestarted();
8017
+ this.brokerInstanceId = id;
8018
+ }
8019
+ handleMessage(msg, ports) {
8020
+ if ("brokerInstanceId" in msg) this.noteInstance(msg.brokerInstanceId);
8021
+ switch (msg.type) {
8022
+ case "ping":
8023
+ this.lastPingAt = Date.now();
8024
+ this.send({
8025
+ type: "pong",
8026
+ tabId: this.tabId
8027
+ });
8028
+ break;
8029
+ case "become-leader":
8030
+ this.events.onBecomeLeader({
8031
+ leadershipId: msg.leadershipId,
8032
+ forceTakeover: msg.forceTakeover,
8033
+ allowMemoryFallback: msg.allowMemoryFallback,
8034
+ resumeHeld: msg.resumeHeld
8035
+ });
8036
+ break;
8037
+ case "demote":
8038
+ this.events.onDemote(msg.leadershipId);
8039
+ break;
8040
+ case "leader-ready":
8041
+ this.events.onLeaderReady(msg.leadershipId, msg.leaderTabId);
8042
+ break;
8043
+ case "attach-follower-ports":
8044
+ if (ports.length >= 2) this.events.onAttachFollowerPorts(msg.followerTabId, msg.leadershipId, ports[0], ports[1]);
8045
+ break;
8046
+ case "use-follower-ports":
8047
+ if (ports.length >= 2) this.events.onUseFollowerPorts(msg.leaderTabId, msg.leadershipId, ports[0], ports[1]);
8048
+ break;
8049
+ case "close-follower-ports":
8050
+ this.events.onCloseFollowerPorts(msg.leadershipId);
8051
+ break;
8052
+ case "unsupported":
8053
+ this.logger.warn({
8054
+ reason: msg.reason,
8055
+ Category: "sp00ky-client::BrokerClient"
8056
+ }, "Broker rejected this tab; running solo");
8057
+ this.events.onUnsupported(msg.reason);
8058
+ this.close();
8059
+ break;
8060
+ }
8061
+ }
8062
+ send(msg) {
8063
+ try {
8064
+ this.worker?.port.postMessage(msg);
8065
+ } catch {}
8066
+ }
8067
+ /** The tab moved to a different bucket: re-hello under the new namespace.
8068
+ * The broker treats a re-hello of a known tabId as leave + rejoin. */
8069
+ rehello(hello) {
8070
+ this.helloState = hello;
8071
+ if (!this.worker) return this.openWorker();
8072
+ this.send({
8073
+ type: "hello",
8074
+ tabId: this.tabId,
8075
+ fingerprint: hello.fingerprint,
8076
+ bucketId: hello.bucketId,
8077
+ visibility: currentVisibility(),
8078
+ heldLeadership: hello.heldLeadership()
8079
+ });
8080
+ return Promise.resolve();
8081
+ }
8082
+ startWatchdog() {
8083
+ if (this.watchdog) return;
8084
+ this.watchdog = setInterval(() => {
8085
+ if (Date.now() - this.lastPingAt > PING_INTERVAL_MS + PONG_TIMEOUT_MS) this.reconnect("broker pings stopped");
8086
+ }, PING_INTERVAL_MS);
8087
+ }
8088
+ reconnect(reason) {
8089
+ if (this.closed) return;
8090
+ this.logger.info({
8091
+ reason,
8092
+ Category: "sp00ky-client::BrokerClient"
8093
+ }, "Reconnecting to the tabs broker");
8094
+ try {
8095
+ this.worker?.port.close();
8096
+ } catch {}
8097
+ this.worker = null;
8098
+ this.brokerInstanceId = null;
8099
+ this.events.onBrokerRestarted();
8100
+ this.openWorker().catch((e) => {
8101
+ this.logger.warn({
8102
+ err: e,
8103
+ Category: "sp00ky-client::BrokerClient"
8104
+ }, "Broker reconnect failed; staying detached until the next watchdog tick");
8105
+ });
8106
+ }
8107
+ installLifecycleListeners() {
8108
+ if (typeof window === "undefined" || typeof document === "undefined") return;
8109
+ document.addEventListener("visibilitychange", () => {
8110
+ if (!this.helloState) return;
8111
+ this.send({
8112
+ type: "visibility",
8113
+ tabId: this.tabId,
8114
+ bucketId: this.helloState.bucketId,
8115
+ visibility: currentVisibility()
8116
+ });
8117
+ });
8118
+ const leave = () => {
8119
+ if (this.helloState) this.send({
8120
+ type: "shutdown",
8121
+ tabId: this.tabId,
8122
+ bucketId: this.helloState.bucketId
8123
+ });
8124
+ };
8125
+ window.addEventListener("pagehide", leave);
8126
+ document.addEventListener("freeze", leave);
8127
+ window.addEventListener("pageshow", (e) => {
8128
+ if (e.persisted && this.helloState) this.rehello(this.helloState);
8129
+ });
8130
+ document.addEventListener("resume", () => {
8131
+ if (this.helloState) this.rehello(this.helloState);
8132
+ });
8133
+ }
8134
+ close() {
8135
+ this.closed = true;
8136
+ if (this.watchdog) clearInterval(this.watchdog);
8137
+ this.watchdog = null;
8138
+ try {
8139
+ this.worker?.port.close();
8140
+ } catch {}
8141
+ this.worker = null;
8142
+ }
8143
+ };
8144
+ function currentVisibility() {
8145
+ if (typeof document === "undefined") return "visible";
8146
+ return document.visibilityState === "hidden" ? "hidden" : "visible";
8147
+ }
8148
+
8149
+ //#endregion
8150
+ //#region src/services/tabs/leader-locks.ts
8151
+ function lockManager() {
8152
+ const nav = globalThis.navigator;
8153
+ return nav?.locks && typeof nav.locks.request === "function" ? nav.locks : null;
8154
+ }
8155
+ /**
8156
+ * Acquire `name` exclusively. `steal: true` when the broker determined the
8157
+ * previous holder is a frozen zombie. Resolves null when the lock is held by
8158
+ * someone else and stealing was not requested.
8159
+ */
8160
+ function acquireLeaderTabLock(name, opts) {
8161
+ const locks = lockManager();
8162
+ if (!locks) return Promise.resolve({
8163
+ release() {},
8164
+ onLost() {}
8165
+ });
8166
+ return new Promise((resolve) => {
8167
+ let lostCb = null;
8168
+ let releasedIntentionally = false;
8169
+ let granted = false;
8170
+ let release = null;
8171
+ locks.request(name, opts.steal ? {
8172
+ mode: "exclusive",
8173
+ steal: true
8174
+ } : {
8175
+ mode: "exclusive",
8176
+ ifAvailable: true
8177
+ }, (lock) => {
8178
+ if (!lock) {
8179
+ resolve(null);
8180
+ return;
8181
+ }
8182
+ granted = true;
8183
+ resolve({
8184
+ release() {
8185
+ releasedIntentionally = true;
8186
+ release?.();
8187
+ },
8188
+ onLost(cb) {
8189
+ lostCb = cb;
8190
+ }
8191
+ });
8192
+ return new Promise((r) => {
8193
+ release = r;
8194
+ });
8195
+ }).then(() => {
8196
+ if (granted && !releasedIntentionally) lostCb?.();
8197
+ }, () => {
8198
+ if (granted && !releasedIntentionally) lostCb?.();
8199
+ else if (!granted) resolve(null);
8200
+ });
8201
+ });
8202
+ }
8203
+
8204
+ //#endregion
8205
+ //#region src/services/tabs/coordinator.ts
8206
+ /** How long boot waits for a role (election + attach) before going solo. */
8207
+ const START_TIMEOUT_MS = 15e3;
8208
+ /** Leader-side fan-out surface handed to the sync layer. The sync router
8209
+ * (modules/sync/tab-router.ts) registers itself as the message handler. */
8210
+ var LeaderSyncHub = class {
8211
+ followers = /* @__PURE__ */ new Map();
8212
+ seq = 0;
8213
+ onFollowerMessage = null;
8214
+ onFollowerDetached = null;
8215
+ constructor(leadershipId, logger) {
8216
+ this.leadershipId = leadershipId;
8217
+ this.logger = logger;
8218
+ }
8219
+ attach(tabId, port) {
8220
+ this.detach(tabId);
8221
+ this.followers.set(tabId, port);
8222
+ port.onmessage = (ev) => {
8223
+ this.onFollowerMessage?.(tabId, ev.data);
8224
+ };
8225
+ port.onmessageerror = () => this.detach(tabId);
8226
+ port.start?.();
8227
+ }
8228
+ detach(tabId) {
8229
+ const port = this.followers.get(tabId);
8230
+ if (!port) return;
8231
+ this.followers.delete(tabId);
8232
+ try {
8233
+ port.close();
8234
+ } catch {}
8235
+ this.onFollowerDetached?.(tabId);
8236
+ }
8237
+ detachAll() {
8238
+ for (const tabId of [...this.followers.keys()]) this.detach(tabId);
8239
+ }
8240
+ sendTo(tabId, msg) {
8241
+ try {
8242
+ this.followers.get(tabId)?.postMessage(msg);
8243
+ } catch {}
8244
+ }
8245
+ broadcast(msg, exceptTabId) {
8246
+ for (const [tabId, port] of this.followers) {
8247
+ if (tabId === exceptTabId) continue;
8248
+ try {
8249
+ port.postMessage(msg);
8250
+ } catch {}
8251
+ }
8252
+ }
8253
+ /** Stamped ingest relay; seq lets followers detect gaps. */
8254
+ relayIngest(tuples, exceptTabId) {
8255
+ if (this.followers.size === 0) return;
8256
+ this.broadcast({
8257
+ type: "ingest-relay",
8258
+ tuples,
8259
+ leadershipId: this.leadershipId,
8260
+ seq: ++this.seq
8261
+ }, exceptTabId);
8262
+ }
8263
+ get followerCount() {
8264
+ return this.followers.size;
8265
+ }
8266
+ get relayedBatches() {
8267
+ return this.seq;
8268
+ }
8269
+ };
8270
+ /** Follower half of the syncPort. Queues while detached (leaderless window)
8271
+ * and flushes on rebind; a lost-in-flight mutation notify is additionally
8272
+ * backstopped by the new leader reloading the shared outbox from the store. */
8273
+ var SyncForwarder = class {
8274
+ port = null;
8275
+ queued = [];
8276
+ onLeaderMessage = null;
8277
+ constructor(tabId) {
8278
+ this.tabId = tabId;
8279
+ }
8280
+ rebind(port) {
8281
+ this.unbind();
8282
+ this.port = port;
8283
+ port.onmessage = (ev) => {
8284
+ this.onLeaderMessage?.(ev.data);
8285
+ };
8286
+ port.start?.();
8287
+ this.post({
8288
+ type: "sync-hello",
8289
+ tabId: this.tabId
8290
+ });
8291
+ const backlog = this.queued;
8292
+ this.queued = [];
8293
+ for (const msg of backlog) this.post(msg);
8294
+ }
8295
+ unbind() {
8296
+ if (this.port) try {
8297
+ this.port.close();
8298
+ } catch {}
8299
+ this.port = null;
8300
+ }
8301
+ post(msg) {
8302
+ if (!this.port) {
8303
+ this.queued.push(msg);
8304
+ return;
8305
+ }
8306
+ try {
8307
+ this.port.postMessage(msg);
8308
+ } catch {
8309
+ this.queued.push(msg);
8310
+ }
8311
+ }
8312
+ mutationEnqueued(mutationId) {
8313
+ this.post({
8314
+ type: "mutation-enqueued",
8315
+ mutationId
8316
+ });
8317
+ }
8318
+ requestPoll() {
8319
+ this.post({ type: "request-poll" });
8320
+ }
8321
+ };
8322
+ var TabsCoordinator = class {
8323
+ role = "solo";
8324
+ leadershipId = 0;
8325
+ leaderTabId = null;
8326
+ brokerRole = "pending";
8327
+ fingerprint;
8328
+ bucketId;
8329
+ broker;
8330
+ hub = null;
8331
+ forwarder = null;
8332
+ roleListeners = /* @__PURE__ */ new Set();
8333
+ startResolve = null;
8334
+ startReject = null;
8335
+ /** Ports received before start() resolves or between roles. */
8336
+ promotionChain = Promise.resolve();
8337
+ closed = false;
8338
+ constructor(deps) {
8339
+ this.deps = deps;
8340
+ this.fingerprint = deps.fingerprint;
8341
+ this.bucketId = "anon";
8342
+ this.broker = new TabBrokerClient(new URL("./tabs-broker-worker.js", import.meta.url), deps.tabId, {
8343
+ onBecomeLeader: (msg) => this.enqueue(() => this.promote(msg)),
8344
+ onDemote: (leadershipId) => this.enqueue(() => this.demote(leadershipId)),
8345
+ onLeaderReady: (leadershipId, leaderTabId) => {
8346
+ this.leaderTabId = leaderTabId;
8347
+ },
8348
+ onAttachFollowerPorts: (followerTabId, leadershipId, dbPort, syncPort) => this.enqueue(() => this.serveFollower(followerTabId, leadershipId, dbPort, syncPort)),
8349
+ onUseFollowerPorts: (leaderTabId, leadershipId, dbPort, syncPort) => this.enqueue(() => this.attachToLeader(leaderTabId, leadershipId, dbPort, syncPort)),
8350
+ onCloseFollowerPorts: (leadershipId) => this.enqueue(() => this.handleLeaderGone(leadershipId)),
8351
+ onUnsupported: () => this.fallbackToSolo("broker rejected this tab"),
8352
+ onBrokerRestarted: () => {}
8353
+ }, deps.logger);
8354
+ if (typeof window !== "undefined" && deps.onLeaderPageHide) window.addEventListener("pagehide", () => {
8355
+ if (this.role === "leader") deps.onLeaderPageHide?.();
8356
+ });
8357
+ }
8358
+ /** Serialize role transitions; each is small but async (worker opens). */
8359
+ enqueue(fn) {
8360
+ this.promotionChain = this.promotionChain.then(fn, fn).catch((e) => {
8361
+ this.deps.logger.error({
8362
+ err: e,
8363
+ Category: "sp00ky-client::TabsCoordinator"
8364
+ }, "Role transition failed");
8365
+ });
8366
+ }
8367
+ onRoleChange(cb) {
8368
+ this.roleListeners.add(cb);
8369
+ return () => this.roleListeners.delete(cb);
8370
+ }
8371
+ setRole(role) {
8372
+ if (this.role !== role) {
8373
+ this.role = role;
8374
+ this.deps.logger.info({
8375
+ role,
8376
+ leadershipId: this.leadershipId,
8377
+ Category: "sp00ky-client::TabsCoordinator"
8378
+ }, "Tab role changed");
8379
+ for (const cb of this.roleListeners) cb(role);
8380
+ }
8381
+ if (this.startResolve && role !== "solo") {
8382
+ this.startResolve(role);
8383
+ this.startResolve = null;
8384
+ }
8385
+ }
8386
+ /** Connect the broker and resolve once this tab has a usable store role.
8387
+ * Rejects when no role lands in time; the caller then boots solo. */
8388
+ start(bucketId) {
8389
+ this.bucketId = bucketId;
8390
+ return new Promise((resolve, reject) => {
8391
+ const timeout = setTimeout(() => {
8392
+ this.startResolve = null;
8393
+ this.startReject = null;
8394
+ reject(/* @__PURE__ */ new Error("shared-tabs: no role assigned in time"));
8395
+ }, START_TIMEOUT_MS);
8396
+ this.startResolve = (role) => {
8397
+ clearTimeout(timeout);
8398
+ this.startReject = null;
8399
+ resolve(role);
8400
+ };
8401
+ this.startReject = (e) => {
8402
+ clearTimeout(timeout);
8403
+ this.startResolve = null;
8404
+ reject(e);
8405
+ };
8406
+ this.broker.connect({
8407
+ fingerprint: this.fingerprint,
8408
+ bucketId,
8409
+ heldLeadership: () => this.role === "leader" ? {
8410
+ leadershipId: this.leadershipId,
8411
+ workerLockName: workerLockName(this.fingerprint, this.bucketId, this.leadershipId)
8412
+ } : null
8413
+ }).catch((e) => {
8414
+ this.startReject?.(e instanceof Error ? e : new Error(String(e)));
8415
+ });
8416
+ });
8417
+ }
8418
+ /** Bucket switch: leave the old namespace, join the new one. Resolves when
8419
+ * a role lands in the new namespace. */
8420
+ moveToBucket(bucketId) {
8421
+ return new Promise((resolve, reject) => {
8422
+ this.enqueue(async () => {
8423
+ if (this.role === "leader") await this.teardownLeader();
8424
+ else if (this.role === "follower") this.teardownFollower("bucket switch");
8425
+ this.bucketId = bucketId;
8426
+ const timeout = setTimeout(() => {
8427
+ this.startResolve = null;
8428
+ this.startReject = null;
8429
+ reject(/* @__PURE__ */ new Error("shared-tabs: no role assigned after bucket switch"));
8430
+ }, START_TIMEOUT_MS);
8431
+ this.startResolve = (role) => {
8432
+ clearTimeout(timeout);
8433
+ this.startReject = null;
8434
+ resolve(role);
8435
+ };
8436
+ this.startReject = (e) => {
8437
+ clearTimeout(timeout);
8438
+ this.startResolve = null;
8439
+ reject(e);
8440
+ };
8441
+ await this.broker.rehello({
8442
+ fingerprint: this.fingerprint,
8443
+ bucketId,
8444
+ heldLeadership: () => null
8445
+ });
8446
+ });
8447
+ });
8448
+ }
8449
+ async promote(msg) {
8450
+ if (this.closed) return;
8451
+ if (msg.leadershipId <= this.leadershipId && !msg.resumeHeld) return;
8452
+ const previousRole = this.role;
8453
+ try {
8454
+ if (previousRole === "follower") this.teardownFollower("promoted");
8455
+ this.leadershipId = msg.leadershipId;
8456
+ const lock = await acquireLeaderTabLock(tabLockName(this.fingerprint, this.bucketId), { steal: msg.forceTakeover });
8457
+ if (!lock) throw new Error("leader tab lock unavailable");
8458
+ this.tabLock?.release();
8459
+ this.tabLock = lock;
8460
+ lock.onLost(() => {
8461
+ this.enqueue(async () => {
8462
+ if (this.leadershipId !== msg.leadershipId || this.role !== "leader") return;
8463
+ await this.teardownLeader();
8464
+ this.deps.hooks.onLeaderLost("tab lock stolen");
8465
+ });
8466
+ });
8467
+ await this.deps.hooks.adoptOwner(this.bucketId, {
8468
+ workerLockName: workerLockName(this.fingerprint, this.bucketId, msg.leadershipId),
8469
+ allowMemoryFallback: msg.allowMemoryFallback,
8470
+ forceTakeover: msg.forceTakeover,
8471
+ resumeHeld: msg.resumeHeld
8472
+ });
8473
+ this.hub = new LeaderSyncHub(msg.leadershipId, this.deps.logger);
8474
+ await this.deps.hooks.becomeSyncLeader(this.hub);
8475
+ this.setRole("leader");
8476
+ this.broker.send({
8477
+ type: "leader-ready",
8478
+ tabId: this.deps.tabId,
8479
+ bucketId: this.bucketId,
8480
+ leadershipId: msg.leadershipId
8481
+ });
8482
+ } catch (e) {
8483
+ const reason = e instanceof Error ? e.message : String(e);
8484
+ this.deps.logger.error({
8485
+ err: e,
8486
+ Category: "sp00ky-client::TabsCoordinator"
8487
+ }, "Promotion failed");
8488
+ this.hub?.detachAll();
8489
+ this.hub = null;
8490
+ this.broker.send({
8491
+ type: "leader-failed",
8492
+ tabId: this.deps.tabId,
8493
+ bucketId: this.bucketId,
8494
+ leadershipId: msg.leadershipId,
8495
+ reason
8496
+ });
8497
+ }
8498
+ }
8499
+ async demote(leadershipId) {
8500
+ if (this.role !== "leader" || leadershipId !== this.leadershipId) return;
8501
+ await this.teardownLeader();
8502
+ this.deps.hooks.onLeaderLost("demoted");
8503
+ }
8504
+ tabLock = null;
8505
+ async teardownLeader() {
8506
+ this.hub?.detachAll();
8507
+ this.hub = null;
8508
+ await this.deps.hooks.releaseOwnership();
8509
+ this.tabLock?.release();
8510
+ this.tabLock = null;
8511
+ }
8512
+ teardownFollower(reason) {
8513
+ this.forwarder?.unbind();
8514
+ this.deps.hooks.onLeaderLost(reason);
8515
+ }
8516
+ async serveFollower(followerTabId, leadershipId, dbPort, syncPort) {
8517
+ if (this.role !== "leader" || leadershipId !== this.leadershipId || !this.hub) {
8518
+ dbPort.close();
8519
+ syncPort.close();
8520
+ return;
8521
+ }
8522
+ await this.deps.hooks.exposeClientPort(followerTabId, dbPort);
8523
+ this.hub.attach(followerTabId, syncPort);
8524
+ this.hub.sendTo(followerTabId, {
8525
+ type: "db-ready",
8526
+ leadershipId,
8527
+ bucketId: this.bucketId,
8528
+ storageHealth: this.deps.hooks.currentStorageHealth()
8529
+ });
8530
+ this.broker.send({
8531
+ type: "follower-port-attached",
8532
+ tabId: this.deps.tabId,
8533
+ bucketId: this.bucketId,
8534
+ leadershipId,
8535
+ followerTabId
8536
+ });
8537
+ }
8538
+ async attachToLeader(leaderTabId, leadershipId, dbPort, syncPort) {
8539
+ if (this.closed || this.role === "leader") {
8540
+ dbPort.close();
8541
+ syncPort.close();
8542
+ return;
8543
+ }
8544
+ this.leaderTabId = leaderTabId;
8545
+ this.leadershipId = leadershipId;
8546
+ if (!this.forwarder) this.forwarder = new SyncForwarder(this.deps.tabId);
8547
+ const forwarder = this.forwarder;
8548
+ await new Promise((resolve) => {
8549
+ let adopted = false;
8550
+ const previousHandler = forwarder.onLeaderMessage;
8551
+ forwarder.onLeaderMessage = (msg) => {
8552
+ if (msg.type === "db-ready" && !adopted) {
8553
+ adopted = true;
8554
+ this.deps.hooks.adoptAttached(dbPort, {
8555
+ bucketId: msg.bucketId,
8556
+ storageHealth: msg.storageHealth,
8557
+ leadershipId: msg.leadershipId
8558
+ }).then(() => {
8559
+ this.deps.hooks.becomeSyncFollower(forwarder);
8560
+ this.setRole("follower");
8561
+ resolve();
8562
+ });
8563
+ return;
8564
+ }
8565
+ previousHandler?.(msg);
8566
+ };
8567
+ forwarder.rebind(syncPort);
8568
+ });
8569
+ }
8570
+ async handleLeaderGone(leadershipId) {
8571
+ if (this.role === "leader") return;
8572
+ if (leadershipId < this.leadershipId) return;
8573
+ this.teardownFollower("leader gone");
8574
+ }
8575
+ fallbackToSolo(reason) {
8576
+ this.deps.logger.warn({
8577
+ reason,
8578
+ Category: "sp00ky-client::TabsCoordinator"
8579
+ }, "Shared-tabs unavailable; running solo");
8580
+ this.deps.hooks.becomeSyncSolo();
8581
+ this.setRole("solo");
8582
+ this.startReject?.(/* @__PURE__ */ new Error(`shared-tabs unavailable: ${reason}`));
8583
+ }
8584
+ get syncHub() {
8585
+ return this.hub;
8586
+ }
8587
+ get syncForwarder() {
8588
+ return this.forwarder;
8589
+ }
8590
+ get tabId() {
8591
+ return this.deps.tabId;
8592
+ }
8593
+ async stop() {
8594
+ this.closed = true;
8595
+ if (this.role === "leader") await this.teardownLeader();
8596
+ else this.forwarder?.unbind();
8597
+ this.broker.send({
8598
+ type: "shutdown",
8599
+ tabId: this.deps.tabId,
8600
+ bucketId: this.bucketId
8601
+ });
8602
+ this.broker.close();
8603
+ }
8604
+ };
8605
+
7159
8606
  //#endregion
7160
8607
  //#region src/sp00ky.ts
7161
8608
  var BucketHandle = class {
@@ -7238,6 +8685,12 @@ var Sp00kyClient = class {
7238
8685
  logger;
7239
8686
  auth;
7240
8687
  streamProcessor;
8688
+ tabsCoordinator = null;
8689
+ sharedActive = false;
8690
+ /** Current shared-tabs role, or null when the feature is off/fell back. */
8691
+ get tabRole() {
8692
+ return this.sharedActive ? this.tabsCoordinator.role : null;
8693
+ }
7241
8694
  get remoteClient() {
7242
8695
  return this.remote.getClient();
7243
8696
  }
@@ -7296,7 +8749,8 @@ var Sp00kyClient = class {
7296
8749
  Category: "sp00ky-client::Sp00kyClient::constructor"
7297
8750
  }, "Sp00kyClient initialized");
7298
8751
  if (config.crdt) preloadLoro();
7299
- this.local = createLocalEngine(this.config.localEngine, this.config.database, logger);
8752
+ const tabsSupport = detectSharedTabsSupport(this.config);
8753
+ this.local = createLocalEngine(this.config.localEngine, this.config.database, logger, { shared: tabsSupport.supported });
7300
8754
  this.remote = new RemoteDatabaseService(this.config.database, logger);
7301
8755
  if (config.persistenceClient === "surrealdb") this.persistenceClient = new SurrealDBPersistenceClient(this.local, logger);
7302
8756
  else if (config.persistenceClient === "localstorage" || !config.persistenceClient) this.persistenceClient = new LocalStoragePersistenceClient(logger);
@@ -7330,6 +8784,86 @@ var Sp00kyClient = class {
7330
8784
  this.devTools = new DevToolsService(this.local, this.remote, logger, this.config.schema, this.auth, this.dataModule);
7331
8785
  this.streamProcessor.addReceiver(this.devTools);
7332
8786
  this.setupCallbacks();
8787
+ if (tabsSupport.supported) {
8788
+ this.tabsCoordinator = this.buildTabsCoordinator();
8789
+ this.devTools.setTabsInfoProvider(() => {
8790
+ if (!this.sharedActive || !this.tabsCoordinator) return null;
8791
+ const c = this.tabsCoordinator;
8792
+ return {
8793
+ role: c.role,
8794
+ tabId: c.tabId,
8795
+ leadershipId: c.leadershipId,
8796
+ leaderTabId: c.leaderTabId,
8797
+ followers: c.syncHub?.followerCount ?? null,
8798
+ relayedBatches: c.syncHub?.relayedBatches ?? null
8799
+ };
8800
+ });
8801
+ } else if (this.config.sharedTabs) this.logger.info({
8802
+ reason: tabsSupport.reason,
8803
+ Category: "sp00ky-client::Sp00kyClient::tabs"
8804
+ }, "sharedTabs requested but unsupported here; running solo");
8805
+ }
8806
+ /** The shared-tabs role machinery, wired to this client's modules. */
8807
+ buildTabsCoordinator() {
8808
+ const engine = this.local;
8809
+ const tabId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `tab_${Math.random().toString(36).slice(2)}`;
8810
+ return new TabsCoordinator({
8811
+ tabId,
8812
+ fingerprint: computeTabsFingerprint({
8813
+ coreVersion: "0.0.1-canary.155",
8814
+ schemaHash: hash53(this.config.schemaSurql),
8815
+ endpoint: this.config.database.endpoint ?? "",
8816
+ namespace: this.config.database.namespace,
8817
+ database: this.config.database.database
8818
+ }),
8819
+ hooks: {
8820
+ adoptOwner: (bucketId, opts) => engine.adoptOwner(bucketId, {
8821
+ workerLockName: opts.workerLockName,
8822
+ allowMemoryFallback: opts.allowMemoryFallback,
8823
+ resumeHeld: opts.resumeHeld
8824
+ }),
8825
+ adoptAttached: (dbPort, snapshot) => engine.adoptAttached(dbPort, snapshot, (reason) => engine.onLeaderLost(reason)),
8826
+ releaseOwnership: () => engine.releaseOwnership(),
8827
+ onLeaderLost: (reason) => engine.onLeaderLost(reason),
8828
+ exposeClientPort: (clientId, port) => engine.exposeClientPort(clientId, port),
8829
+ removeClientPort: (clientId) => engine.removeClientPort(clientId),
8830
+ becomeSyncLeader: async (hub) => {
8831
+ this.streamProcessor.setPersistenceEnabled(true);
8832
+ this.cache.setIngestRelay((tuples) => hub.relayIngest(tuples));
8833
+ this.sync.setTabContext("leader", tabId);
8834
+ this.dataModule.setTabId(tabId);
8835
+ await this.sync.promoteToLeader(hub);
8836
+ },
8837
+ becomeSyncFollower: (forwarder) => {
8838
+ this.streamProcessor.setPersistenceEnabled(false);
8839
+ this.cache.setIngestRelay(null);
8840
+ this.sync.setTabContext("follower", tabId);
8841
+ this.dataModule.setTabId(tabId);
8842
+ this.sync.demoteToFollower(forwarder);
8843
+ const inner = forwarder.onLeaderMessage;
8844
+ forwarder.onLeaderMessage = (msg) => {
8845
+ if (msg.type === "ingest-relay") {
8846
+ this.cache.applyRelayedIngest(msg.tuples);
8847
+ return;
8848
+ }
8849
+ inner?.(msg);
8850
+ };
8851
+ },
8852
+ becomeSyncSolo: () => {
8853
+ this.streamProcessor.setPersistenceEnabled(true);
8854
+ this.cache.setIngestRelay(null);
8855
+ this.sync.setTabContext("solo", tabId);
8856
+ },
8857
+ currentStorageHealth: () => this.local.storageHealth ?? {
8858
+ status: "unknown",
8859
+ fallback: false
8860
+ }
8861
+ },
8862
+ logger: this.logger,
8863
+ onLeaderPageHide: () => {
8864
+ engine.shutdownOwnedWorker();
8865
+ }
8866
+ });
7333
8867
  }
7334
8868
  /**
7335
8869
  * Setup direct callbacks instead of event subscriptions
@@ -7390,7 +8924,23 @@ var Sp00kyClient = class {
7390
8924
  this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization started");
7391
8925
  try {
7392
8926
  const bootBucket = readBootBucketHint() ?? ANON_USER_ID;
7393
- await this.local.connect(bootBucket);
8927
+ if (this.tabsCoordinator) try {
8928
+ const role = await this.tabsCoordinator.start(bootBucket);
8929
+ this.sharedActive = true;
8930
+ this.logger.info({
8931
+ role,
8932
+ bootBucket,
8933
+ Category: "sp00ky-client::Sp00kyClient::init"
8934
+ }, "Shared-tabs role assigned");
8935
+ } catch (e) {
8936
+ this.logger.warn({
8937
+ err: e,
8938
+ Category: "sp00ky-client::Sp00kyClient::init"
8939
+ }, "Shared-tabs unavailable; booting solo");
8940
+ this.sharedActive = false;
8941
+ await this.local.connect(bootBucket);
8942
+ }
8943
+ else await this.local.connect(bootBucket);
7394
8944
  this.logger.debug({
7395
8945
  bootBucket,
7396
8946
  Category: "sp00ky-client::Sp00kyClient::init"
@@ -7501,9 +9051,26 @@ var Sp00kyClient = class {
7501
9051
  this.crdtManager.closeAll({ flush: false });
7502
9052
  const reopen = gateRelease ?? this.local.beginSwitch();
7503
9053
  try {
7504
- await this.local.switchStore(target);
7505
- if (this.local.usesSurqlSchema) await this.migrator.provision(this.config.schemaSurql);
7506
- await this.local.queryUngated("DELETE _00_query;");
9054
+ if (this.sharedActive && this.tabsCoordinator) try {
9055
+ await this.tabsCoordinator.moveToBucket(target);
9056
+ } catch (e) {
9057
+ this.logger.warn({
9058
+ err: e,
9059
+ target,
9060
+ Category: "sp00ky-client::Sp00kyClient::doSwitchBucket"
9061
+ }, "Shared-tabs bucket move failed; switching solo");
9062
+ this.sharedActive = false;
9063
+ this.sync.setTabContext("solo", null);
9064
+ this.cache.setIngestRelay(null);
9065
+ this.streamProcessor.setPersistenceEnabled(true);
9066
+ await this.local.switchStore(target);
9067
+ await this.local.queryUngated("DELETE _00_query;");
9068
+ }
9069
+ else {
9070
+ await this.local.switchStore(target);
9071
+ if (this.local.usesSurqlSchema) await this.migrator.provision(this.config.schemaSurql);
9072
+ await this.local.queryUngated("DELETE _00_query;");
9073
+ }
7507
9074
  this.streamProcessor.setStateKeySuffix(target);
7508
9075
  await this.streamProcessor.reset();
7509
9076
  this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
@@ -7536,6 +9103,7 @@ var Sp00kyClient = class {
7536
9103
  await this.featureFlags.closeAll();
7537
9104
  await this.appReleases.closeAll();
7538
9105
  this.crdtManager.closeAll();
9106
+ if (this.tabsCoordinator) await this.tabsCoordinator.stop();
7539
9107
  await this.local.close();
7540
9108
  await this.remote.close();
7541
9109
  }