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

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 (40) hide show
  1. package/dist/index.d.ts +151 -0
  2. package/dist/index.js +1520 -120
  3. package/dist/{sqlite-plan-sql.js → sqlite-open.js} +83 -1
  4. package/dist/sqlite-worker.js +268 -108
  5. package/dist/tabs-broker-worker.d.ts +8 -0
  6. package/dist/tabs-broker-worker.js +434 -0
  7. package/dist/types.d.ts +32 -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 +12 -1
  15. package/src/modules/devtools/storage-info.ts +27 -0
  16. package/src/modules/sync/queue/queue-up.forwarded.test.ts +51 -0
  17. package/src/modules/sync/queue/queue-up.ts +74 -38
  18. package/src/modules/sync/scheduler.ts +4 -2
  19. package/src/modules/sync/sync.ts +186 -8
  20. package/src/services/database/engine-factory.ts +3 -2
  21. package/src/services/database/sqlite-cache-engine.test.ts +160 -103
  22. package/src/services/database/sqlite-cache-engine.ts +285 -72
  23. package/src/services/database/sqlite-open.ts +36 -1
  24. package/src/services/database/sqlite-select.test.ts +13 -46
  25. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  26. package/src/services/database/sqlite-transport.ts +219 -0
  27. package/src/services/database/sqlite-worker.ts +340 -55
  28. package/src/services/stream-processor/index.ts +11 -2
  29. package/src/services/tabs/broker-client.ts +283 -0
  30. package/src/services/tabs/broker.test.ts +278 -0
  31. package/src/services/tabs/coordinator.test.ts +192 -0
  32. package/src/services/tabs/coordinator.ts +567 -0
  33. package/src/services/tabs/fake-ports.fixture.ts +70 -0
  34. package/src/services/tabs/leader-locks.ts +75 -0
  35. package/src/services/tabs/protocol.ts +239 -0
  36. package/src/services/tabs/support.ts +36 -0
  37. package/src/services/tabs/tabs-broker-worker.ts +581 -0
  38. package/src/sp00ky.ts +176 -6
  39. package/src/types.ts +25 -0
  40. 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";
@@ -1442,6 +1442,171 @@ function setPath(obj, path, value) {
1442
1442
  cur[parts[parts.length - 1]] = value;
1443
1443
  }
1444
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
+
1445
1610
  //#endregion
1446
1611
  //#region src/services/database/sqlite-cache-engine.ts
1447
1612
  /**
@@ -1500,9 +1665,9 @@ function pureWriteOpResult(op) {
1500
1665
  * oracle E2E in the browser.
1501
1666
  */
