@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.
- package/dist/index.d.ts +151 -0
- package/dist/index.js +1520 -120
- package/dist/{sqlite-plan-sql.js → sqlite-open.js} +83 -1
- package/dist/sqlite-worker.js +268 -108
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +434 -0
- package/dist/types.d.ts +32 -0
- package/package.json +4 -4
- package/scripts/check-broker-bundle.mjs +33 -0
- package/src/modules/cache/index.ts +35 -0
- package/src/modules/data/index.ts +13 -3
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/devtools/index.ts +12 -1
- package/src/modules/devtools/storage-info.ts +27 -0
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +51 -0
- package/src/modules/sync/queue/queue-up.ts +74 -38
- package/src/modules/sync/scheduler.ts +4 -2
- package/src/modules/sync/sync.ts +186 -8
- package/src/services/database/engine-factory.ts +3 -2
- package/src/services/database/sqlite-cache-engine.test.ts +160 -103
- package/src/services/database/sqlite-cache-engine.ts +285 -72
- package/src/services/database/sqlite-open.ts +36 -1
- package/src/services/database/sqlite-select.test.ts +13 -46
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +219 -0
- package/src/services/database/sqlite-worker.ts +340 -55
- package/src/services/stream-processor/index.ts +11 -2
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +278 -0
- package/src/services/tabs/coordinator.test.ts +192 -0
- package/src/services/tabs/coordinator.ts +567 -0
- package/src/services/tabs/fake-ports.fixture.ts +70 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +239 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +581 -0
- package/src/sp00ky.ts +176 -6
- package/src/types.ts +25 -0
- package/tsdown.config.ts +19 -10
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
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
|
|
1504
|
-
|
|
1505
|
-
|
|
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.
|
|
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
|
-
|
|
1660
|
-
s.inFlight--;
|
|
1661
|
-
};
|
|
1811
|
+
if (this.transport.kind === "port") s.proxiedOps = (s.proxiedOps ?? 0) + 1;
|
|
1662
1812
|
const sentAt = performance.now();
|
|
1663
|
-
return
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
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
|
-
|
|
1692
|
-
|
|
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.
|
|
1890
|
+
if (this.transport) {
|
|
1737
1891
|
try {
|
|
1738
1892
|
await this.rawCall("close");
|
|
1739
1893
|
} catch {}
|
|
1740
|
-
this.
|
|
1741
|
-
this.
|
|
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.
|
|
1901
|
+
if (!this.transport) return;
|
|
1748
1902
|
try {
|
|
1749
1903
|
await this.call("close");
|
|
1750
1904
|
} catch {}
|
|
1751
|
-
this.
|
|
1752
|
-
this.
|
|
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, {
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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.
|
|
5205
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7684
|
-
|
|
7685
|
-
|
|
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
|
}
|