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