1502
1667
  var SqliteCacheEngine = class {
1503
- worker = null;
1504
- seq = 0;
1505
- 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;
1506
1671
  storeEpoch = 0;
1507
1672
  knownTables = /* @__PURE__ */ new Set();
1508
1673
  useOpfs;
@@ -1526,12 +1691,20 @@ var SqliteCacheEngine = class {
1526
1691
  /** Schemaless — tables are created lazily on first write; no migrator. */
1527
1692
  usesSurqlSchema = false;
1528
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;
1529
1701
  constructor(config, logger, opts = {}) {
1530
1702
  this.config = config;
1531
1703
  this.logger = logger;
1532
1704
  this.useOpfs = opts.useOpfs ?? true;
1533
1705
  this.workerSelect = opts.workerSelect ?? config.workerSelect ?? true;
1534
1706
  this.workerSelectConfigured = this.workerSelect;
1707
+ this.shared = opts.shared ?? false;
1535
1708
  }
1536
1709
  get epoch() {
1537
1710
  return this.storeEpoch;
@@ -1607,36 +1780,16 @@ var SqliteCacheEngine = class {
1607
1780
  beginSwitch() {
1608
1781
  return () => {};
1609
1782
  }
1610
- spawnWorker() {
1611
- const worker = new Worker(new URL("./sqlite-worker.js", import.meta.url), { type: "module" });
1612
- worker.onmessage = (ev) => {
1613
- const { id, ok, error, ...rest } = ev.data ?? {};
1614
- const p = this.pending.get(id);
1615
- if (!p) return;
1616
- this.pending.delete(id);
1617
- if (ok) p.resolve(rest);
1618
- else p.reject(new Error(error));
1619
- };
1620
- const failAll = (msg) => {
1621
- const err = /* @__PURE__ */ new Error(`SQLite worker crashed: ${msg}`);
1622
- this.logger.error({
1623
- err,
1624
- Category: "sp00ky-client::SqliteCacheEngine::worker"
1625
- }, "Worker error");
1626
- for (const [, p] of this.pending) p.reject(err);
1627
- this.pending.clear();
1628
- };
1629
- worker.onerror = (e) => failAll(e.message || "onerror");
1630
- worker.onmessageerror = () => failAll("messageerror");
1631
- return worker;
1632
- }
1633
1783
  /** Serializes every worker op so reads/writes never overlap at the VFS layer
1634
1784
  * (overlapping ops trip SQLITE_BUSY). Mirrors the SurrealDB engine's
1635
- * 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.) */
1636
1788
  opQueue = Promise.resolve();
1637
1789
  call(type, payload) {
1638
1790
  const enqueuedAt = performance.now();
1639
- const run = () => {
1791
+ const run = async () => {
1792
+ if (this.roleGate) await this.roleGate.promise;
1640
1793
  getStats().queueWaitMs += performance.now() - enqueuedAt;
1641
1794
  return this.rawCall(type, payload);
1642
1795
  };
@@ -1645,8 +1798,7 @@ var SqliteCacheEngine = class {
1645
1798
  return result;
1646
1799
  }
1647
1800
  rawCall(type, payload) {
1648
- if (!this.worker) throw new Error("SqliteCacheEngine: not connected");
1649
- const id = ++this.seq;
1801
+ if (!this.transport) throw new Error("SqliteCacheEngine: not connected");
1650
1802
  const s = getStats();
1651
1803
  s.roundTrips++;
1652
1804
  s.byType[type] = (s.byType[type] ?? 0) + 1;
@@ -1656,31 +1808,19 @@ var SqliteCacheEngine = class {
1656
1808
  }
1657
1809
  s.inFlight++;
1658
1810
  s.maxInFlight = Math.max(s.maxInFlight, s.inFlight);
1659
- const done = () => {
1660
- s.inFlight--;
1661
- };
1811
+ if (this.transport.kind === "port") s.proxiedOps = (s.proxiedOps ?? 0) + 1;
1662
1812
  const sentAt = performance.now();
1663
- return new Promise((resolve, reject) => {
1664
- this.pending.set(id, {
1665
- resolve: (v) => {
1666
- done();
1667
- const wt = v?.wt;
1668
- if (typeof wt === "number") {
1669
- s.workerMs += wt;
1670
- s.rpcOverheadMs += Math.max(0, performance.now() - sentAt - wt);
1671
- }
1672
- resolve(v);
1673
- },
1674
- reject: (e) => {
1675
- done();
1676
- reject(e);
1677
- }
1678
- });
1679
- this.worker.postMessage({
1680
- id,
1681
- type,
1682
- payload
1683
- });
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;
1684
1824
  });
1685
1825
  }
1686
1826
  /**
@@ -1688,12 +1828,25 @@ var SqliteCacheEngine = class {
1688
1828
  * {@link call}) so it can run as the body of an already-queued opQueue entry
1689
1829
  * without re-queuing onto itself. Callers must run it through the opQueue.
1690
1830
  */
1691
- async openInternal(bucketId) {
1692
- 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
+ }
1693
1844
  const { persisted, opfsError } = await this.rawCall("open", {
1694
1845
  dbName: bucketId,
1695
1846
  useOpfs: this.useOpfs,
1696
- systemTables: SYSTEM_TABLES
1847
+ systemTables: SYSTEM_TABLES,
1848
+ ...extras?.workerLockName ? { workerLockName: extras.workerLockName } : {},
1849
+ ...extras?.openOptions ? { openOptions: extras.openOptions } : {}
1697
1850
  });
1698
1851
  this.knownTables.clear();
1699
1852
  for (const t of SYSTEM_TABLES) this.knownTables.add(t);
@@ -1704,6 +1857,7 @@ var SqliteCacheEngine = class {
1704
1857
  fallback: fellBack
1705
1858
  };
1706
1859
  if (fellBack && opfsError) health.error = opfsError;
1860
+ if (this.shared) health.role = this.roleLabel;
1707
1861
  this.setStorageHealth(health);
1708
1862
  const stats = getStats();
1709
1863
  stats.persisted = persisted;
@@ -1733,23 +1887,174 @@ var SqliteCacheEngine = class {
1733
1887
  async switchBucket(bucketId) {
1734
1888
  this.storeEpoch++;
1735
1889
  await this.enqueue(async () => {
1736
- if (this.worker) {
1890
+ if (this.transport) {
1737
1891
  try {
1738
1892
  await this.rawCall("close");
1739
1893
  } catch {}
1740
- this.worker.terminate();
1741
- this.worker = null;
1894
+ this.transport.close("bucket switch");
1895
+ this.transport = null;
1742
1896
  }
1743
1897
  await this.openInternal(bucketId);
1744
1898
  });
1745
1899
  }
1746
1900
  async close() {
1747
- if (!this.worker) return;
1901
+ if (!this.transport) return;
1748
1902
  try {
1749
1903
  await this.call("close");
1750
1904
  } catch {}
1751
- this.worker.terminate();
1752
- 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;
1753
2058
  }
1754
2059
  async ensureTable(table) {
1755
2060
  if (this.knownTables.has(table)) return;
@@ -2140,9 +2445,12 @@ function normalizePlanForClone(plan) {
2140
2445
  * `this.local` through the SurrealQL-vocabulary shim + verb surface.
2141
2446
  * - a custom object → used as-is (must satisfy {@link LocalStore}).
2142
2447
  */
2143
- function createLocalEngine(choice, config, logger) {
2448
+ function createLocalEngine(choice, config, logger, opts = {}) {
2144
2449
  if (choice === void 0 || choice === "surrealdb") return new SurrealCacheEngine(config, logger);
2145
- 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
+ });
2146
2454
  return choice;
2147
2455
  }
2148
2456
 
@@ -2267,6 +2575,35 @@ function readNumberAfter(sql, from) {
2267
2575
  return m ? parseInt(m[0], 10) : null;
2268
2576
  }
2269
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
+
2270
2607
  //#endregion
2271
2608
  //#region src/modules/data/index.ts
2272
2609
  /** Push a timing sample (ms) into a rolling window, capped at the sample window. */
@@ -2300,6 +2637,9 @@ function phaseStatOf(samples, lastMs) {
2300
2637
  * Uses CacheModule for all storage operations.
2301
2638
  */
2302
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;
2303
2643
  activeQueries = /* @__PURE__ */ new Map();
2304
2644
  pendingQueries = /* @__PURE__ */ new Map();
2305
2645
  subscriptions = /* @__PURE__ */ new Map();
@@ -2359,6 +2699,11 @@ var DataModule = class {
2359
2699
  setSessionId(sessionId) {
2360
2700
  this.sessionId = sessionId;
2361
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
+ }
2362
2707
  /**
2363
2708
  * Update the authenticated user record id. Pass `null` on sign-out.
2364
2709
  * Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
@@ -3111,7 +3456,7 @@ var DataModule = class {
3111
3456
  if (!tableSchema) throw new Error(`Table ${tableName} not found`);
3112
3457
  const rid = parseRecordIdString(id);
3113
3458
  const params = parseParams(tableSchema.columns, data);
3114
- const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
3459
+ const mutationId = parseRecordIdString(mintMutationId(this.tabId));
3115
3460
  const dataKeys = Object.keys(params).map((key) => ({
3116
3461
  key,
3117
3462
  variable: `data_${key}`
@@ -3154,7 +3499,7 @@ var DataModule = class {
3154
3499
  if (!tableSchema) throw new Error(`Table ${tableName} not found`);
3155
3500
  const rid = parseRecordIdString(id);
3156
3501
  const params = parseParams(tableSchema.columns, data);
3157
- const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
3502
+ const mutationId = parseRecordIdString(mintMutationId(this.tabId));
3158
3503
  const [beforeRecord] = await withRetry(this.logger, () => this.local.query("SELECT * FROM ONLY $id", { id: rid }));
3159
3504
  const query = surql.seal(surql.tx([
3160
3505
  surql.updateSet("id", [{ statement: "_00_rv += 1" }]),
@@ -3205,7 +3550,7 @@ var DataModule = class {
3205
3550
  const tableName = extractTablePart(id);
3206
3551
  if (!this.schema.tables.find((t) => t.name === tableName)) throw new Error(`Table ${tableName} not found`);
3207
3552
  const rid = parseRecordIdString(id);
3208
- const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
3553
+ const mutationId = parseRecordIdString(mintMutationId(this.tabId));
3209
3554
  const [beforeRecords] = await this.local.query("SELECT * FROM ONLY $id", { id: rid });
3210
3555
  const beforeRecord = beforeRecords ?? {};
3211
3556
  const query = surql.seal(surql.tx([surql.delete("id"), surql.createMutation("delete", "mid", "id")]));
@@ -3655,39 +4000,31 @@ var UpQueue = class {
3655
4000
  async removeEventFromDatabase(mutation_id) {
3656
4001
  return this.local.query(`DELETE $mutation_id`, { mutation_id });
3657
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
+ }
3658
4024
  async loadFromDatabase() {
3659
4025
  try {
3660
- const [records] = await this.local.query(`SELECT * FROM _00_pending_mutations ORDER BY created_at ASC`);
3661
- this.queue = records.map((r) => {
3662
- switch (r.mutationType) {
3663
- case "create": return {
3664
- type: "create",
3665
- mutation_id: parseRecordIdString(r.id),
3666
- record_id: parseRecordIdString(r.recordId),
3667
- data: r.data,
3668
- tableName: extractTablePart(r.recordId)
3669
- };
3670
- case "update": return {
3671
- type: "update",
3672
- mutation_id: parseRecordIdString(r.id),
3673
- record_id: parseRecordIdString(r.recordId),
3674
- data: r.data,
3675
- beforeRecord: r.beforeRecord
3676
- };
3677
- case "delete": return {
3678
- type: "delete",
3679
- mutation_id: parseRecordIdString(r.id),
3680
- record_id: parseRecordIdString(r.recordId)
3681
- };
3682
- default:
3683
- this.logger.warn({
3684
- mutationType: r.mutationType,
3685
- record: r,
3686
- Category: "sp00ky-client::UpQueue::loadFromDatabase"
3687
- }, "Unknown mutation type");
3688
- return null;
3689
- }
3690
- }).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);
3691
4028
  } catch (error) {
3692
4029
  this.logger.error({
3693
4030
  error,
@@ -3696,6 +4033,41 @@ var UpQueue = class {
3696
4033
  }
3697
4034
  }
3698
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
+ }
3699
4071
 
3700
4072
  //#endregion
3701
4073
  //#region src/modules/sync/queue/queue-down.ts
@@ -4089,8 +4461,8 @@ var SyncScheduler = class {
4089
4461
  this.onRollback = onRollback;
4090
4462
  this.onSyncOutcome = onSyncOutcome;
4091
4463
  }
4092
- async init() {
4093
- await this.upQueue.loadFromDatabase();
4464
+ async init(opts = {}) {
4465
+ if (opts.loadOutbox !== false) await this.upQueue.loadFromDatabase();
4094
4466
  this.upQueue.events.subscribe(SyncQueueEventTypes.MutationEnqueued, this.syncUp.bind(this));
4095
4467
  this.downQueue.events.subscribe(SyncQueueEventTypes.QueryItemEnqueued, this.syncDown.bind(this));
4096
4468
  }
@@ -4208,6 +4580,10 @@ var Sp00kySync = class Sp00kySync {
4208
4580
  wasDisconnected = false;
4209
4581
  events = createSyncEventSystem();
4210
4582
  currentUserId = null;
4583
+ tabRole = "solo";
4584
+ tabId = null;
4585
+ hub = null;
4586
+ forwarder = null;
4211
4587
  refMode = DEFAULT_REF_MODE;
4212
4588
  anonLiveEnabled;
4213
4589
  currentLiveQueryUuid = null;
@@ -4380,7 +4756,7 @@ var Sp00kySync = class Sp00kySync {
4380
4756
  async init() {
4381
4757
  if (this.isInit) throw new Error("Sp00kySync is already initialized");
4382
4758
  this.isInit = true;
4383
- await this.scheduler.init();
4759
+ await this.scheduler.init({ loadOutbox: this.tabRole !== "follower" });
4384
4760
  this.subscribeToReconnect();
4385
4761
  this.scheduler.syncUp();
4386
4762
  this.scheduler.syncDown();
@@ -4394,6 +4770,93 @@ var Sp00kySync = class Sp00kySync {
4394
4770
  });
4395
4771
  }
4396
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
+ }
4397
4860
  /**
4398
4861
  * Quiesce all sync activity ahead of a local-bucket switch. After this
4399
4862
  * resolves, nothing in the sync module writes to the local store: the poll
@@ -4674,6 +5137,7 @@ var Sp00kySync = class Sp00kySync {
4674
5137
  });
4675
5138
  }
4676
5139
  async startRefLiveQueries() {
5140
+ if (this.tabRole === "follower") return;
4677
5141
  const tableName = this.listRefTable();
4678
5142
  this.logger.debug({
4679
5143
  tableName,
@@ -4707,9 +5171,17 @@ var Sp00kySync = class Sp00kySync {
4707
5171
  async handleRemoteListRefChange(action, queryId, recordId, version) {
4708
5172
  this.lastLiveEventAt = Date.now();
4709
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
+ });
4710
5182
  const existing = this.dataModule.getQueryById(queryId);
4711
5183
  if (!existing) {
4712
- this.logger.warn({
5184
+ if (!this.hub) this.logger.warn({
4713
5185
  queryId: queryId.toString(),
4714
5186
  Category: "sp00ky-client::Sp00kySync::handleRemoteListRefChange"
4715
5187
  }, "Received remote update for unknown local query");
@@ -4744,6 +5216,14 @@ var Sp00kySync = class Sp00kySync {
4744
5216
  this.lastLiveEventAt = Date.now();
4745
5217
  this.listRefIdleStreak = 0;
4746
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
+ });
4747
5227
  const existing = this.dataModule.getQueryById(queryId);
4748
5228
  if (!existing) return;
4749
5229
  const item = {
@@ -4839,6 +5319,15 @@ var Sp00kySync = class Sp00kySync {
4839
5319
  recordId,
4840
5320
  error: error.message
4841
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
+ });
4842
5331
  }
4843
5332
  async processDownEvent(event) {
4844
5333
  this.logger.debug({
@@ -4949,6 +5438,10 @@ var Sp00kySync = class Sp00kySync {
4949
5438
  * @param mutations Array of UpEvents (create/update/delete) to enqueue.
4950
5439
  */
4951
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
+ }
4952
5445
  this.scheduler.enqueueMutation(mutations);
4953
5446
  }
4954
5447
  async registerQuery(queryHash) {
@@ -5201,8 +5694,8 @@ async function walkOpfs(maxEntries = 2e3, maxDepth = 8) {
5201
5694
 
5202
5695
  //#endregion
5203
5696
  //#region src/modules/devtools/index.ts
5204
- const CORE_VERSION = "0.0.1-canary.154";
5205
- const WASM_VERSION = "0.0.1-canary.154";
5697
+ const CORE_VERSION = "0.0.1-canary.156";
5698
+ const WASM_VERSION = "0.0.1-canary.156";
5206
5699
  const SURREAL_VERSION = "3.0.3";
5207
5700
  var DevToolsService = class {
5208
5701
  eventsHistory = [];
@@ -5210,6 +5703,12 @@ var DevToolsService = class {
5210
5703
  version = CORE_VERSION;
5211
5704
  backendInfo = emptyBackendInfo();
5212
5705
  enabled = false;
5706
+ /** Shared-tabs snapshot for the panel, wired by Sp00kyClient whenever the
5707
+ * feature was REQUESTED (so an inactive/degraded tab still reports why). */
5708
+ tabsInfoProvider = null;
5709
+ setTabsInfoProvider(provider) {
5710
+ this.tabsInfoProvider = provider;
5711
+ }
5213
5712
  localTables = [];
5214
5713
  localTablesFetching = false;
5215
5714
  localTablesAt = 0;
@@ -5403,7 +5902,8 @@ var DevToolsService = class {
5403
5902
  storage: this.databaseService.storageHealth ?? {
5404
5903
  status: "unknown",
5405
5904
  fallback: false
5406
- }
5905
+ },
5906
+ tabs: this.tabsInfoProvider?.() ?? null
5407
5907
  }
5408
5908
  });
5409
5909
  }
@@ -5426,6 +5926,7 @@ var DevToolsService = class {
5426
5926
  status: "unknown",
5427
5927
  fallback: false
5428
5928
  },
5929
+ tabs: this.tabsInfoProvider?.() ?? null,
5429
5930
  browser: {},
5430
5931
  opfs: {
5431
5932
  supported: false,
@@ -5810,6 +6311,7 @@ var StreamProcessorService = class {
5810
6311
  };
5811
6312
  stateKeySuffix = "";
5812
6313
  stateGeneration = 0;
6314
+ persistState = true;
5813
6315
  constructor(events, db, persistenceClient, logger) {
5814
6316
  this.events = events;
5815
6317
  this.db = db;
@@ -5937,8 +6439,12 @@ var StreamProcessorService = class {
5937
6439
  this.processor = new Sp00kyProcessor();
5938
6440
  this.logger.info({ Category: "sp00ky-client::StreamProcessorService::reset" }, "Stream processor reset (fresh circuit)");
5939
6441
  }
6442
+ /** Toggle circuit-state persistence (shared-tabs follower/leader role). */
6443
+ setPersistenceEnabled(enabled) {
6444
+ this.persistState = enabled;
6445
+ }
5940
6446
  async loadState() {
5941
- if (!this.processor) return;
6447
+ if (!this.processor || !this.persistState) return;
5942
6448
  try {
5943
6449
  const result = await this.persistenceClient.get(this.stateKey());
5944
6450
  if (Array.isArray(result) && result.length > 0 && Array.isArray(result[0]) && result[0].length > 0 && result[0][0]?.state) {
@@ -5996,7 +6502,7 @@ var StreamProcessorService = class {
5996
6502
  }, "Session auth context updated");
5997
6503
  }
5998
6504
  async saveState() {
5999
- if (!this.processor) return;
6505
+ if (!this.processor || !this.persistState) return;
6000
6506
  const generation = this.stateGeneration;
6001
6507
  try {
6002
6508
  if (typeof this.processor.save_state === "function") {
@@ -6214,16 +6720,14 @@ function selectPredicateFromBody(body) {
6214
6720
 
6215
6721
  //#endregion
6216
6722
  //#region src/modules/cache/index.ts
6217
- /**
6218
- * CacheModule - Centralized storage and DBSP ingestion
6219
- *
6220
- * Single responsibility: Handle all local storage operations and DBSP ingestion.
6221
- * This module acts as the bridge between data operations and persistence.
6222
- */
6223
6723
  var CacheModule = class {
6224
6724
  logger;
6225
6725
  streamUpdateCallback;
6226
6726
  versionLookups = {};
6727
+ /** Shared-tabs leader: fan every committed ingest out to follower circuits.
6728
+ * Fired AFTER the local tx (the rows are already in the shared store, so a
6729
+ * follower only needs the circuit feed). Never set on followers. */
6730
+ ingestRelay = null;
6227
6731
  constructor(local, streamProcessor, streamUpdateCallback, logger) {
6228
6732
  this.local = local;
6229
6733
  this.streamProcessor = streamProcessor;
@@ -6243,6 +6747,24 @@ var CacheModule = class {
6243
6747
  }, "Stream update received");
6244
6748
  this.streamUpdateCallback(update);
6245
6749
  }
6750
+ setIngestRelay(cb) {
6751
+ this.ingestRelay = cb;
6752
+ }
6753
+ /**
6754
+ * Shared-tabs follower: feed relayed tuples into THIS tab's circuit only.
6755
+ * The rows are already in the shared store (the leader wrote them), so no
6756
+ * local write happens here; the normal chain then runs: SSP -> stream update
6757
+ * -> DataModule debounce -> materializeRecords (re-reads via the port
6758
+ * transport) -> this tab's subscriptions fire with this tab's hashes.
6759
+ */
6760
+ applyRelayedIngest(tuples) {
6761
+ for (const t of tuples) {
6762
+ const rv = t.record?._00_rv;
6763
+ if (t.op === "DELETE") delete this.versionLookups[t.id];
6764
+ else if (typeof rv === "number") this.versionLookups[t.id] = rv;
6765
+ }
6766
+ this.streamProcessor.ingestMany(tuples);
6767
+ }
6246
6768
  lookup(recordId) {
6247
6769
  return this.versionLookups[recordId] ?? 0;
6248
6770
  }
@@ -6307,6 +6829,7 @@ var CacheModule = class {
6307
6829
  };
6308
6830
  });
6309
6831
  this.streamProcessor.ingestMany(bulk);
6832
+ this.ingestRelay?.(bulk);
6310
6833
  this.logger.debug({
6311
6834
  count: records.length,
6312
6835
  Category: "sp00ky-client::CacheModule::saveBatch"
@@ -6342,6 +6865,12 @@ var CacheModule = class {
6342
6865
  if (this.local.epoch !== epoch) throw new StaleEpochError();
6343
6866
  delete this.versionLookups[id];
6344
6867
  this.streamProcessor.ingest(table, "DELETE", id, recordData);
6868
+ this.ingestRelay?.([{
6869
+ table,
6870
+ op: "DELETE",
6871
+ id,
6872
+ record: recordData
6873
+ }]);
6345
6874
  this.logger.debug({
6346
6875
  table,
6347
6876
  id,
@@ -7335,6 +7864,747 @@ var ResilientPersistenceClient = class {
7335
7864
  }
7336
7865
  };
7337
7866
 
7867
+ //#endregion
7868
+ //#region src/services/tabs/support.ts
7869
+ function detectSharedTabsSupport(config) {
7870
+ if (config.sharedTabs !== true) return {
7871
+ supported: false,
7872
+ reason: "flag-off"
7873
+ };
7874
+ if (config.localEngine !== "sqlite") return {
7875
+ supported: false,
7876
+ reason: "engine-not-sqlite"
7877
+ };
7878
+ if (typeof window === "undefined") return {
7879
+ supported: false,
7880
+ reason: "not-browser"
7881
+ };
7882
+ if (typeof SharedWorker === "undefined") return {
7883
+ supported: false,
7884
+ reason: "no-shared-worker"
7885
+ };
7886
+ if (typeof MessageChannel === "undefined") return {
7887
+ supported: false,
7888
+ reason: "no-message-channel"
7889
+ };
7890
+ const locks = navigator.locks;
7891
+ if (!locks || typeof locks.request !== "function") return {
7892
+ supported: false,
7893
+ reason: "no-web-locks"
7894
+ };
7895
+ return { supported: true };
7896
+ }
7897
+
7898
+ //#endregion
7899
+ //#region src/services/tabs/protocol.ts
7900
+ const TABS_PROTOCOL_VERSION = 1;
7901
+ /** Broker pings every tab at this cadence. */
7902
+ const PING_INTERVAL_MS = 5e3;
7903
+ /** A tab that has not ponged for this long is presumed dead and evicted.
7904
+ * MUST stay above the sqlite worker's freeze-suspect threshold (10s): a
7905
+ * freeze shorter than that never triggers a steal, which is what makes the
7906
+ * worker-side thaw gate sufficient. */
7907
+ const PONG_TIMEOUT_MS = 15e3;
7908
+ /** Deterministic JSON: objects with sorted keys, so equal inputs always
7909
+ * produce equal fingerprints regardless of construction order. */
7910
+ function stableStringify(value) {
7911
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
7912
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
7913
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`;
7914
+ }
7915
+ /** cyrb53: tiny, fast, good-enough 53-bit hash for identity strings. */
7916
+ function hash53(str, seed = 0) {
7917
+ let h1 = 3735928559 ^ seed;
7918
+ let h2 = 1103547991 ^ seed;
7919
+ for (let i = 0; i < str.length; i++) {
7920
+ const ch = str.charCodeAt(i);
7921
+ h1 = Math.imul(h1 ^ ch, 2654435761);
7922
+ h2 = Math.imul(h2 ^ ch, 1597334677);
7923
+ }
7924
+ h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
7925
+ h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
7926
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
7927
+ }
7928
+ function computeTabsFingerprint(input) {
7929
+ return hash53(stableStringify({
7930
+ v: TABS_PROTOCOL_VERSION,
7931
+ ...input
7932
+ }));
7933
+ }
7934
+ /** Lock names. The worker lock embeds the leadershipId, so after a steal the
7935
+ * exact name is never re-acquired by anyone: "not held" is an unambiguous
7936
+ * fencing check for a thawed worker, with no clientId resolution needed. */
7937
+ function tabLockName(fingerprint, bucketId) {
7938
+ return `sp00ky-tabs:${fingerprint}:${bucketId}:tab`;
7939
+ }
7940
+ function workerLockName(fingerprint, bucketId, leadershipId) {
7941
+ return `sp00ky-tabs:${fingerprint}:${bucketId}:worker:${leadershipId}`;
7942
+ }
7943
+
7944
+ //#endregion
7945
+ //#region src/services/tabs/broker-client.ts
7946
+ var TabBrokerClient = class {
7947
+ worker = null;
7948
+ brokerInstanceId = null;
7949
+ watchdog = null;
7950
+ lastPingAt = 0;
7951
+ closed = false;
7952
+ helloState = null;
7953
+ constructor(workerUrl, tabId, events, logger) {
7954
+ this.workerUrl = workerUrl;
7955
+ this.tabId = tabId;
7956
+ this.events = events;
7957
+ this.logger = logger;
7958
+ }
7959
+ /** Connect and hello. Resolves once broker-hello arrives (or rejects on
7960
+ * timeout / SharedWorker error, in which case the caller goes solo). */
7961
+ connect(hello) {
7962
+ this.helloState = hello;
7963
+ this.installLifecycleListeners();
7964
+ return this.openWorker();
7965
+ }
7966
+ openWorker() {
7967
+ if (this.closed || !this.helloState) return Promise.reject(/* @__PURE__ */ new Error("broker client closed"));
7968
+ return new Promise((resolve, reject) => {
7969
+ let settled = false;
7970
+ const settle = (fn) => {
7971
+ if (!settled) {
7972
+ settled = true;
7973
+ fn();
7974
+ }
7975
+ };
7976
+ let worker;
7977
+ try {
7978
+ worker = new SharedWorker(this.workerUrl, {
7979
+ type: "module",
7980
+ name: "sp00ky-tabs-broker"
7981
+ });
7982
+ } catch (e) {
7983
+ reject(e);
7984
+ return;
7985
+ }
7986
+ this.worker = worker;
7987
+ const timeout = setTimeout(() => settle(() => reject(/* @__PURE__ */ new Error("broker hello timeout"))), 5e3);
7988
+ worker.onerror = () => {
7989
+ clearTimeout(timeout);
7990
+ settle(() => reject(/* @__PURE__ */ new Error("SharedWorker failed to start")));
7991
+ };
7992
+ worker.port.onmessage = (ev) => {
7993
+ const msg = ev.data;
7994
+ if (!msg || typeof msg !== "object") return;
7995
+ if (msg.type === "broker-hello") {
7996
+ clearTimeout(timeout);
7997
+ this.noteInstance(msg.brokerInstanceId);
7998
+ this.lastPingAt = Date.now();
7999
+ this.startWatchdog();
8000
+ settle(resolve);
8001
+ return;
8002
+ }
8003
+ this.handleMessage(msg, ev.ports ?? []);
8004
+ };
8005
+ worker.port.onmessageerror = () => this.reconnect("messageerror");
8006
+ worker.port.start?.();
8007
+ this.send({
8008
+ type: "hello",
8009
+ tabId: this.tabId,
8010
+ fingerprint: this.helloState.fingerprint,
8011
+ bucketId: this.helloState.bucketId,
8012
+ visibility: currentVisibility(),
8013
+ heldLeadership: this.helloState.heldLeadership()
8014
+ });
8015
+ });
8016
+ }
8017
+ noteInstance(id) {
8018
+ if (this.brokerInstanceId !== null && this.brokerInstanceId !== id) this.events.onBrokerRestarted();
8019
+ this.brokerInstanceId = id;
8020
+ }
8021
+ handleMessage(msg, ports) {
8022
+ if ("brokerInstanceId" in msg) this.noteInstance(msg.brokerInstanceId);
8023
+ switch (msg.type) {
8024
+ case "ping":
8025
+ this.lastPingAt = Date.now();
8026
+ this.send({
8027
+ type: "pong",
8028
+ tabId: this.tabId
8029
+ });
8030
+ break;
8031
+ case "become-leader":
8032
+ this.events.onBecomeLeader({
8033
+ leadershipId: msg.leadershipId,
8034
+ forceTakeover: msg.forceTakeover,
8035
+ allowMemoryFallback: msg.allowMemoryFallback,
8036
+ resumeHeld: msg.resumeHeld
8037
+ });
8038
+ break;
8039
+ case "demote":
8040
+ this.events.onDemote(msg.leadershipId);
8041
+ break;
8042
+ case "leader-ready":
8043
+ this.events.onLeaderReady(msg.leadershipId, msg.leaderTabId);
8044
+ break;
8045
+ case "attach-follower-ports":
8046
+ if (ports.length >= 2) this.events.onAttachFollowerPorts(msg.followerTabId, msg.leadershipId, ports[0], ports[1]);
8047
+ break;
8048
+ case "use-follower-ports":
8049
+ if (ports.length >= 2) this.events.onUseFollowerPorts(msg.leaderTabId, msg.leadershipId, ports[0], ports[1]);
8050
+ break;
8051
+ case "close-follower-ports":
8052
+ this.events.onCloseFollowerPorts(msg.leadershipId);
8053
+ break;
8054
+ case "unsupported":
8055
+ this.logger.warn({
8056
+ reason: msg.reason,
8057
+ Category: "sp00ky-client::BrokerClient"
8058
+ }, "Broker rejected this tab; running solo");
8059
+ this.events.onUnsupported(msg.reason);
8060
+ this.close();
8061
+ break;
8062
+ }
8063
+ }
8064
+ send(msg) {
8065
+ try {
8066
+ this.worker?.port.postMessage(msg);
8067
+ } catch {}
8068
+ }
8069
+ /** The tab moved to a different bucket: re-hello under the new namespace.
8070
+ * The broker treats a re-hello of a known tabId as leave + rejoin. */
8071
+ rehello(hello) {
8072
+ this.helloState = hello;
8073
+ if (!this.worker) return this.openWorker();
8074
+ this.send({
8075
+ type: "hello",
8076
+ tabId: this.tabId,
8077
+ fingerprint: hello.fingerprint,
8078
+ bucketId: hello.bucketId,
8079
+ visibility: currentVisibility(),
8080
+ heldLeadership: hello.heldLeadership()
8081
+ });
8082
+ return Promise.resolve();
8083
+ }
8084
+ startWatchdog() {
8085
+ if (this.watchdog) return;
8086
+ this.watchdog = setInterval(() => {
8087
+ if (Date.now() - this.lastPingAt > PING_INTERVAL_MS + PONG_TIMEOUT_MS) this.reconnect("broker pings stopped");
8088
+ }, PING_INTERVAL_MS);
8089
+ }
8090
+ reconnect(reason) {
8091
+ if (this.closed) return;
8092
+ this.logger.info({
8093
+ reason,
8094
+ Category: "sp00ky-client::BrokerClient"
8095
+ }, "Reconnecting to the tabs broker");
8096
+ try {
8097
+ this.worker?.port.close();
8098
+ } catch {}
8099
+ this.worker = null;
8100
+ this.brokerInstanceId = null;
8101
+ this.events.onBrokerRestarted();
8102
+ this.openWorker().catch((e) => {
8103
+ this.logger.warn({
8104
+ err: e,
8105
+ Category: "sp00ky-client::BrokerClient"
8106
+ }, "Broker reconnect failed; staying detached until the next watchdog tick");
8107
+ });
8108
+ }
8109
+ installLifecycleListeners() {
8110
+ if (typeof window === "undefined" || typeof document === "undefined") return;
8111
+ document.addEventListener("visibilitychange", () => {
8112
+ if (!this.helloState) return;
8113
+ this.send({
8114
+ type: "visibility",
8115
+ tabId: this.tabId,
8116
+ bucketId: this.helloState.bucketId,
8117
+ visibility: currentVisibility()
8118
+ });
8119
+ });
8120
+ const leave = () => {
8121
+ if (this.helloState) this.send({
8122
+ type: "shutdown",
8123
+ tabId: this.tabId,
8124
+ bucketId: this.helloState.bucketId
8125
+ });
8126
+ };
8127
+ window.addEventListener("pagehide", leave);
8128
+ document.addEventListener("freeze", leave);
8129
+ window.addEventListener("pageshow", (e) => {
8130
+ if (e.persisted && this.helloState) this.rehello(this.helloState);
8131
+ });
8132
+ document.addEventListener("resume", () => {
8133
+ if (this.helloState) this.rehello(this.helloState);
8134
+ });
8135
+ }
8136
+ close() {
8137
+ this.closed = true;
8138
+ if (this.watchdog) clearInterval(this.watchdog);
8139
+ this.watchdog = null;
8140
+ try {
8141
+ this.worker?.port.close();
8142
+ } catch {}
8143
+ this.worker = null;
8144
+ }
8145
+ };
8146
+ function currentVisibility() {
8147
+ if (typeof document === "undefined") return "visible";
8148
+ return document.visibilityState === "hidden" ? "hidden" : "visible";
8149
+ }
8150
+
8151
+ //#endregion
8152
+ //#region src/services/tabs/leader-locks.ts
8153
+ function lockManager() {
8154
+ const nav = globalThis.navigator;
8155
+ return nav?.locks && typeof nav.locks.request === "function" ? nav.locks : null;
8156
+ }
8157
+ /**
8158
+ * Acquire `name` exclusively. `steal: true` when the broker determined the
8159
+ * previous holder is a frozen zombie. Resolves null when the lock is held by
8160
+ * someone else and stealing was not requested.
8161
+ */
8162
+ function acquireLeaderTabLock(name, opts) {
8163
+ const locks = lockManager();
8164
+ if (!locks) return Promise.resolve({
8165
+ release() {},
8166
+ onLost() {}
8167
+ });
8168
+ return new Promise((resolve) => {
8169
+ let lostCb = null;
8170
+ let releasedIntentionally = false;
8171
+ let granted = false;
8172
+ let release = null;
8173
+ locks.request(name, opts.steal ? {
8174
+ mode: "exclusive",
8175
+ steal: true
8176
+ } : {
8177
+ mode: "exclusive",
8178
+ ifAvailable: true
8179
+ }, (lock) => {
8180
+ if (!lock) {
8181
+ resolve(null);
8182
+ return;
8183
+ }
8184
+ granted = true;
8185
+ resolve({
8186
+ release() {
8187
+ releasedIntentionally = true;
8188
+ release?.();
8189
+ },
8190
+ onLost(cb) {
8191
+ lostCb = cb;
8192
+ }
8193
+ });
8194
+ return new Promise((r) => {
8195
+ release = r;
8196
+ });
8197
+ }).then(() => {
8198
+ if (granted && !releasedIntentionally) lostCb?.();
8199
+ }, () => {
8200
+ if (granted && !releasedIntentionally) lostCb?.();
8201
+ else if (!granted) resolve(null);
8202
+ });
8203
+ });
8204
+ }
8205
+
8206
+ //#endregion
8207
+ //#region src/services/tabs/coordinator.ts
8208
+ /** How long boot waits for a role (election + attach) before going solo. */
8209
+ const START_TIMEOUT_MS = 15e3;
8210
+ /** Leader-side fan-out surface handed to the sync layer. The sync router
8211
+ * (modules/sync/tab-router.ts) registers itself as the message handler. */
8212
+ var LeaderSyncHub = class {
8213
+ followers = /* @__PURE__ */ new Map();
8214
+ seq = 0;
8215
+ onFollowerMessage = null;
8216
+ onFollowerDetached = null;
8217
+ constructor(leadershipId, logger) {
8218
+ this.leadershipId = leadershipId;
8219
+ this.logger = logger;
8220
+ }
8221
+ attach(tabId, port) {
8222
+ this.detach(tabId);
8223
+ this.followers.set(tabId, port);
8224
+ port.onmessage = (ev) => {
8225
+ this.onFollowerMessage?.(tabId, ev.data);
8226
+ };
8227
+ port.onmessageerror = () => this.detach(tabId);
8228
+ port.start?.();
8229
+ }
8230
+ detach(tabId) {
8231
+ const port = this.followers.get(tabId);
8232
+ if (!port) return;
8233
+ this.followers.delete(tabId);
8234
+ try {
8235
+ port.close();
8236
+ } catch {}
8237
+ this.onFollowerDetached?.(tabId);
8238
+ }
8239
+ detachAll() {
8240
+ for (const tabId of [...this.followers.keys()]) this.detach(tabId);
8241
+ }
8242
+ sendTo(tabId, msg) {
8243
+ try {
8244
+ this.followers.get(tabId)?.postMessage(msg);
8245
+ } catch {}
8246
+ }
8247
+ broadcast(msg, exceptTabId) {
8248
+ for (const [tabId, port] of this.followers) {
8249
+ if (tabId === exceptTabId) continue;
8250
+ try {
8251
+ port.postMessage(msg);
8252
+ } catch {}
8253
+ }
8254
+ }
8255
+ /** Stamped ingest relay; seq lets followers detect gaps. */
8256
+ relayIngest(tuples, exceptTabId) {
8257
+ if (this.followers.size === 0) return;
8258
+ this.broadcast({
8259
+ type: "ingest-relay",
8260
+ tuples,
8261
+ leadershipId: this.leadershipId,
8262
+ seq: ++this.seq
8263
+ }, exceptTabId);
8264
+ }
8265
+ get followerCount() {
8266
+ return this.followers.size;
8267
+ }
8268
+ get relayedBatches() {
8269
+ return this.seq;
8270
+ }
8271
+ };
8272
+ /** Follower half of the syncPort. Queues while detached (leaderless window)
8273
+ * and flushes on rebind; a lost-in-flight mutation notify is additionally
8274
+ * backstopped by the new leader reloading the shared outbox from the store. */
8275
+ var SyncForwarder = class {
8276
+ port = null;
8277
+ queued = [];
8278
+ onLeaderMessage = null;
8279
+ constructor(tabId) {
8280
+ this.tabId = tabId;
8281
+ }
8282
+ rebind(port) {
8283
+ this.unbind();
8284
+ this.port = port;
8285
+ port.onmessage = (ev) => {
8286
+ this.onLeaderMessage?.(ev.data);
8287
+ };
8288
+ port.start?.();
8289
+ this.post({
8290
+ type: "sync-hello",
8291
+ tabId: this.tabId
8292
+ });
8293
+ const backlog = this.queued;
8294
+ this.queued = [];
8295
+ for (const msg of backlog) this.post(msg);
8296
+ }
8297
+ unbind() {
8298
+ if (this.port) try {
8299
+ this.port.close();
8300
+ } catch {}
8301
+ this.port = null;
8302
+ }
8303
+ post(msg) {
8304
+ if (!this.port) {
8305
+ this.queued.push(msg);
8306
+ return;
8307
+ }
8308
+ try {
8309
+ this.port.postMessage(msg);
8310
+ } catch {
8311
+ this.queued.push(msg);
8312
+ }
8313
+ }
8314
+ mutationEnqueued(mutationId) {
8315
+ this.post({
8316
+ type: "mutation-enqueued",
8317
+ mutationId
8318
+ });
8319
+ }
8320
+ requestPoll() {
8321
+ this.post({ type: "request-poll" });
8322
+ }
8323
+ };
8324
+ var TabsCoordinator = class {
8325
+ role = "solo";
8326
+ leadershipId = 0;
8327
+ leaderTabId = null;
8328
+ brokerRole = "pending";
8329
+ fingerprint;
8330
+ bucketId;
8331
+ broker;
8332
+ hub = null;
8333
+ forwarder = null;
8334
+ roleListeners = /* @__PURE__ */ new Set();
8335
+ startResolve = null;
8336
+ startReject = null;
8337
+ /** Ports received before start() resolves or between roles. */
8338
+ promotionChain = Promise.resolve();
8339
+ closed = false;
8340
+ constructor(deps) {
8341
+ this.deps = deps;
8342
+ this.fingerprint = deps.fingerprint;
8343
+ this.bucketId = "anon";
8344
+ this.broker = new TabBrokerClient(new URL("./tabs-broker-worker.js", import.meta.url), deps.tabId, {
8345
+ onBecomeLeader: (msg) => this.enqueue(() => this.promote(msg)),
8346
+ onDemote: (leadershipId) => this.enqueue(() => this.demote(leadershipId)),
8347
+ onLeaderReady: (leadershipId, leaderTabId) => {
8348
+ this.leaderTabId = leaderTabId;
8349
+ },
8350
+ onAttachFollowerPorts: (followerTabId, leadershipId, dbPort, syncPort) => this.enqueue(() => this.serveFollower(followerTabId, leadershipId, dbPort, syncPort)),
8351
+ onUseFollowerPorts: (leaderTabId, leadershipId, dbPort, syncPort) => this.enqueue(() => this.attachToLeader(leaderTabId, leadershipId, dbPort, syncPort)),
8352
+ onCloseFollowerPorts: (leadershipId) => this.enqueue(() => this.handleLeaderGone(leadershipId)),
8353
+ onUnsupported: () => this.fallbackToSolo("broker rejected this tab"),
8354
+ onBrokerRestarted: () => {}
8355
+ }, deps.logger);
8356
+ if (typeof window !== "undefined" && deps.onLeaderPageHide) window.addEventListener("pagehide", () => {
8357
+ if (this.role === "leader") deps.onLeaderPageHide?.();
8358
+ });
8359
+ }
8360
+ /** Serialize role transitions; each is small but async (worker opens). */
8361
+ enqueue(fn) {
8362
+ this.promotionChain = this.promotionChain.then(fn, fn).catch((e) => {
8363
+ this.deps.logger.error({
8364
+ err: e,
8365
+ Category: "sp00ky-client::TabsCoordinator"
8366
+ }, "Role transition failed");
8367
+ });
8368
+ }
8369
+ onRoleChange(cb) {
8370
+ this.roleListeners.add(cb);
8371
+ return () => this.roleListeners.delete(cb);
8372
+ }
8373
+ setRole(role) {
8374
+ if (this.role !== role) {
8375
+ this.role = role;
8376
+ this.deps.logger.info({
8377
+ role,
8378
+ leadershipId: this.leadershipId,
8379
+ Category: "sp00ky-client::TabsCoordinator"
8380
+ }, "Tab role changed");
8381
+ for (const cb of this.roleListeners) cb(role);
8382
+ }
8383
+ if (this.startResolve && role !== "solo") {
8384
+ this.startResolve(role);
8385
+ this.startResolve = null;
8386
+ }
8387
+ }
8388
+ /** Connect the broker and resolve once this tab has a usable store role.
8389
+ * Rejects when no role lands in time; the caller then boots solo. */
8390
+ start(bucketId) {
8391
+ this.bucketId = bucketId;
8392
+ return new Promise((resolve, reject) => {
8393
+ const timeout = setTimeout(() => {
8394
+ this.startResolve = null;
8395
+ this.startReject = null;
8396
+ reject(/* @__PURE__ */ new Error("shared-tabs: no role assigned in time"));
8397
+ }, START_TIMEOUT_MS);
8398
+ this.startResolve = (role) => {
8399
+ clearTimeout(timeout);
8400
+ this.startReject = null;
8401
+ resolve(role);
8402
+ };
8403
+ this.startReject = (e) => {
8404
+ clearTimeout(timeout);
8405
+ this.startResolve = null;
8406
+ reject(e);
8407
+ };
8408
+ this.broker.connect({
8409
+ fingerprint: this.fingerprint,
8410
+ bucketId,
8411
+ heldLeadership: () => this.role === "leader" ? {
8412
+ leadershipId: this.leadershipId,
8413
+ workerLockName: workerLockName(this.fingerprint, this.bucketId, this.leadershipId)
8414
+ } : null
8415
+ }).catch((e) => {
8416
+ this.startReject?.(e instanceof Error ? e : new Error(String(e)));
8417
+ });
8418
+ });
8419
+ }
8420
+ /** Bucket switch: leave the old namespace, join the new one. Resolves when
8421
+ * a role lands in the new namespace. */
8422
+ moveToBucket(bucketId) {
8423
+ return new Promise((resolve, reject) => {
8424
+ this.enqueue(async () => {
8425
+ if (this.role === "leader") await this.teardownLeader();
8426
+ else if (this.role === "follower") this.teardownFollower("bucket switch");
8427
+ this.bucketId = bucketId;
8428
+ const timeout = setTimeout(() => {
8429
+ this.startResolve = null;
8430
+ this.startReject = null;
8431
+ reject(/* @__PURE__ */ new Error("shared-tabs: no role assigned after bucket switch"));
8432
+ }, START_TIMEOUT_MS);
8433
+ this.startResolve = (role) => {
8434
+ clearTimeout(timeout);
8435
+ this.startReject = null;
8436
+ resolve(role);
8437
+ };
8438
+ this.startReject = (e) => {
8439
+ clearTimeout(timeout);
8440
+ this.startResolve = null;
8441
+ reject(e);
8442
+ };
8443
+ await this.broker.rehello({
8444
+ fingerprint: this.fingerprint,
8445
+ bucketId,
8446
+ heldLeadership: () => null
8447
+ });
8448
+ });
8449
+ });
8450
+ }
8451
+ async promote(msg) {
8452
+ if (this.closed) return;
8453
+ if (msg.leadershipId <= this.leadershipId && !msg.resumeHeld) return;
8454
+ const previousRole = this.role;
8455
+ try {
8456
+ if (previousRole === "follower") this.teardownFollower("promoted");
8457
+ this.leadershipId = msg.leadershipId;
8458
+ const lock = await acquireLeaderTabLock(tabLockName(this.fingerprint, this.bucketId), { steal: msg.forceTakeover });
8459
+ if (!lock) throw new Error("leader tab lock unavailable");
8460
+ this.tabLock?.release();
8461
+ this.tabLock = lock;
8462
+ lock.onLost(() => {
8463
+ this.enqueue(async () => {
8464
+ if (this.leadershipId !== msg.leadershipId || this.role !== "leader") return;
8465
+ await this.teardownLeader();
8466
+ this.deps.hooks.onLeaderLost("tab lock stolen");
8467
+ });
8468
+ });
8469
+ await this.deps.hooks.adoptOwner(this.bucketId, {
8470
+ workerLockName: workerLockName(this.fingerprint, this.bucketId, msg.leadershipId),
8471
+ allowMemoryFallback: msg.allowMemoryFallback,
8472
+ forceTakeover: msg.forceTakeover,
8473
+ resumeHeld: msg.resumeHeld
8474
+ });
8475
+ this.hub = new LeaderSyncHub(msg.leadershipId, this.deps.logger);
8476
+ await this.deps.hooks.becomeSyncLeader(this.hub);
8477
+ this.setRole("leader");
8478
+ this.broker.send({
8479
+ type: "leader-ready",
8480
+ tabId: this.deps.tabId,
8481
+ bucketId: this.bucketId,
8482
+ leadershipId: msg.leadershipId
8483
+ });
8484
+ } catch (e) {
8485
+ const reason = e instanceof Error ? e.message : String(e);
8486
+ this.deps.logger.error({
8487
+ err: e,
8488
+ Category: "sp00ky-client::TabsCoordinator"
8489
+ }, "Promotion failed");
8490
+ this.hub?.detachAll();
8491
+ this.hub = null;
8492
+ this.broker.send({
8493
+ type: "leader-failed",
8494
+ tabId: this.deps.tabId,
8495
+ bucketId: this.bucketId,
8496
+ leadershipId: msg.leadershipId,
8497
+ reason
8498
+ });
8499
+ }
8500
+ }
8501
+ async demote(leadershipId) {
8502
+ if (this.role !== "leader" || leadershipId !== this.leadershipId) return;
8503
+ await this.teardownLeader();
8504
+ this.deps.hooks.onLeaderLost("demoted");
8505
+ }
8506
+ tabLock = null;
8507
+ async teardownLeader() {
8508
+ this.hub?.detachAll();
8509
+ this.hub = null;
8510
+ await this.deps.hooks.releaseOwnership();
8511
+ this.tabLock?.release();
8512
+ this.tabLock = null;
8513
+ }
8514
+ teardownFollower(reason) {
8515
+ this.forwarder?.unbind();
8516
+ this.deps.hooks.onLeaderLost(reason);
8517
+ }
8518
+ async serveFollower(followerTabId, leadershipId, dbPort, syncPort) {
8519
+ if (this.role !== "leader" || leadershipId !== this.leadershipId || !this.hub) {
8520
+ dbPort.close();
8521
+ syncPort.close();
8522
+ return;
8523
+ }
8524
+ await this.deps.hooks.exposeClientPort(followerTabId, dbPort);
8525
+ this.hub.attach(followerTabId, syncPort);
8526
+ this.hub.sendTo(followerTabId, {
8527
+ type: "db-ready",
8528
+ leadershipId,
8529
+ bucketId: this.bucketId,
8530
+ storageHealth: this.deps.hooks.currentStorageHealth()
8531
+ });
8532
+ this.broker.send({
8533
+ type: "follower-port-attached",
8534
+ tabId: this.deps.tabId,
8535
+ bucketId: this.bucketId,
8536
+ leadershipId,
8537
+ followerTabId
8538
+ });
8539
+ }
8540
+ async attachToLeader(leaderTabId, leadershipId, dbPort, syncPort) {
8541
+ if (this.closed || this.role === "leader") {
8542
+ dbPort.close();
8543
+ syncPort.close();
8544
+ return;
8545
+ }
8546
+ this.leaderTabId = leaderTabId;
8547
+ this.leadershipId = leadershipId;
8548
+ if (!this.forwarder) this.forwarder = new SyncForwarder(this.deps.tabId);
8549
+ const forwarder = this.forwarder;
8550
+ await new Promise((resolve) => {
8551
+ let adopted = false;
8552
+ const previousHandler = forwarder.onLeaderMessage;
8553
+ forwarder.onLeaderMessage = (msg) => {
8554
+ if (msg.type === "db-ready" && !adopted) {
8555
+ adopted = true;
8556
+ this.deps.hooks.adoptAttached(dbPort, {
8557
+ bucketId: msg.bucketId,
8558
+ storageHealth: msg.storageHealth,
8559
+ leadershipId: msg.leadershipId
8560
+ }).then(() => {
8561
+ this.deps.hooks.becomeSyncFollower(forwarder);
8562
+ this.setRole("follower");
8563
+ resolve();
8564
+ });
8565
+ return;
8566
+ }
8567
+ previousHandler?.(msg);
8568
+ };
8569
+ forwarder.rebind(syncPort);
8570
+ });
8571
+ }
8572
+ async handleLeaderGone(leadershipId) {
8573
+ if (this.role === "leader") return;
8574
+ if (leadershipId < this.leadershipId) return;
8575
+ this.teardownFollower("leader gone");
8576
+ }
8577
+ fallbackToSolo(reason) {
8578
+ this.deps.logger.warn({
8579
+ reason,
8580
+ Category: "sp00ky-client::TabsCoordinator"
8581
+ }, "Shared-tabs unavailable; running solo");
8582
+ this.deps.hooks.becomeSyncSolo();
8583
+ this.setRole("solo");
8584
+ this.startReject?.(/* @__PURE__ */ new Error(`shared-tabs unavailable: ${reason}`));
8585
+ }
8586
+ get syncHub() {
8587
+ return this.hub;
8588
+ }
8589
+ get syncForwarder() {
8590
+ return this.forwarder;
8591
+ }
8592
+ get tabId() {
8593
+ return this.deps.tabId;
8594
+ }
8595
+ async stop() {
8596
+ this.closed = true;
8597
+ if (this.role === "leader") await this.teardownLeader();
8598
+ else this.forwarder?.unbind();
8599
+ this.broker.send({
8600
+ type: "shutdown",
8601
+ tabId: this.deps.tabId,
8602
+ bucketId: this.bucketId
8603
+ });
8604
+ this.broker.close();
8605
+ }
8606
+ };
8607
+
7338
8608
  //#endregion
7339
8609
  //#region src/sp00ky.ts
7340
8610
  var BucketHandle = class {
@@ -7417,6 +8687,12 @@ var Sp00kyClient = class {
7417
8687
  logger;
7418
8688
  auth;
7419
8689
  streamProcessor;
8690
+ tabsCoordinator = null;
8691
+ sharedActive = false;
8692
+ /** Current shared-tabs role, or null when the feature is off/fell back. */
8693
+ get tabRole() {
8694
+ return this.sharedActive ? this.tabsCoordinator.role : null;
8695
+ }
7420
8696
  get remoteClient() {
7421
8697
  return this.remote.getClient();
7422
8698
  }
@@ -7475,7 +8751,8 @@ var Sp00kyClient = class {
7475
8751
  Category: "sp00ky-client::Sp00kyClient::constructor"
7476
8752
  }, "Sp00kyClient initialized");
7477
8753
  if (config.crdt) preloadLoro();
7478
- this.local = createLocalEngine(this.config.localEngine, this.config.database, logger);
8754
+ const tabsSupport = detectSharedTabsSupport(this.config);
8755
+ this.local = createLocalEngine(this.config.localEngine, this.config.database, logger, { shared: tabsSupport.supported });
7479
8756
  this.remote = new RemoteDatabaseService(this.config.database, logger);
7480
8757
  if (config.persistenceClient === "surrealdb") this.persistenceClient = new SurrealDBPersistenceClient(this.local, logger);
7481
8758
  else if (config.persistenceClient === "localstorage" || !config.persistenceClient) this.persistenceClient = new LocalStoragePersistenceClient(logger);
@@ -7509,6 +8786,95 @@ var Sp00kyClient = class {
7509
8786
  this.devTools = new DevToolsService(this.local, this.remote, logger, this.config.schema, this.auth, this.dataModule);
7510
8787
  this.streamProcessor.addReceiver(this.devTools);
7511
8788
  this.setupCallbacks();
8789
+ if (tabsSupport.supported) this.tabsCoordinator = this.buildTabsCoordinator();
8790
+ else if (this.config.sharedTabs) this.logger.info({
8791
+ reason: tabsSupport.reason,
8792
+ Category: "sp00ky-client::Sp00kyClient::tabs"
8793
+ }, "sharedTabs requested but unsupported here; running solo");
8794
+ if (this.config.sharedTabs) {
8795
+ const unsupportedReason = tabsSupport.supported ? void 0 : tabsSupport.reason;
8796
+ this.devTools.setTabsInfoProvider(() => {
8797
+ const c = this.tabsCoordinator;
8798
+ if (!this.sharedActive || !c) return {
8799
+ active: false,
8800
+ reason: unsupportedReason ?? "fell-back"
8801
+ };
8802
+ const hub = c.syncHub;
8803
+ return {
8804
+ active: true,
8805
+ role: c.role,
8806
+ tabId: c.tabId,
8807
+ leadershipId: c.leadershipId,
8808
+ leaderTabId: c.role === "leader" ? c.tabId : c.leaderTabId,
8809
+ ...hub ? {
8810
+ followers: hub.followerCount,
8811
+ relayedBatches: hub.relayedBatches
8812
+ } : {}
8813
+ };
8814
+ });
8815
+ }
8816
+ }
8817
+ /** The shared-tabs role machinery, wired to this client's modules. */
8818
+ buildTabsCoordinator() {
8819
+ const engine = this.local;
8820
+ const tabId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `tab_${Math.random().toString(36).slice(2)}`;
8821
+ return new TabsCoordinator({
8822
+ tabId,
8823
+ fingerprint: computeTabsFingerprint({
8824
+ coreVersion: "0.0.1-canary.156",
8825
+ schemaHash: hash53(this.config.schemaSurql),
8826
+ endpoint: this.config.database.endpoint ?? "",
8827
+ namespace: this.config.database.namespace,
8828
+ database: this.config.database.database
8829
+ }),
8830
+ hooks: {
8831
+ adoptOwner: (bucketId, opts) => engine.adoptOwner(bucketId, {
8832
+ workerLockName: opts.workerLockName,
8833
+ allowMemoryFallback: opts.allowMemoryFallback,
8834
+ resumeHeld: opts.resumeHeld
8835
+ }),
8836
+ adoptAttached: (dbPort, snapshot) => engine.adoptAttached(dbPort, snapshot, (reason) => engine.onLeaderLost(reason)),
8837
+ releaseOwnership: () => engine.releaseOwnership(),
8838
+ onLeaderLost: (reason) => engine.onLeaderLost(reason),
8839
+ exposeClientPort: (clientId, port) => engine.exposeClientPort(clientId, port),
8840
+ removeClientPort: (clientId) => engine.removeClientPort(clientId),
8841
+ becomeSyncLeader: async (hub) => {
8842
+ this.streamProcessor.setPersistenceEnabled(true);
8843
+ this.cache.setIngestRelay((tuples) => hub.relayIngest(tuples));
8844
+ this.sync.setTabContext("leader", tabId);
8845
+ this.dataModule.setTabId(tabId);
8846
+ await this.sync.promoteToLeader(hub);
8847
+ },
8848
+ becomeSyncFollower: (forwarder) => {
8849
+ this.streamProcessor.setPersistenceEnabled(false);
8850
+ this.cache.setIngestRelay(null);
8851
+ this.sync.setTabContext("follower", tabId);
8852
+ this.dataModule.setTabId(tabId);
8853
+ this.sync.demoteToFollower(forwarder);
8854
+ const inner = forwarder.onLeaderMessage;
8855
+ forwarder.onLeaderMessage = (msg) => {
8856
+ if (msg.type === "ingest-relay") {
8857
+ this.cache.applyRelayedIngest(msg.tuples);
8858
+ return;
8859
+ }
8860
+ inner?.(msg);
8861
+ };
8862
+ },
8863
+ becomeSyncSolo: () => {
8864
+ this.streamProcessor.setPersistenceEnabled(true);
8865
+ this.cache.setIngestRelay(null);
8866
+ this.sync.setTabContext("solo", tabId);
8867
+ },
8868
+ currentStorageHealth: () => this.local.storageHealth ?? {
8869
+ status: "unknown",
8870
+ fallback: false
8871
+ }
8872
+ },
8873
+ logger: this.logger,
8874
+ onLeaderPageHide: () => {
8875
+ engine.shutdownOwnedWorker();
8876
+ }
8877
+ });
7512
8878
  }
7513
8879
  /**
7514
8880
  * Setup direct callbacks instead of event subscriptions
@@ -7569,7 +8935,23 @@ var Sp00kyClient = class {
7569
8935
  this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization started");
7570
8936
  try {
7571
8937
  const bootBucket = readBootBucketHint() ?? ANON_USER_ID;
7572
- await this.local.connect(bootBucket);
8938
+ if (this.tabsCoordinator) try {
8939
+ const role = await this.tabsCoordinator.start(bootBucket);
8940
+ this.sharedActive = true;
8941
+ this.logger.info({
8942
+ role,
8943
+ bootBucket,
8944
+ Category: "sp00ky-client::Sp00kyClient::init"
8945
+ }, "Shared-tabs role assigned");
8946
+ } catch (e) {
8947
+ this.logger.warn({
8948
+ err: e,
8949
+ Category: "sp00ky-client::Sp00kyClient::init"
8950
+ }, "Shared-tabs unavailable; booting solo");
8951
+ this.sharedActive = false;
8952
+ await this.local.connect(bootBucket);
8953
+ }
8954
+ else await this.local.connect(bootBucket);
7573
8955
  this.logger.debug({
7574
8956
  bootBucket,
7575
8957
  Category: "sp00ky-client::Sp00kyClient::init"
@@ -7680,9 +9062,26 @@ var Sp00kyClient = class {
7680
9062
  this.crdtManager.closeAll({ flush: false });
7681
9063
  const reopen = gateRelease ?? this.local.beginSwitch();
7682
9064
  try {
7683
- await this.local.switchStore(target);
7684
- if (this.local.usesSurqlSchema) await this.migrator.provision(this.config.schemaSurql);
7685
- await this.local.queryUngated("DELETE _00_query;");
9065
+ if (this.sharedActive && this.tabsCoordinator) try {
9066
+ await this.tabsCoordinator.moveToBucket(target);
9067
+ } catch (e) {
9068
+ this.logger.warn({
9069
+ err: e,
9070
+ target,
9071
+ Category: "sp00ky-client::Sp00kyClient::doSwitchBucket"
9072
+ }, "Shared-tabs bucket move failed; switching solo");
9073
+ this.sharedActive = false;
9074
+ this.sync.setTabContext("solo", null);
9075
+ this.cache.setIngestRelay(null);
9076
+ this.streamProcessor.setPersistenceEnabled(true);
9077
+ await this.local.switchStore(target);
9078
+ await this.local.queryUngated("DELETE _00_query;");
9079
+ }
9080
+ else {
9081
+ await this.local.switchStore(target);
9082
+ if (this.local.usesSurqlSchema) await this.migrator.provision(this.config.schemaSurql);
9083
+ await this.local.queryUngated("DELETE _00_query;");
9084
+ }
7686
9085
  this.streamProcessor.setStateKeySuffix(target);
7687
9086
  await this.streamProcessor.reset();
7688
9087
  this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
@@ -7715,6 +9114,7 @@ var Sp00kyClient = class {
7715
9114
  await this.featureFlags.closeAll();
7716
9115
  await this.appReleases.closeAll();
7717
9116
  this.crdtManager.closeAll();
9117
+ if (this.tabsCoordinator) await this.tabsCoordinator.stop();
7718
9118
  await this.local.close();
7719
9119
  await this.remote.close();
7720
9120
  }