@spooky-sync/core 0.0.1-canary.355 → 0.0.1-canary.357

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.js CHANGED
@@ -1333,6 +1333,1347 @@ var WebPushModule = class {
1333
1333
  }
1334
1334
  };
1335
1335
 
1336
+ //#endregion
1337
+ //#region src/modules/channel/index.ts
1338
+ const compareHlc = (a, b) => a[0] - b[0] || a[1] - b[1] || (a[2] < b[2] ? -1 : a[2] > b[2] ? 1 : 0);
1339
+ const peerKey = (p) => `${p.user}#${p.peer}`;
1340
+ const isRecordIdLike = (v) => v instanceof RecordId$1 || v !== null && typeof v === "object" && v.constructor?.name === "RecordId";
1341
+ /** `table:id` for a record id in any spelling, or null. */
1342
+ function idKey(v) {
1343
+ if (typeof v === "string") return v.includes(":") ? v : null;
1344
+ if (isRecordIdLike(v)) return encodeRecordId(v);
1345
+ if (v !== null && typeof v === "object" && "table" in v && "id" in v) {
1346
+ const t = v.table;
1347
+ const name = typeof t === "string" ? t : t?.name;
1348
+ if (typeof name === "string") return `${name}:${String(v.id)}`;
1349
+ }
1350
+ return null;
1351
+ }
1352
+ const toBase64 = (bytes) => {
1353
+ let s = "";
1354
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
1355
+ return btoa(s);
1356
+ };
1357
+ const fromBase64 = (b64) => {
1358
+ const s = atob(b64);
1359
+ const out = new Uint8Array(s.length);
1360
+ for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
1361
+ return out;
1362
+ };
1363
+ /** JSON-safe form of a field value: record ids, dates and bytes are tagged. */
1364
+ function encodeValue(v) {
1365
+ if (v === void 0) return null;
1366
+ if (isRecordIdLike(v)) return { $rid: encodeRecordId(v) };
1367
+ if (v instanceof Date) return { $date: v.toISOString() };
1368
+ if (v instanceof Uint8Array) return { $bytes: toBase64(v) };
1369
+ if (v instanceof ArrayBuffer) return { $bytes: toBase64(new Uint8Array(v)) };
1370
+ if (Array.isArray(v)) return v.map(encodeValue);
1371
+ if (v !== null && typeof v === "object") {
1372
+ const out = {};
1373
+ for (const [k, x] of Object.entries(v)) out[k] = encodeValue(x);
1374
+ return out;
1375
+ }
1376
+ return v;
1377
+ }
1378
+ function decodeValue(v) {
1379
+ if (Array.isArray(v)) return v.map(decodeValue);
1380
+ if (v === null || typeof v !== "object") return v;
1381
+ const o = v;
1382
+ if (Object.keys(o).length === 1) {
1383
+ if (typeof o.$rid === "string") return parseRecordIdString(o.$rid);
1384
+ if (typeof o.$date === "string") return new Date(o.$date);
1385
+ if (typeof o.$bytes === "string") return fromBase64(o.$bytes);
1386
+ }
1387
+ const out = {};
1388
+ for (const [k, x] of Object.entries(o)) out[k] = decodeValue(x);
1389
+ return out;
1390
+ }
1391
+ const sameValue = (a, b) => a === b || JSON.stringify(encodeValue(a)) === JSON.stringify(encodeValue(b));
1392
+ /** A well-formed `op` message, or null. */
1393
+ function parseMessage(raw) {
1394
+ if (raw === null || typeof raw !== "object") return null;
1395
+ const m = raw;
1396
+ const hlc = m.hlc;
1397
+ if (m.kind !== void 0 && m.kind !== "update" && m.kind !== "create" && m.kind !== "delete") return null;
1398
+ if (m.t !== "op" || typeof m.table !== "string" || typeof m.id !== "string" || m.patch === null || typeof m.patch !== "object" || Array.isArray(m.patch) || !Array.isArray(hlc) || hlc.length !== 3 || typeof hlc[0] !== "number" || typeof hlc[1] !== "number" || typeof hlc[2] !== "string") return null;
1399
+ return m;
1400
+ }
1401
+ /** Whether a decoded value fits a column, loosely (the shapes the client itself writes). */
1402
+ function fitsColumn(col, v) {
1403
+ if (v === null || v === void 0) return col.optional;
1404
+ if (col.array) return Array.isArray(v) && v.every((x) => fitsScalar(col, x));
1405
+ return fitsScalar(col, v);
1406
+ }
1407
+ function fitsScalar(col, v) {
1408
+ if (col.recordId) return typeof v === "string" || isRecordIdLike(v);
1409
+ if (col.dateTime) return v instanceof Date || typeof v === "string";
1410
+ switch (col.type) {
1411
+ case "string": return typeof v === "string";
1412
+ case "number": return typeof v === "number" && Number.isFinite(v);
1413
+ case "boolean": return typeof v === "boolean";
1414
+ case "Uint8Array": return v instanceof Uint8Array;
1415
+ case "null": return v === null;
1416
+ default: return true;
1417
+ }
1418
+ }
1419
+ /** Why a peer patch is refused against the table's columns, or null when it fits. */
1420
+ function checkPatch(columns, patch, allowEmpty = false) {
1421
+ const fields = Object.keys(patch);
1422
+ if (fields.length === 0 && !allowEmpty) return "empty patch";
1423
+ for (const field of fields) {
1424
+ if (field === "id") return "patches the id";
1425
+ const col = columns[field];
1426
+ if (!col) return `unknown field '${field}'`;
1427
+ if (col.crdt) return `'${field}' is a @crdt field`;
1428
+ if (!fitsColumn(col, patch[field])) return `'${field}' does not fit its column type`;
1429
+ }
1430
+ return null;
1431
+ }
1432
+ /** A patch split into the fields the database gets and the ones only peers get. */
1433
+ function splitPatch(columns, data) {
1434
+ let ephemeral = null;
1435
+ const persistent = {};
1436
+ for (const [k, v] of Object.entries(data)) if (columns[k]?.ephemeral) (ephemeral ??= {})[k] = v;
1437
+ else persistent[k] = v;
1438
+ return {
1439
+ persistent,
1440
+ ephemeral
1441
+ };
1442
+ }
1443
+ const OVERLAY_DEPTH = 4;
1444
+ /**
1445
+ * Rows with the overlay folded in, nested related rows included. Untouched
1446
+ * rows (and the array itself, when nothing changed) keep their identity.
1447
+ */
1448
+ function applyOverlay(rows, overlay) {
1449
+ if (overlay.size === 0) return rows;
1450
+ return foldArray(rows, overlay, 0);
1451
+ }
1452
+ function foldArray(arr, overlay, depth) {
1453
+ let changed = false;
1454
+ const next = arr.map((x) => {
1455
+ const y = foldValue(x, overlay, depth);
1456
+ if (y !== x) changed = true;
1457
+ return y;
1458
+ });
1459
+ return changed ? next : arr;
1460
+ }
1461
+ function foldValue(v, overlay, depth) {
1462
+ if (depth > OVERLAY_DEPTH || v === null || typeof v !== "object") return v;
1463
+ if (Array.isArray(v)) return foldArray(v, overlay, depth + 1);
1464
+ if (isRecordIdLike(v) || v instanceof Date || v instanceof Uint8Array) return v;
1465
+ const row = v;
1466
+ let next = null;
1467
+ for (const [k, x] of Object.entries(row)) {
1468
+ if (k === "id") continue;
1469
+ const y = foldValue(x, overlay, depth + 1);
1470
+ if (y !== x) (next ??= { ...row })[k] = y;
1471
+ }
1472
+ const key = "id" in row ? idKey(row.id) : null;
1473
+ const fields = key ? overlay.get(key) : void 0;
1474
+ if (fields) {
1475
+ for (const [field, entry] of fields) if (!sameValue(row[field], entry.value)) (next ??= { ...row })[field] = entry.value;
1476
+ }
1477
+ return next ?? row;
1478
+ }
1479
+ /** Every record id in rows, nested related rows included. */
1480
+ function collectIds(rows, into = /* @__PURE__ */ new Set(), depth = 0) {
1481
+ if (depth > OVERLAY_DEPTH) return into;
1482
+ for (const v of rows) {
1483
+ if (v === null || typeof v !== "object") continue;
1484
+ if (Array.isArray(v)) {
1485
+ collectIds(v, into, depth + 1);
1486
+ continue;
1487
+ }
1488
+ if (isRecordIdLike(v) || v instanceof Date || v instanceof Uint8Array) continue;
1489
+ const row = v;
1490
+ const key = "id" in row ? idKey(row.id) : null;
1491
+ if (key) into.add(key);
1492
+ for (const [k, x] of Object.entries(row)) if (k !== "id" && x !== null && typeof x === "object") collectIds([x], into, depth + 1);
1493
+ }
1494
+ return into;
1495
+ }
1496
+ /** Normalized peer ids, without `self`. */
1497
+ function peerUsers(peers, self) {
1498
+ const out = /* @__PURE__ */ new Set();
1499
+ for (const p of peers) {
1500
+ const key = idKey(p);
1501
+ if (key && key !== self) out.add(key);
1502
+ }
1503
+ return out;
1504
+ }
1505
+ const OPS = {
1506
+ "=": (a, b) => sameKey(a, b),
1507
+ "==": (a, b) => sameKey(a, b),
1508
+ "!=": (a, b) => !sameKey(a, b),
1509
+ ">": (a, b) => order(a, b) > 0,
1510
+ ">=": (a, b) => order(a, b) >= 0,
1511
+ "<": (a, b) => order(a, b) < 0,
1512
+ "<=": (a, b) => order(a, b) <= 0,
1513
+ IN: (a, b) => Array.isArray(b) && b.some((x) => sameKey(a, x)),
1514
+ INSIDE: (a, b) => Array.isArray(b) && b.some((x) => sameKey(a, x)),
1515
+ "∈": (a, b) => Array.isArray(b) && b.some((x) => sameKey(a, x)),
1516
+ "NOT IN": (a, b) => !(Array.isArray(b) && b.some((x) => sameKey(a, x))),
1517
+ NOTINSIDE: (a, b) => !(Array.isArray(b) && b.some((x) => sameKey(a, x))),
1518
+ "∉": (a, b) => !(Array.isArray(b) && b.some((x) => sameKey(a, x))),
1519
+ CONTAINS: (a, b) => Array.isArray(a) && a.some((x) => sameKey(x, b)),
1520
+ "∋": (a, b) => Array.isArray(a) && a.some((x) => sameKey(x, b)),
1521
+ CONTAINSNOT: (a, b) => !(Array.isArray(a) && a.some((x) => sameKey(x, b))),
1522
+ "∌": (a, b) => !(Array.isArray(a) && a.some((x) => sameKey(x, b)))
1523
+ };
1524
+ /** A comparable form: record ids by `table:id` (in any spelling), dates by ms. */
1525
+ function keyOf(v) {
1526
+ if (v === void 0) return null;
1527
+ if (isRecordIdLike(v)) return idKey(v);
1528
+ if (v instanceof Date) return v.getTime();
1529
+ return v;
1530
+ }
1531
+ function sameKey(a, b) {
1532
+ const x = isRecordIdLike(a) || isRecordIdLike(b) ? keyOfId(a) : keyOf(a);
1533
+ const y = isRecordIdLike(a) || isRecordIdLike(b) ? keyOfId(b) : keyOf(b);
1534
+ if (x === y) return true;
1535
+ return x !== null && y !== null && typeof x === "object" && typeof y === "object" && sameValue(x, y);
1536
+ }
1537
+ /** A record id compared against a string spelling of one. */
1538
+ function keyOfId(v) {
1539
+ return typeof v === "string" ? v : keyOf(v);
1540
+ }
1541
+ function order(a, b) {
1542
+ const x = keyOf(a);
1543
+ const y = keyOf(b);
1544
+ if (x === null || y === null || typeof x !== typeof y) return NaN;
1545
+ return x < y ? -1 : x > y ? 1 : 0;
1546
+ }
1547
+ function pathValue(row, path) {
1548
+ let v = row;
1549
+ for (const part of path.split(".")) {
1550
+ if (v === null || typeof v !== "object") return void 0;
1551
+ v = v[part];
1552
+ }
1553
+ return v;
1554
+ }
1555
+ function matchesComparison(c, row) {
1556
+ const field = pathValue(row, c.field);
1557
+ const op = OPS[c.op];
1558
+ return c.swap ? op(c.value, field) : op(field, c.value);
1559
+ }
1560
+ /** Whether `row` passes a query's `where` (a conjunction of comparisons and `_or` groups). */
1561
+ function matchesWhere(where, row) {
1562
+ return where.every((node) => "or" in node ? node.or.some((c) => matchesComparison(c, row)) : matchesComparison(node, row));
1563
+ }
1564
+ /**
1565
+ * The parts of a channel query that do nothing there (a channel only reads
1566
+ * the `where`). Not an error, worth a warning unless `.ignoreUnusedArgs()`.
1567
+ */
1568
+ function unusedArgs(plan, isOne = false) {
1569
+ const out = [];
1570
+ if (isOne) out.push(".one()");
1571
+ if (plan.orderBy && plan.orderBy.length > 0) out.push("orderBy");
1572
+ if (plan.limit !== void 0 && !(isOne && plan.limit === 1)) out.push("limit");
1573
+ if (plan.offset !== void 0) out.push("offset");
1574
+ if (plan.select && plan.select.length > 0) out.push("select");
1575
+ return out;
1576
+ }
1577
+ /**
1578
+ * A query plan as a channel scope, or an error saying why it cannot be one:
1579
+ * the table must be `@channel`, and the filter must be decidable from the
1580
+ * row alone (its own fields, literal values, supported operators). Order,
1581
+ * limit and projection do not matter to a channel and are ignored.
1582
+ */
1583
+ function compileScope(schema, plan) {
1584
+ const table = schema.tables.find((t) => t.name === plan.table);
1585
+ if (!table?.channel) throw new Error(`Cannot open a channel on '${plan.table}': the table is not marked '-- @channel'. Add '-- @channel' above 'DEFINE TABLE ${plan.table}' and run 'spky generate'.`);
1586
+ if (plan.relations && plan.relations.length > 0) throw new Error(`A channel query on '${plan.table}' cannot use .related(): its filter must be decidable from the row alone.`);
1587
+ const where = plan.where ?? [];
1588
+ for (const node of where) for (const c of "or" in node ? node.or : [node]) {
1589
+ if (!(c.op in OPS)) throw new Error(`A channel query on '${plan.table}' cannot use the operator '${c.op}'.`);
1590
+ if (c.value === void 0 && c.paramRef) throw new Error(`A channel query on '${plan.table}' cannot compare '${c.field}' with a parameter ($${c.paramRef}); use a value.`);
1591
+ const root = c.field.split(".")[0];
1592
+ if (c.field.includes(".") && table.columns[root]?.recordId) throw new Error(`A channel query on '${plan.table}' cannot filter on '${c.field}': it follows a record link, which the row alone does not hold.`);
1593
+ }
1594
+ return {
1595
+ table: plan.table,
1596
+ where
1597
+ };
1598
+ }
1599
+ const SWEEP_MS = 1e3;
1600
+ const randomPeerId = () => {
1601
+ const bytes = new Uint8Array(8);
1602
+ globalThis.crypto.getRandomValues(bytes);
1603
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
1604
+ };
1605
+ var ChannelManager = class {
1606
+ tables;
1607
+ schema;
1608
+ overlay = /* @__PURE__ */ new Map();
1609
+ taps = /* @__PURE__ */ new Set();
1610
+ channels = /* @__PURE__ */ new Set();
1611
+ handles = /* @__PURE__ */ new WeakMap();
1612
+ connected = /* @__PURE__ */ new Map();
1613
+ userStatus = /* @__PURE__ */ new Map();
1614
+ /** Unused-argument warnings already given, so a reactive query does not repeat them. */
1615
+ warnedUnused = /* @__PURE__ */ new Set();
1616
+ peerId = randomPeerId();
1617
+ user = null;
1618
+ clock;
1619
+ transport = null;
1620
+ sweepTimer = null;
1621
+ closed = false;
1622
+ now;
1623
+ confirmMs;
1624
+ constructor(schema, deps = {}) {
1625
+ this.deps = deps;
1626
+ this.schema = schema;
1627
+ this.tables = new Map(schema.tables.map((t) => [t.name, {
1628
+ columns: t.columns,
1629
+ channel: t.channel === true
1630
+ }]));
1631
+ this.now = deps.now ?? Date.now;
1632
+ this.confirmMs = Math.max(0, deps.config?.confirmTimeoutMs ?? 5e3);
1633
+ this.clock = [
1634
+ 0,
1635
+ 0,
1636
+ this.peerId
1637
+ ];
1638
+ }
1639
+ /** True when any table is a channel table; otherwise the client skips all of this. */
1640
+ get enabled() {
1641
+ for (const t of this.tables.values()) if (t.channel) return true;
1642
+ return false;
1643
+ }
1644
+ /** The signed-in user changed: peers, their values and the transport start over. */
1645
+ setUser(user) {
1646
+ if (user === this.user) return;
1647
+ this.user = user;
1648
+ this.transport?.close();
1649
+ this.transport = null;
1650
+ this.connected.clear();
1651
+ this.userStatus.clear();
1652
+ const touched = new Set(this.overlay.keys());
1653
+ this.overlay.clear();
1654
+ for (const ch of this.channels) ch.users = peerUsers(ch.listed, user);
1655
+ this.syncWanted();
1656
+ this.emitFor(touched);
1657
+ this.notifyAll();
1658
+ }
1659
+ /**
1660
+ * Compile a query plan into a channel scope (throws when it cannot be
1661
+ * one). Parts the channel ignores draw one warning per distinct query
1662
+ * shape, unless the query says so itself (`ignoreUnused`).
1663
+ */
1664
+ scope(plan, opts = {}) {
1665
+ const scope = compileScope(this.schema, plan);
1666
+ const unused = opts.ignoreUnused ? [] : unusedArgs(plan, opts.isOne);
1667
+ if (unused.length > 0) {
1668
+ const message = `channel query on '${plan.table}' has ${unused.join(", ")}, which a channel ignores (it only uses the where filter). Remove them, or add .ignoreUnusedArgs() to the query to say it is on purpose.`;
1669
+ if (!this.warnedUnused.has(message)) {
1670
+ this.warnedUnused.add(message);
1671
+ this.deps.logger?.warn({
1672
+ table: plan.table,
1673
+ unused,
1674
+ Category: "sp00ky-client::channel"
1675
+ }, message);
1676
+ }
1677
+ }
1678
+ return scope;
1679
+ }
1680
+ /** Open a channel carrying the rows `scopes` match to `peers`. */
1681
+ open(scopes, peers) {
1682
+ if (scopes.length === 0) throw new Error("A channel needs at least one query.");
1683
+ const listed = [...peers];
1684
+ const ch = {
1685
+ scopes,
1686
+ listed,
1687
+ users: peerUsers(listed, this.user),
1688
+ listeners: /* @__PURE__ */ new Set(),
1689
+ state: {
1690
+ status: "idle",
1691
+ peers: []
1692
+ },
1693
+ closed: false
1694
+ };
1695
+ this.channels.add(ch);
1696
+ this.syncWanted();
1697
+ ch.state = this.stateOf(ch);
1698
+ const handle = {
1699
+ tables: () => [...new Set(ch.scopes.map((sc) => sc.table))],
1700
+ state: () => ch.state,
1701
+ subscribe: (cb) => {
1702
+ cb(ch.state);
1703
+ ch.listeners.add(cb);
1704
+ return () => ch.listeners.delete(cb);
1705
+ },
1706
+ setPeers: (next) => {
1707
+ if (ch.closed) return;
1708
+ ch.listed = [...next];
1709
+ ch.users = peerUsers(ch.listed, this.user);
1710
+ this.syncWanted();
1711
+ this.notify(ch);
1712
+ },
1713
+ setScopes: (next) => {
1714
+ if (ch.closed) return;
1715
+ if (next.length === 0) throw new Error("A channel needs at least one query.");
1716
+ ch.scopes = next;
1717
+ },
1718
+ close: () => {
1719
+ if (ch.closed) return;
1720
+ ch.closed = true;
1721
+ this.channels.delete(ch);
1722
+ ch.listeners.clear();
1723
+ this.syncWanted();
1724
+ }
1725
+ };
1726
+ this.handles.set(handle, ch);
1727
+ return handle;
1728
+ }
1729
+ /** Throws unless `handle` is an open channel of this manager with a query on `table`. */
1730
+ assertCarries(handle, table) {
1731
+ const ch = this.handles.get(handle);
1732
+ if (!ch || ch.closed) throw new Error("This channel is closed.");
1733
+ if (!ch.scopes.some((sc) => sc.table === table)) throw new Error(`This channel has no query on '${table}' (it reads ${[...new Set(ch.scopes.map((sc) => sc.table))].join(", ")}).`);
1734
+ }
1735
+ /**
1736
+ * Follow a subscription: `feed` it the raw rows, and `cb` gets them with
1737
+ * the overlay folded in, again whenever the overlay changes for its rows.
1738
+ */
1739
+ tap(cb) {
1740
+ const tap = {
1741
+ cb,
1742
+ raw: null,
1743
+ ids: null
1744
+ };
1745
+ this.taps.add(tap);
1746
+ return {
1747
+ feed: (rows) => {
1748
+ tap.raw = rows;
1749
+ tap.ids = null;
1750
+ this.emit(tap);
1751
+ },
1752
+ stop: () => {
1753
+ this.taps.delete(tap);
1754
+ }
1755
+ };
1756
+ }
1757
+ /** Split an own write on `table` into database fields and peer-only fields. */
1758
+ split(table, data) {
1759
+ const t = this.tables.get(table);
1760
+ if (!t?.channel) return {
1761
+ persistent: data,
1762
+ ephemeral: null
1763
+ };
1764
+ return splitPatch(t.columns, data);
1765
+ }
1766
+ /**
1767
+ * An own update: ephemeral fields go into the overlay (they have no other
1768
+ * home), persistent fields drop any peer value they supersede, and the
1769
+ * whole patch goes to the peers (of `scope`, or of the channel carrying the
1770
+ * table, see `targets`).
1771
+ */
1772
+ local(table, id, persistent, ephemeral, scope) {
1773
+ if (!this.tables.get(table)?.channel || this.closed) return;
1774
+ const rid = normalizeId(table, id);
1775
+ const hlc = this.tick();
1776
+ const fields = this.overlay.get(rid);
1777
+ let touched = false;
1778
+ if (fields) for (const f of Object.keys(persistent)) touched = fields.delete(f) || touched;
1779
+ if (ephemeral) {
1780
+ const map = fields ?? /* @__PURE__ */ new Map();
1781
+ for (const [f, value] of Object.entries(ephemeral)) map.set(f, {
1782
+ value,
1783
+ hlc,
1784
+ from: "self",
1785
+ ephemeral: true,
1786
+ expiresAt: null
1787
+ });
1788
+ this.overlay.set(rid, map);
1789
+ touched = true;
1790
+ }
1791
+ if (fields && fields.size === 0) this.overlay.delete(rid);
1792
+ if (touched) this.emitFor(new Set([rid]));
1793
+ const patch = {
1794
+ ...persistent,
1795
+ ...ephemeral ?? {}
1796
+ };
1797
+ if (Object.keys(patch).length === 0) return;
1798
+ this.rowFor(rid).then((row) => {
1799
+ const after = {
1800
+ ...row ?? { id: rid },
1801
+ ...patch
1802
+ };
1803
+ this.send(this.targets(table, row ? [row, after] : [after], scope), table, rid, patch, hlc, "update");
1804
+ });
1805
+ }
1806
+ /**
1807
+ * An own create: its ephemeral fields go into the overlay (the store never
1808
+ * holds them), and the whole row goes to the peers.
1809
+ */
1810
+ localCreate(table, id, row, scope) {
1811
+ const t = this.tables.get(table);
1812
+ if (!t?.channel || this.closed) return;
1813
+ const rid = normalizeId(table, id);
1814
+ const full = pickColumns(t.columns, row);
1815
+ const hlc = this.tick();
1816
+ const ephemeral = Object.keys(full).filter((f) => t.columns[f]?.ephemeral);
1817
+ if (ephemeral.length > 0) {
1818
+ const map = this.overlay.get(rid) ?? /* @__PURE__ */ new Map();
1819
+ for (const f of ephemeral) map.set(f, {
1820
+ value: full[f],
1821
+ hlc,
1822
+ from: "self",
1823
+ ephemeral: true,
1824
+ expiresAt: null
1825
+ });
1826
+ this.overlay.set(rid, map);
1827
+ this.emitFor(new Set([rid]));
1828
+ }
1829
+ this.send(this.targets(table, [{
1830
+ ...full,
1831
+ id: rid
1832
+ }], scope), table, rid, full, hlc, "create");
1833
+ }
1834
+ /**
1835
+ * An own delete: the peers hide the row at once. Resolves once it is sent;
1836
+ * await it before the local delete, which the filter check reads past.
1837
+ */
1838
+ async localDelete(table, id, scope) {
1839
+ if (!this.tables.get(table)?.channel || this.closed) return;
1840
+ const rid = normalizeId(table, id);
1841
+ const hlc = this.tick();
1842
+ const row = await this.rowFor(rid);
1843
+ if (row) this.send(this.targets(table, [row], scope), table, rid, {}, hlc, "delete");
1844
+ if (this.overlay.delete(rid)) this.emitFor(new Set([rid]));
1845
+ }
1846
+ /** A transport delivered a message from `from`. */
1847
+ receive(from, raw) {
1848
+ if (this.closed) return;
1849
+ const msg = parseMessage(raw);
1850
+ if (!msg) return this.drop(from, "malformed message");
1851
+ this.observe(msg.hlc);
1852
+ const t = this.tables.get(msg.table);
1853
+ if (!t?.channel) return this.drop(from, `'${msg.table}' is not a @channel table`);
1854
+ const rid = normalizeId(msg.table, msg.id);
1855
+ if (rid.slice(0, rid.indexOf(":")) !== msg.table) return this.drop(from, "id is not a row of the table");
1856
+ if (!this.listedOn(msg.table, from.user)) return this.drop(from, `no channel on '${msg.table}' lists ${from.user}`);
1857
+ const kind = msg.kind ?? "update";
1858
+ if (kind === "create") return this.receiveCreate(from, msg, rid, t.columns);
1859
+ if (kind === "delete") return void this.receiveDelete(from, msg.table, rid);
1860
+ const patch = {};
1861
+ for (const [k, v] of Object.entries(msg.patch)) patch[k] = decodeValue(v);
1862
+ const problem = checkPatch(t.columns, patch);
1863
+ if (problem) return this.drop(from, problem);
1864
+ this.receiveUpdate(from, msg, rid, patch, t.columns);
1865
+ }
1866
+ /** A peer updated a row we hold, inside a channel's filter (before or after the patch). */
1867
+ async receiveUpdate(from, msg, rid, patch, columns) {
1868
+ const row = await this.rowFor(rid);
1869
+ if (this.closed) return;
1870
+ if (!row) return this.drop(from, `${rid} is not held here, so no filter can be checked`);
1871
+ if (!this.matchesFor(msg.table, [row, {
1872
+ ...row,
1873
+ ...patch
1874
+ }], from.user)) return this.drop(from, `${rid} is outside every channel listing ${from.user}`);
1875
+ if (!this.accepts({
1876
+ kind: "update",
1877
+ from: from.user,
1878
+ table: msg.table,
1879
+ id: rid,
1880
+ row,
1881
+ patch
1882
+ })) return this.drop(from, "refused by the accept rule");
1883
+ const map = this.overlay.get(rid) ?? /* @__PURE__ */ new Map();
1884
+ const key = peerKey(from);
1885
+ let touched = false;
1886
+ for (const [field, value] of Object.entries(patch)) {
1887
+ const prev = map.get(field);
1888
+ if (prev && compareHlc(prev.hlc, msg.hlc) >= 0) continue;
1889
+ const ephemeral = columns[field]?.ephemeral === true;
1890
+ map.set(field, {
1891
+ value,
1892
+ hlc: msg.hlc,
1893
+ from: key,
1894
+ ephemeral,
1895
+ expiresAt: ephemeral ? null : this.now() + this.confirmMs
1896
+ });
1897
+ touched = true;
1898
+ }
1899
+ if (map.size > 0) this.overlay.set(rid, map);
1900
+ if (!touched) return;
1901
+ this.ensureSweep();
1902
+ this.emitFor(new Set([rid]));
1903
+ }
1904
+ /** A peer created a row: into the store like an own optimistic create, unconfirmed. */
1905
+ receiveCreate(from, msg, rid, columns) {
1906
+ const rows = this.deps.rows;
1907
+ if (!rows) return this.drop(from, "creates are not wired");
1908
+ const row = {};
1909
+ for (const [k, v] of Object.entries(msg.patch)) if (k !== "id") row[k] = decodeValue(v);
1910
+ const problem = checkPatch(columns, row, true);
1911
+ if (problem) return this.drop(from, problem);
1912
+ if (!this.matchesFor(msg.table, [{
1913
+ ...row,
1914
+ id: parseRecordIdString(rid)
1915
+ }], from.user)) return this.drop(from, `the new ${rid} is outside every channel listing ${from.user}`);
1916
+ if (!this.accepts({
1917
+ kind: "create",
1918
+ from: from.user,
1919
+ table: msg.table,
1920
+ id: rid,
1921
+ row: void 0,
1922
+ patch: row
1923
+ })) return this.drop(from, "refused by the accept rule");
1924
+ const persistent = {};
1925
+ const ephemeral = {};
1926
+ for (const [k, v] of Object.entries(row)) (columns[k]?.ephemeral ? ephemeral : persistent)[k] = v;
1927
+ rows.create(msg.table, rid, persistent, this.confirmMs).then((applied) => {
1928
+ if (!applied || this.closed || Object.keys(ephemeral).length === 0) return;
1929
+ const map = this.overlay.get(rid) ?? /* @__PURE__ */ new Map();
1930
+ for (const [f, value] of Object.entries(ephemeral)) map.set(f, {
1931
+ value,
1932
+ hlc: msg.hlc,
1933
+ from: peerKey(from),
1934
+ ephemeral: true,
1935
+ expiresAt: null
1936
+ });
1937
+ this.overlay.set(rid, map);
1938
+ this.emitFor(new Set([rid]));
1939
+ }, (error) => this.deps.logger?.debug({
1940
+ error,
1941
+ Category: "sp00ky-client::channel"
1942
+ }, "peer create failed"));
1943
+ }
1944
+ /** A peer deleted a row inside a channel's filter: hide it until the server confirms (or undo its own create). */
1945
+ async receiveDelete(from, table, rid) {
1946
+ const rows = this.deps.rows;
1947
+ if (!rows) return this.drop(from, "deletes are not wired");
1948
+ const row = await this.rowFor(rid);
1949
+ if (this.closed) return;
1950
+ if (!row) return this.drop(from, `${rid} is not held here`);
1951
+ if (!this.matchesFor(table, [row], from.user)) return this.drop(from, `${rid} is outside every channel listing ${from.user}`);
1952
+ if (!this.accepts({
1953
+ kind: "delete",
1954
+ from: from.user,
1955
+ table,
1956
+ id: rid,
1957
+ row,
1958
+ patch: {}
1959
+ })) return this.drop(from, "refused by the accept rule");
1960
+ if (this.overlay.delete(rid)) this.emitFor(new Set([rid]));
1961
+ rows.delete(table, rid, this.confirmMs).catch((error) => this.deps.logger?.debug({
1962
+ error,
1963
+ Category: "sp00ky-client::channel"
1964
+ }, "peer delete failed"));
1965
+ }
1966
+ accepts(op) {
1967
+ const rule = this.deps.config?.accept?.[op.table];
1968
+ if (!rule) return true;
1969
+ try {
1970
+ return rule(op) === true;
1971
+ } catch (error) {
1972
+ this.deps.logger?.warn({
1973
+ error,
1974
+ Category: "sp00ky-client::channel"
1975
+ }, "channel accept rule threw");
1976
+ return false;
1977
+ }
1978
+ }
1979
+ /** A peer tab came up (send it our ephemeral state) or went away (drop its). */
1980
+ peerChanged(peer, up) {
1981
+ const key = peerKey(peer);
1982
+ if (up) {
1983
+ this.connected.set(key, peer);
1984
+ this.userStatus.set(peer.user, "connected");
1985
+ this.sendSnapshot(peer);
1986
+ } else {
1987
+ this.connected.delete(key);
1988
+ const touched = /* @__PURE__ */ new Set();
1989
+ for (const [rid, fields] of this.overlay) {
1990
+ for (const [f, e] of fields) if (e.from === key && e.ephemeral) {
1991
+ fields.delete(f);
1992
+ touched.add(rid);
1993
+ }
1994
+ if (fields.size === 0) this.overlay.delete(rid);
1995
+ }
1996
+ this.emitFor(touched);
1997
+ }
1998
+ this.notifyAll();
1999
+ }
2000
+ statusChanged(user, status) {
2001
+ if (status !== "connected" && [...this.connected.values()].some((p) => p.user === user)) return;
2002
+ this.userStatus.set(user, status);
2003
+ this.notifyAll();
2004
+ }
2005
+ close() {
2006
+ this.closed = true;
2007
+ this.transport?.close();
2008
+ this.transport = null;
2009
+ if (this.sweepTimer) clearInterval(this.sweepTimer);
2010
+ this.sweepTimer = null;
2011
+ this.taps.clear();
2012
+ for (const ch of this.channels) ch.closed = true;
2013
+ this.channels.clear();
2014
+ this.overlay.clear();
2015
+ }
2016
+ syncWanted() {
2017
+ if (this.closed) return;
2018
+ const wanted = /* @__PURE__ */ new Set();
2019
+ for (const ch of this.channels) for (const u of ch.users) wanted.add(u);
2020
+ if (!this.transport) {
2021
+ if (wanted.size === 0 || !this.user || !this.deps.transport) return;
2022
+ this.transport = this.deps.transport({
2023
+ self: () => this.user ? {
2024
+ user: this.user,
2025
+ peer: this.peerId
2026
+ } : null,
2027
+ onMessage: (from, message) => this.receive(from, message),
2028
+ onPeer: (peer, up) => this.peerChanged(peer, up),
2029
+ onStatus: (user, status) => this.statusChanged(user, status)
2030
+ });
2031
+ }
2032
+ this.transport.want(wanted);
2033
+ }
2034
+ /** Some open channel lists `user` and has a query on `table` (the cheap check before any read). */
2035
+ listedOn(table, user) {
2036
+ for (const ch of this.channels) if (ch.users.has(user) && ch.scopes.some((sc) => sc.table === table)) return true;
2037
+ return false;
2038
+ }
2039
+ /** Some open channel listing `user` has a query on `table` one of `rows` matches. */
2040
+ matchesFor(table, rows, user) {
2041
+ for (const ch of this.channels) if (ch.users.has(user) && carries(ch, table, rows)) return true;
2042
+ return false;
2043
+ }
2044
+ /**
2045
+ * Who an own write goes to: the peers of every open channel (or only
2046
+ * `scope`) whose filter one of `rows` matches. That is what keeps a
2047
+ * message of one conversation away from the participants of another.
2048
+ */
2049
+ targets(table, rows, scope) {
2050
+ const users = /* @__PURE__ */ new Set();
2051
+ const pick = scope ? this.handles.get(scope) : void 0;
2052
+ for (const ch of pick ? [pick] : this.channels) {
2053
+ if (ch.closed || !carries(ch, table, rows)) continue;
2054
+ for (const u of ch.users) users.add(u);
2055
+ }
2056
+ return users;
2057
+ }
2058
+ /** The row as shown (overlay included): from a subscription, else the store. */
2059
+ async rowFor(rid) {
2060
+ const shown = this.rowOf(rid);
2061
+ if (shown) return shown;
2062
+ const stored = await this.deps.rows?.read(rid).catch(() => null);
2063
+ return stored ? applyOverlay([stored], this.overlay)[0] : null;
2064
+ }
2065
+ send(users, table, rid, patch, hlc, kind) {
2066
+ const transport = this.transport;
2067
+ if (!transport || users.size === 0) return;
2068
+ const encoded = {};
2069
+ for (const [k, v] of Object.entries(patch)) encoded[k] = encodeValue(v);
2070
+ const message = kind === "update" ? {
2071
+ t: "op",
2072
+ table,
2073
+ id: rid,
2074
+ patch: encoded,
2075
+ hlc
2076
+ } : {
2077
+ t: "op",
2078
+ kind,
2079
+ table,
2080
+ id: rid,
2081
+ patch: encoded,
2082
+ hlc
2083
+ };
2084
+ for (const peer of this.connected.values()) if (users.has(peer.user)) transport.send(peer, message);
2085
+ }
2086
+ /** Own ephemeral values of rows inside a channel a newly connected peer is listed on. */
2087
+ sendSnapshot(peer) {
2088
+ for (const [rid, fields] of this.overlay) {
2089
+ const own = [...fields].filter(([, e]) => e.from === "self");
2090
+ if (own.length === 0) continue;
2091
+ const table = rid.slice(0, rid.indexOf(":"));
2092
+ if (!this.listedOn(table, peer.user)) continue;
2093
+ const patch = {};
2094
+ let hlc = own[0][1].hlc;
2095
+ for (const [f, e] of own) {
2096
+ patch[f] = encodeValue(e.value);
2097
+ if (compareHlc(e.hlc, hlc) > 0) hlc = e.hlc;
2098
+ }
2099
+ this.rowFor(rid).then((row) => {
2100
+ if (!row || !this.matchesFor(table, [row], peer.user)) return;
2101
+ this.transport?.send(peer, {
2102
+ t: "op",
2103
+ table,
2104
+ id: rid,
2105
+ patch,
2106
+ hlc
2107
+ });
2108
+ });
2109
+ }
2110
+ }
2111
+ rowOf(rid) {
2112
+ for (const tap of this.taps) {
2113
+ if (!tap.raw || !idsOf(tap).has(rid)) continue;
2114
+ const row = findRow(tap.raw, rid, 0);
2115
+ if (row) return applyOverlay([row], this.overlay)[0];
2116
+ }
2117
+ }
2118
+ emit(tap) {
2119
+ if (!tap.raw) return;
2120
+ const rows = applyOverlay(tap.raw, this.overlay);
2121
+ this.confirm(tap.raw);
2122
+ tap.cb(rows);
2123
+ }
2124
+ emitFor(rids) {
2125
+ if (rids.size === 0) return;
2126
+ for (const tap of this.taps) {
2127
+ if (!tap.raw) continue;
2128
+ const ids = idsOf(tap);
2129
+ for (const rid of rids) if (ids.has(rid)) {
2130
+ this.emit(tap);
2131
+ break;
2132
+ }
2133
+ }
2134
+ }
2135
+ /** Persistent peer values the synced rows already hold are done showing. */
2136
+ confirm(raw) {
2137
+ if (this.overlay.size === 0) return;
2138
+ for (const rid of collectIds(raw)) {
2139
+ const fields = this.overlay.get(rid);
2140
+ if (!fields) continue;
2141
+ const row = findRow(raw, rid, 0);
2142
+ if (!row) continue;
2143
+ for (const [f, e] of fields) if (!e.ephemeral && e.from !== "self" && f in row && sameValue(row[f], e.value)) fields.delete(f);
2144
+ if (fields.size === 0) this.overlay.delete(rid);
2145
+ }
2146
+ }
2147
+ ensureSweep() {
2148
+ if (this.sweepTimer || this.closed) return;
2149
+ this.sweepTimer = setInterval(() => this.sweep(), SWEEP_MS);
2150
+ }
2151
+ sweep() {
2152
+ const now = this.now();
2153
+ const touched = /* @__PURE__ */ new Set();
2154
+ let pending = false;
2155
+ for (const [rid, fields] of this.overlay) {
2156
+ for (const [f, e] of fields) {
2157
+ if (e.expiresAt === null) continue;
2158
+ if (e.expiresAt <= now) {
2159
+ fields.delete(f);
2160
+ touched.add(rid);
2161
+ } else pending = true;
2162
+ }
2163
+ if (fields.size === 0) this.overlay.delete(rid);
2164
+ }
2165
+ if (!pending && this.sweepTimer) {
2166
+ clearInterval(this.sweepTimer);
2167
+ this.sweepTimer = null;
2168
+ }
2169
+ this.emitFor(touched);
2170
+ }
2171
+ tick() {
2172
+ const now = this.now();
2173
+ const [ms, c] = this.clock;
2174
+ this.clock = now > ms ? [
2175
+ now,
2176
+ 0,
2177
+ this.peerId
2178
+ ] : [
2179
+ ms,
2180
+ c + 1,
2181
+ this.peerId
2182
+ ];
2183
+ return this.clock;
2184
+ }
2185
+ observe(h) {
2186
+ if (h[0] > this.clock[0] || h[0] === this.clock[0] && h[1] > this.clock[1]) this.clock = [
2187
+ h[0],
2188
+ h[1],
2189
+ this.peerId
2190
+ ];
2191
+ }
2192
+ stateOf(ch) {
2193
+ const users = [...ch.users];
2194
+ if (users.length === 0) return {
2195
+ status: "idle",
2196
+ peers: []
2197
+ };
2198
+ if (this.transport && !this.transport.supported) return {
2199
+ status: "unsupported",
2200
+ peers: []
2201
+ };
2202
+ const up = users.filter((u) => [...this.connected.values()].some((p) => p.user === u));
2203
+ if (up.length > 0) return {
2204
+ status: "connected",
2205
+ peers: up
2206
+ };
2207
+ if (users.every((u) => this.userStatus.get(u) === "failed")) return {
2208
+ status: "failed",
2209
+ peers: []
2210
+ };
2211
+ return {
2212
+ status: "connecting",
2213
+ peers: []
2214
+ };
2215
+ }
2216
+ notify(ch) {
2217
+ const next = this.stateOf(ch);
2218
+ if (next.status === ch.state.status && next.peers.join(",") === ch.state.peers.join(",")) return;
2219
+ ch.state = next;
2220
+ for (const cb of ch.listeners) cb(next);
2221
+ }
2222
+ notifyAll() {
2223
+ for (const ch of this.channels) this.notify(ch);
2224
+ }
2225
+ drop(from, reason) {
2226
+ this.deps.logger?.debug({
2227
+ from: from.user,
2228
+ reason,
2229
+ Category: "sp00ky-client::channel"
2230
+ }, "channel message dropped");
2231
+ }
2232
+ };
2233
+ /** Whether a channel has a query on `table` that one of `rows` matches. */
2234
+ function carries(ch, table, rows) {
2235
+ return ch.scopes.some((sc) => sc.table === table && rows.some((r) => matchesWhere(sc.where, r)));
2236
+ }
2237
+ /** The fields of `row` the table has as plain columns (no id, no `@crdt`). */
2238
+ function pickColumns(columns, row) {
2239
+ const out = {};
2240
+ for (const [k, v] of Object.entries(row)) {
2241
+ if (k === "id" || !columns[k] || columns[k].crdt || v === void 0) continue;
2242
+ out[k] = v;
2243
+ }
2244
+ return out;
2245
+ }
2246
+ function idsOf(tap) {
2247
+ return tap.ids ??= tap.raw ? collectIds(tap.raw) : /* @__PURE__ */ new Set();
2248
+ }
2249
+ /** `table:id` for an id given with or without its table. */
2250
+ function normalizeId(table, id) {
2251
+ return id.includes(":") ? id : `${table}:${id}`;
2252
+ }
2253
+ function findRow(rows, rid, depth) {
2254
+ if (depth > OVERLAY_DEPTH) return null;
2255
+ for (const v of rows) {
2256
+ if (v === null || typeof v !== "object") continue;
2257
+ if (Array.isArray(v)) {
2258
+ const hit = findRow(v, rid, depth + 1);
2259
+ if (hit) return hit;
2260
+ continue;
2261
+ }
2262
+ if (isRecordIdLike(v) || v instanceof Date || v instanceof Uint8Array) continue;
2263
+ const row = v;
2264
+ if ("id" in row && idKey(row.id) === rid) return row;
2265
+ for (const [k, x] of Object.entries(row)) {
2266
+ if (k === "id" || x === null || typeof x !== "object") continue;
2267
+ const hit = findRow([x], rid, depth + 1);
2268
+ if (hit) return hit;
2269
+ }
2270
+ }
2271
+ return null;
2272
+ }
2273
+
2274
+ //#endregion
2275
+ //#region src/modules/channel/webrtc.ts
2276
+ const DEFAULT_ICE = [{ urls: "stun:stun.cloudflare.com:3478" }];
2277
+ const ICE_FLUSH_MS = 20;
2278
+ const RETRY_BASE_MS$1 = 2e3;
2279
+ const RETRY_MAX_MS$1 = 3e4;
2280
+ const randomSid = () => Math.random().toString(36).slice(2, 10);
2281
+ function webrtcTransport(hooks, opts) {
2282
+ const PC = opts.RTCPeerConnection ?? globalThis.RTCPeerConnection;
2283
+ const log = opts.logger;
2284
+ const connectTimeoutMs = opts.connectTimeoutMs ?? 15e3;
2285
+ const links = /* @__PURE__ */ new Map();
2286
+ const retries = /* @__PURE__ */ new Map();
2287
+ let wanted = /* @__PURE__ */ new Set();
2288
+ let stopListen = null;
2289
+ let listeningAs = null;
2290
+ let closed = false;
2291
+ const selfKey = () => {
2292
+ const self = hooks.self();
2293
+ return self ? peerKey(self) : null;
2294
+ };
2295
+ const send = (link, signal) => {
2296
+ const self = hooks.self();
2297
+ if (!self) return;
2298
+ const out = {
2299
+ ...signal,
2300
+ fromPeer: self.peer
2301
+ };
2302
+ const run = () => opts.signal.send(out).catch((error) => {
2303
+ log?.debug({
2304
+ error,
2305
+ kind: signal.kind,
2306
+ Category: "sp00ky-client::channel"
2307
+ }, "channel signal failed");
2308
+ });
2309
+ if (link) link.queue = link.queue.then(run);
2310
+ else run();
2311
+ };
2312
+ const dropLink = (link, failed) => {
2313
+ if (link.closed) return;
2314
+ link.closed = true;
2315
+ if (link.iceTimer) clearTimeout(link.iceTimer);
2316
+ if (link.connectTimer) clearTimeout(link.connectTimer);
2317
+ try {
2318
+ link.dc?.close();
2319
+ link.pc.close();
2320
+ } catch {}
2321
+ if (links.get(link.key) === link) links.delete(link.key);
2322
+ if (link.open) {
2323
+ link.open = false;
2324
+ hooks.onPeer(link.ref, false);
2325
+ }
2326
+ if (failed && wanted.has(link.ref.user) && !closed) {
2327
+ if (![...links.values()].some((l) => l.ref.user === link.ref.user && l.open)) hooks.onStatus(link.ref.user, "failed");
2328
+ scheduleRetry(link.ref);
2329
+ }
2330
+ };
2331
+ const scheduleRetry = (ref) => {
2332
+ const key = peerKey(ref);
2333
+ const r = retries.get(key) ?? {
2334
+ attempts: 0,
2335
+ timer: null
2336
+ };
2337
+ if (r.timer) return;
2338
+ const delay = Math.min(RETRY_MAX_MS$1, RETRY_BASE_MS$1 * 2 ** r.attempts);
2339
+ r.attempts++;
2340
+ r.timer = setTimeout(() => {
2341
+ r.timer = null;
2342
+ if (closed || !wanted.has(ref.user) || links.has(key)) return;
2343
+ const mine = selfKey();
2344
+ if (!mine) return;
2345
+ if (mine < key) startOffer(ref);
2346
+ else send(null, {
2347
+ to: ref.user,
2348
+ toPeer: ref.peer,
2349
+ kind: "hello",
2350
+ payload: null
2351
+ });
2352
+ }, delay);
2353
+ retries.set(key, r);
2354
+ };
2355
+ const attachChannel = (link, dc) => {
2356
+ link.dc = dc;
2357
+ dc.onopen = () => {
2358
+ if (link.closed) return;
2359
+ link.open = true;
2360
+ if (link.connectTimer) clearTimeout(link.connectTimer);
2361
+ link.connectTimer = null;
2362
+ retries.delete(link.key);
2363
+ hooks.onPeer(link.ref, true);
2364
+ hooks.onStatus(link.ref.user, "connected");
2365
+ };
2366
+ dc.onclose = () => dropLink(link, true);
2367
+ dc.onmessage = (event) => {
2368
+ if (typeof event.data !== "string") return;
2369
+ let message;
2370
+ try {
2371
+ message = JSON.parse(event.data);
2372
+ } catch {
2373
+ return;
2374
+ }
2375
+ hooks.onMessage(link.ref, message);
2376
+ };
2377
+ };
2378
+ const newLink = (ref, offerer, sid) => {
2379
+ if (!PC) return null;
2380
+ const key = peerKey(ref);
2381
+ const prev = links.get(key);
2382
+ if (prev) dropLink(prev, false);
2383
+ const pc = new PC({ iceServers: opts.iceServers ?? DEFAULT_ICE });
2384
+ const link = {
2385
+ ref,
2386
+ key,
2387
+ offerer,
2388
+ sid,
2389
+ pc,
2390
+ dc: null,
2391
+ open: false,
2392
+ remoteSet: false,
2393
+ pendingIce: [],
2394
+ outIce: [],
2395
+ iceTimer: null,
2396
+ connectTimer: null,
2397
+ queue: Promise.resolve(),
2398
+ closed: false
2399
+ };
2400
+ links.set(key, link);
2401
+ pc.onicecandidate = (event) => {
2402
+ if (!event.candidate || link.closed) return;
2403
+ link.outIce.push(event.candidate.toJSON());
2404
+ link.iceTimer ??= setTimeout(() => {
2405
+ link.iceTimer = null;
2406
+ const candidates = link.outIce.splice(0);
2407
+ if (candidates.length === 0 || link.closed) return;
2408
+ send(link, {
2409
+ to: ref.user,
2410
+ toPeer: ref.peer,
2411
+ kind: "ice",
2412
+ payload: JSON.stringify({
2413
+ sid,
2414
+ candidates
2415
+ })
2416
+ });
2417
+ }, ICE_FLUSH_MS);
2418
+ };
2419
+ pc.onconnectionstatechange = () => {
2420
+ if (pc.connectionState === "failed" || pc.connectionState === "closed") dropLink(link, true);
2421
+ };
2422
+ if (!offerer) pc.ondatachannel = (event) => attachChannel(link, event.channel);
2423
+ link.connectTimer = setTimeout(() => {
2424
+ if (!link.open) dropLink(link, true);
2425
+ }, connectTimeoutMs);
2426
+ hooks.onStatus(ref.user, "connecting");
2427
+ return link;
2428
+ };
2429
+ const startOffer = (ref) => {
2430
+ const link = newLink(ref, true, randomSid());
2431
+ if (!link) return;
2432
+ attachChannel(link, link.pc.createDataChannel("sp00ky", { ordered: true }));
2433
+ (async () => {
2434
+ try {
2435
+ const offer = await link.pc.createOffer();
2436
+ await link.pc.setLocalDescription(offer);
2437
+ send(link, {
2438
+ to: ref.user,
2439
+ toPeer: ref.peer,
2440
+ kind: "offer",
2441
+ payload: JSON.stringify({
2442
+ sid: link.sid,
2443
+ sdp: link.pc.localDescription?.sdp ?? offer.sdp
2444
+ })
2445
+ });
2446
+ } catch (error) {
2447
+ log?.debug({
2448
+ error,
2449
+ Category: "sp00ky-client::channel"
2450
+ }, "channel offer failed");
2451
+ dropLink(link, true);
2452
+ }
2453
+ })();
2454
+ };
2455
+ const addIce = async (link, candidates) => {
2456
+ for (const c of candidates) try {
2457
+ await link.pc.addIceCandidate(c);
2458
+ } catch {}
2459
+ };
2460
+ const onSignal = (s) => {
2461
+ if (closed) return;
2462
+ const self = hooks.self();
2463
+ if (!self) return;
2464
+ const from = idKey(s.from);
2465
+ if (!from || !wanted.has(from)) return;
2466
+ if (s.toPeer !== null && s.toPeer !== self.peer) return;
2467
+ const ref = {
2468
+ user: from,
2469
+ peer: s.fromPeer
2470
+ };
2471
+ const key = peerKey(ref);
2472
+ const mine = peerKey(self);
2473
+ if (key === mine) return;
2474
+ const payload = parsePayload(s.payload);
2475
+ const link = links.get(key);
2476
+ switch (s.kind) {
2477
+ case "hello":
2478
+ if (link && !link.closed) return;
2479
+ if (mine < key) startOffer(ref);
2480
+ else if (s.toPeer === null) send(null, {
2481
+ to: from,
2482
+ toPeer: ref.peer,
2483
+ kind: "hello",
2484
+ payload: null
2485
+ });
2486
+ return;
2487
+ case "offer": {
2488
+ if (mine < key || typeof payload?.sdp !== "string" || typeof payload.sid !== "string") return;
2489
+ const answerer = newLink(ref, false, payload.sid);
2490
+ if (!answerer) return;
2491
+ (async () => {
2492
+ try {
2493
+ await answerer.pc.setRemoteDescription({
2494
+ type: "offer",
2495
+ sdp: payload.sdp
2496
+ });
2497
+ answerer.remoteSet = true;
2498
+ await addIce(answerer, answerer.pendingIce.splice(0));
2499
+ const answer = await answerer.pc.createAnswer();
2500
+ await answerer.pc.setLocalDescription(answer);
2501
+ send(answerer, {
2502
+ to: from,
2503
+ toPeer: ref.peer,
2504
+ kind: "answer",
2505
+ payload: JSON.stringify({
2506
+ sid: answerer.sid,
2507
+ sdp: answerer.pc.localDescription?.sdp ?? answer.sdp
2508
+ })
2509
+ });
2510
+ } catch (error) {
2511
+ log?.debug({
2512
+ error,
2513
+ Category: "sp00ky-client::channel"
2514
+ }, "channel answer failed");
2515
+ dropLink(answerer, true);
2516
+ }
2517
+ })();
2518
+ return;
2519
+ }
2520
+ case "answer":
2521
+ if (!link || !link.offerer || link.remoteSet || payload?.sid !== link.sid || typeof payload.sdp !== "string") return;
2522
+ link.remoteSet = true;
2523
+ link.pc.setRemoteDescription({
2524
+ type: "answer",
2525
+ sdp: payload.sdp
2526
+ }).then(() => addIce(link, link.pendingIce.splice(0))).catch(() => dropLink(link, true));
2527
+ return;
2528
+ case "ice": {
2529
+ if (!link || payload?.sid !== link.sid || !Array.isArray(payload.candidates)) return;
2530
+ const candidates = payload.candidates;
2531
+ if (link.remoteSet) addIce(link, candidates);
2532
+ else link.pendingIce.push(...candidates);
2533
+ return;
2534
+ }
2535
+ }
2536
+ };
2537
+ const ensureListening = () => {
2538
+ const self = hooks.self();
2539
+ const as = self ? peerKey(self) : null;
2540
+ if (as === listeningAs) return;
2541
+ stopListen?.();
2542
+ stopListen = null;
2543
+ listeningAs = as;
2544
+ if (self) stopListen = opts.signal.listen(self, onSignal);
2545
+ };
2546
+ return {
2547
+ supported: PC !== void 0,
2548
+ want(users) {
2549
+ if (closed || !PC) {
2550
+ wanted = new Set(users);
2551
+ return;
2552
+ }
2553
+ const before = wanted;
2554
+ wanted = new Set(users);
2555
+ if (wanted.size > 0) ensureListening();
2556
+ for (const link of [...links.values()]) if (!wanted.has(link.ref.user)) dropLink(link, false);
2557
+ for (const [key, r] of retries) if (!wanted.has(key.slice(0, key.lastIndexOf("#"))) && r.timer) clearTimeout(r.timer);
2558
+ for (const user of wanted) {
2559
+ if (before.has(user)) continue;
2560
+ send(null, {
2561
+ to: user,
2562
+ toPeer: null,
2563
+ kind: "hello",
2564
+ payload: null
2565
+ });
2566
+ hooks.onStatus(user, "connecting");
2567
+ }
2568
+ },
2569
+ send(to, message) {
2570
+ const link = links.get(peerKey(to));
2571
+ if (!link?.open || !link.dc || link.dc.readyState !== "open") return;
2572
+ try {
2573
+ link.dc.send(JSON.stringify(message));
2574
+ } catch (error) {
2575
+ log?.debug({
2576
+ error,
2577
+ Category: "sp00ky-client::channel"
2578
+ }, "channel send failed");
2579
+ }
2580
+ },
2581
+ close() {
2582
+ closed = true;
2583
+ for (const r of retries.values()) if (r.timer) clearTimeout(r.timer);
2584
+ retries.clear();
2585
+ for (const link of [...links.values()]) dropLink(link, false);
2586
+ stopListen?.();
2587
+ stopListen = null;
2588
+ }
2589
+ };
2590
+ }
2591
+ function parsePayload(payload) {
2592
+ if (!payload) return null;
2593
+ try {
2594
+ const v = JSON.parse(payload);
2595
+ return v !== null && typeof v === "object" ? v : null;
2596
+ } catch {
2597
+ return null;
2598
+ }
2599
+ }
2600
+ const SIGNAL_TABLE = "_00_channel_signal";
2601
+ const LISTEN_RETRY_MS = 3e3;
2602
+ /**
2603
+ * Signals as rows of `_00_channel_signal` (generated for schemas with a
2604
+ * `@channel` table): a CREATE to send, one raw `LIVE SELECT` on rows
2605
+ * addressed to this user to receive. Not the outbox and not a synced query:
2606
+ * each signal costs one round trip, which is what keeps connection setup
2607
+ * short. The LIVE is re-armed whenever the socket reconnects.
2608
+ */
2609
+ function surrealSignal(remote, connection, logger) {
2610
+ return {
2611
+ async send(s) {
2612
+ const content = {
2613
+ recipient: parseRecordIdString(s.to),
2614
+ sender_peer: s.fromPeer,
2615
+ kind: s.kind
2616
+ };
2617
+ if (s.toPeer !== null) content.recipient_peer = s.toPeer;
2618
+ if (s.payload !== null) content.payload = s.payload;
2619
+ await remote.query(`CREATE ${SIGNAL_TABLE} CONTENT $c RETURN NONE`, { c: content });
2620
+ },
2621
+ listen(self, cb) {
2622
+ let stopped = false;
2623
+ let uuid = null;
2624
+ let retry = null;
2625
+ let state = null;
2626
+ const arm = async () => {
2627
+ if (stopped) return;
2628
+ if (retry) clearTimeout(retry);
2629
+ retry = null;
2630
+ const old = uuid;
2631
+ uuid = null;
2632
+ if (old) remote.query("KILL $u", { u: old }).catch(() => {});
2633
+ try {
2634
+ const [id] = await remote.query(`LIVE SELECT * FROM ${SIGNAL_TABLE} WHERE recipient = $me`, { me: parseRecordIdString(self.user) });
2635
+ if (stopped) {
2636
+ remote.query("KILL $u", { u: id }).catch(() => {});
2637
+ return;
2638
+ }
2639
+ uuid = id;
2640
+ (await remote.getClient().liveOf(id)).subscribe((message) => {
2641
+ if (stopped || message.action !== "CREATE") return;
2642
+ const v = message.value;
2643
+ const from = idKey(v.sender);
2644
+ if (!from || typeof v.sender_peer !== "string" || typeof v.kind !== "string") return;
2645
+ cb({
2646
+ from,
2647
+ fromPeer: v.sender_peer,
2648
+ toPeer: typeof v.recipient_peer === "string" ? v.recipient_peer : null,
2649
+ kind: v.kind,
2650
+ payload: typeof v.payload === "string" ? v.payload : null
2651
+ });
2652
+ });
2653
+ } catch (error) {
2654
+ logger?.debug({
2655
+ error,
2656
+ Category: "sp00ky-client::channel"
2657
+ }, "channel signal listen failed; retrying");
2658
+ if (!stopped) retry = setTimeout(() => void arm(), LISTEN_RETRY_MS);
2659
+ }
2660
+ };
2661
+ const unsubscribe = connection.subscribe((next) => {
2662
+ const was = state;
2663
+ state = next;
2664
+ if (next === "connected" && was !== null && was !== "connected") arm();
2665
+ });
2666
+ arm();
2667
+ return () => {
2668
+ stopped = true;
2669
+ unsubscribe();
2670
+ if (retry) clearTimeout(retry);
2671
+ if (uuid) remote.query("KILL $u", { u: uuid }).catch(() => {});
2672
+ };
2673
+ }
2674
+ };
2675
+ }
2676
+
1336
2677
  //#endregion
1337
2678
  //#region src/mutation/rows.ts
1338
2679
  const PENDING_TABLE = "_00_pending_mutations";
@@ -1509,6 +2850,29 @@ function planDeleteTx(input) {
1509
2850
  };
1510
2851
  }
1511
2852
  /**
2853
+ * A channel peer's create: the row alone, no outbox entry (the peer's own
2854
+ * client pushes it). Result index 0 is the row.
2855
+ */
2856
+ function planPeerCreateTx(input) {
2857
+ const keys = Object.keys(input.data).map((key) => ({
2858
+ key,
2859
+ variable: `data_${key}`
2860
+ }));
2861
+ const vars = { id: input.recordId };
2862
+ for (const { key, variable } of keys) vars[variable] = input.data[key];
2863
+ return {
2864
+ query: surql.seal(surql.tx([surql.createSet("id", keys)]), { resultIndex: 0 }),
2865
+ vars
2866
+ };
2867
+ }
2868
+ /** Undo a peer's create the server never confirmed: the row alone, no outbox entry. */
2869
+ function planPeerUndoTx(recordId) {
2870
+ return {
2871
+ query: surql.seal(surql.tx([surql.delete("id")])),
2872
+ vars: { id: recordId }
2873
+ };
2874
+ }
2875
+ /**
1512
2876
  * Debounced update: bump `_00_rv` and MERGE now; the outbox row comes later.
1513
2877
  * With a `mirror`, the merged patch so far is written to `_00_pending_writes`
1514
2878
  * in the same transaction, so the row never changes without a durable copy of
@@ -2286,8 +3650,8 @@ function trimHistory(history, cap) {
2286
3650
 
2287
3651
  //#endregion
2288
3652
  //#region src/modules/devtools/index.ts
2289
- const CORE_VERSION = "0.0.1-canary.355";
2290
- const WASM_VERSION = "0.0.1-canary.355";
3653
+ const CORE_VERSION = "0.0.1-canary.357";
3654
+ const WASM_VERSION = "0.0.1-canary.357";
2291
3655
  const SURREAL_VERSION = "3.1.0";
2292
3656
  var DevToolsService = class DevToolsService {
2293
3657
  version = CORE_VERSION;
@@ -3521,6 +4885,25 @@ const outboxReplace = (items) => (s) => {
3521
4885
  for (const table of tables) next = markTableDirty(table)(next);
3522
4886
  return next;
3523
4887
  };
4888
+ /** A channel peer's create or delete is now showing; its table re-renders. */
4889
+ const setPeerWrite = (id, write) => (s) => {
4890
+ const peerWrites = new Map(s.peerWrites);
4891
+ peerWrites.set(id, write);
4892
+ return markTableDirty(write.table)({
4893
+ ...s,
4894
+ peerWrites
4895
+ });
4896
+ };
4897
+ const clearPeerWrite = (id) => (s) => {
4898
+ const write = s.peerWrites.get(id);
4899
+ if (!write) return s;
4900
+ const peerWrites = new Map(s.peerWrites);
4901
+ peerWrites.delete(id);
4902
+ return markTableDirty(write.table)({
4903
+ ...s,
4904
+ peerWrites
4905
+ });
4906
+ };
3524
4907
  const outboxPush = (item) => (s) => markTableDirty(item.table)({
3525
4908
  ...s,
3526
4909
  outbox: [...s.outbox, item]
@@ -3654,6 +5037,7 @@ const clearBucketState = () => (s) => ({
3654
5037
  views: /* @__PURE__ */ new Map(),
3655
5038
  outbox: [],
3656
5039
  pendingWrites: /* @__PURE__ */ new Map(),
5040
+ peerWrites: /* @__PURE__ */ new Map(),
3657
5041
  dirty: /* @__PURE__ */ new Set(),
3658
5042
  membershipDirty: /* @__PURE__ */ new Set(),
3659
5043
  membershipReread: /* @__PURE__ */ new Map(),
@@ -3722,6 +5106,8 @@ function overlay(s) {
3722
5106
  const deletes = /* @__PURE__ */ new Set();
3723
5107
  for (const item of s.outbox) if (item.type === "delete") deletes.add(item.recordId);
3724
5108
  else writes.add(item.recordId);
5109
+ for (const [id, write] of s.peerWrites) if (write.kind === "delete") deletes.add(id);
5110
+ else writes.add(id);
3725
5111
  return {
3726
5112
  writes,
3727
5113
  deletes
@@ -4212,6 +5598,7 @@ function emptyState(init) {
4212
5598
  views: /* @__PURE__ */ new Map(),
4213
5599
  outbox: [],
4214
5600
  pendingWrites: /* @__PURE__ */ new Map(),
5601
+ peerWrites: /* @__PURE__ */ new Map(),
4215
5602
  failedCount: 0,
4216
5603
  dirty: /* @__PURE__ */ new Set(),
4217
5604
  membershipDirty: /* @__PURE__ */ new Set(),
@@ -5868,6 +7255,152 @@ function* adoptPendingWrites() {
5868
7255
  if (adopted > 0) yield fx.dispatch({ type: "Drain" });
5869
7256
  if (recheckAt !== Infinity) yield fx.timer.set("adopt-writes", recheckAt - now, { type: "AdoptPendingWrites" });
5870
7257
  }
7258
+ /** The row as the local store holds it, or null: what a channel checks its filter against. */
7259
+ function* readRow(recordId) {
7260
+ return yield* readBefore(parseRecordIdString(recordId));
7261
+ }
7262
+ /**
7263
+ * A channel peer created a row: put it in the store and the circuit like an
7264
+ * own optimistic create, minus the outbox entry (the peer pushes it), and
7265
+ * show it through the overlay. `_00_rv` stays unset on purpose: the server's
7266
+ * body (with its computed fields) still downloads and replaces this one.
7267
+ * Refuses an id this client already holds a confirmed row or an own pending
7268
+ * write for, so a peer can never overwrite real data. Returns whether it applied.
7269
+ */
7270
+ function* peerCreate(env, input) {
7271
+ const table = extractTablePart(input.recordId);
7272
+ const columns = columnsFor(env, table);
7273
+ if (!columns) return false;
7274
+ const [confirmed, ownPending, peerPending] = yield fx.state.read((s) => [
7275
+ s.versions.has(input.recordId),
7276
+ s.outbox.some((i) => i.recordId === input.recordId),
7277
+ s.peerWrites.has(input.recordId)
7278
+ ]);
7279
+ if (confirmed || ownPending || peerPending) return false;
7280
+ const rid = parseRecordIdString(input.recordId);
7281
+ const existing = yield* readBefore(rid);
7282
+ let record = existing;
7283
+ if (!existing) {
7284
+ const tx = planPeerCreateTx({
7285
+ recordId: rid,
7286
+ data: parseParams(columns, input.data ?? {})
7287
+ });
7288
+ try {
7289
+ const res = yield fx.local.execute(tx.query, tx.vars);
7290
+ record = res && typeof res === "object" && !Array.isArray(res) ? res : null;
7291
+ } catch (error) {
7292
+ yield fx.emit({
7293
+ type: "log",
7294
+ level: "debug",
7295
+ message: "peer create refused by the local store",
7296
+ data: {
7297
+ recordId: input.recordId,
7298
+ error
7299
+ }
7300
+ });
7301
+ return false;
7302
+ }
7303
+ }
7304
+ if (!record) return false;
7305
+ try {
7306
+ yield fx.ssp.ingest([{
7307
+ table,
7308
+ op: "CREATE",
7309
+ id: input.recordId,
7310
+ record: {
7311
+ ...record,
7312
+ _00_rv: 0
7313
+ }
7314
+ }]);
7315
+ } catch (error) {
7316
+ yield fx.emit({
7317
+ type: "log",
7318
+ level: "error",
7319
+ message: "circuit ingest failed after a peer create",
7320
+ data: {
7321
+ recordId: input.recordId,
7322
+ error
7323
+ }
7324
+ });
7325
+ }
7326
+ const now = yield fx.now();
7327
+ yield fx.state.update(setPeerWrite(input.recordId, {
7328
+ kind: "create",
7329
+ table,
7330
+ expiresAt: now + input.ttlMs
7331
+ }));
7332
+ yield fx.timer.set(`peer:${input.recordId}`, input.ttlMs, {
7333
+ type: "PeerWriteExpired",
7334
+ id: input.recordId
7335
+ });
7336
+ return true;
7337
+ }
7338
+ /**
7339
+ * A channel peer deleted a row: hide it through the overlay, keep it in the
7340
+ * store. Undoing is then just forgetting the entry; a confirmed delete has
7341
+ * taken the row out of the server's membership by the time it expires.
7342
+ */
7343
+ function* peerDelete(env, input) {
7344
+ const table = extractTablePart(input.recordId);
7345
+ if (!columnsFor(env, table)) return false;
7346
+ const [known, confirmed] = yield fx.state.read((s) => [s.peerWrites.get(input.recordId), s.versions.has(input.recordId)]);
7347
+ if (known?.kind === "create" && !confirmed) {
7348
+ yield fx.timer.clear(`peer:${input.recordId}`);
7349
+ yield* undoPeerCreate(table, input.recordId);
7350
+ return true;
7351
+ }
7352
+ if (!((yield* readBefore(parseRecordIdString(input.recordId))) !== null)) return false;
7353
+ const now = yield fx.now();
7354
+ yield fx.state.update(setPeerWrite(input.recordId, {
7355
+ kind: "delete",
7356
+ table,
7357
+ expiresAt: now + input.ttlMs
7358
+ }));
7359
+ yield fx.timer.set(`peer:${input.recordId}`, input.ttlMs, {
7360
+ type: "PeerWriteExpired",
7361
+ id: input.recordId
7362
+ });
7363
+ return true;
7364
+ }
7365
+ /** Timer target: a peer's write stops showing as unconfirmed. */
7366
+ function* peerWriteExpired(id) {
7367
+ const [write, confirmed, ownPending] = yield fx.state.read((s) => [
7368
+ s.peerWrites.get(id),
7369
+ s.versions.has(id),
7370
+ s.outbox.some((i) => i.recordId === id)
7371
+ ]);
7372
+ if (!write) return;
7373
+ if (write.kind === "create" && !confirmed && !ownPending) {
7374
+ yield* undoPeerCreate(write.table, id);
7375
+ return;
7376
+ }
7377
+ yield fx.state.update(clearPeerWrite(id));
7378
+ }
7379
+ function* undoPeerCreate(table, id) {
7380
+ const rid = parseRecordIdString(id);
7381
+ const before = yield* readBefore(rid);
7382
+ try {
7383
+ const tx = planPeerUndoTx(rid);
7384
+ yield fx.local.execute(tx.query, tx.vars);
7385
+ if (before) yield fx.ssp.ingest([{
7386
+ table,
7387
+ op: "DELETE",
7388
+ id,
7389
+ record: before
7390
+ }]);
7391
+ } catch (error) {
7392
+ yield fx.emit({
7393
+ type: "log",
7394
+ level: "warn",
7395
+ message: "could not undo an unconfirmed peer create",
7396
+ data: {
7397
+ recordId: id,
7398
+ error
7399
+ }
7400
+ });
7401
+ }
7402
+ yield fx.state.update(clearPeerWrite(id));
7403
+ }
5871
7404
 
5872
7405
  //#endregion
5873
7406
  //#region src/mutation/tray.saga.ts
@@ -6965,7 +8498,10 @@ function* materialize(hash) {
6965
8498
  let rows;
6966
8499
  try {
6967
8500
  rows = rowsFromResult(effect, yield effect);
6968
- if (ids === null) rows = rows.filter((row) => paints(rowKey$1(row)));
8501
+ if (ids === null) rows = rows.filter((row) => {
8502
+ const key = rowKey$1(row);
8503
+ return paints(key) && !(key !== null && ov.deletes.has(key));
8504
+ });
6969
8505
  } catch (error) {
6970
8506
  yield fx.state.update(compose(recordError(hash), clearDirty(hash)));
6971
8507
  yield fx.emit({
@@ -7670,6 +9206,10 @@ function route(env, event) {
7670
9206
  saga: flushWrite(env, event.key),
7671
9207
  lane: serial("outbox-write")
7672
9208
  };
9209
+ case "PeerWriteExpired": return {
9210
+ saga: peerWriteExpired(event.id),
9211
+ lane: serial(`peer:${event.id}`)
9212
+ };
7673
9213
  case "FlushPendingWrites": return { saga: flushPendingWrites() };
7674
9214
  case "AdoptPendingWrites": return {
7675
9215
  saga: adoptPendingWrites(),
@@ -16069,7 +17609,7 @@ function createTabsCoordinator(config, s, host) {
16069
17609
  return new TabsCoordinator({
16070
17610
  tabId: s.tabId,
16071
17611
  fingerprint: computeTabsFingerprint({
16072
- coreVersion: "0.0.1-canary.355",
17612
+ coreVersion: "0.0.1-canary.357",
16073
17613
  schemaHash: hash53(config.schemaSurql),
16074
17614
  endpoint: config.database.endpoint ?? "",
16075
17615
  namespace: config.database.namespace,
@@ -16685,6 +18225,8 @@ var Sp00kyClient = class {
16685
18225
  streamProcessor;
16686
18226
  /** Web Push for this browser: subscribe, devices, self-pushes, service worker bridge. */
16687
18227
  webPush;
18228
+ /** Peer to peer channels of `-- @channel` tables. */
18229
+ channels;
16688
18230
  services;
16689
18231
  env;
16690
18232
  runtime;
@@ -16787,6 +18329,29 @@ var Sp00kyClient = class {
16787
18329
  config: config.webPush
16788
18330
  });
16789
18331
  this.disposers.push(this.webPush.attach());
18332
+ const channelsConfig = config.channels;
18333
+ this.channels = new ChannelManager(config.schema, {
18334
+ config: channelsConfig,
18335
+ logger: s.logger,
18336
+ rows: {
18337
+ create: (_table, id, row, ttlMs) => this.runtime.run(peerCreate(this.env, {
18338
+ recordId: id,
18339
+ data: row,
18340
+ ttlMs
18341
+ })),
18342
+ delete: (_table, id, ttlMs) => this.runtime.run(peerDelete(this.env, {
18343
+ recordId: id,
18344
+ ttlMs
18345
+ })),
18346
+ read: (id) => this.runtime.run(readRow(id))
18347
+ },
18348
+ transport: channelsConfig?.transport ?? ((hooks) => webrtcTransport(hooks, {
18349
+ signal: surrealSignal(s.remote, s.connectionSupervisor, s.logger),
18350
+ iceServers: channelsConfig?.iceServers,
18351
+ logger: s.logger
18352
+ }))
18353
+ });
18354
+ if (this.channels.enabled) this.disposers.push(s.auth.subscribe((userId) => this.channels.setUser(userId)));
16790
18355
  this.devTools = new DevToolsService(s.local, s.remote, s.logger, config.schema, s.auth, {
16791
18356
  getActiveQueries: () => [...this.runtime.state.queries.values()].map(toQueryState),
16792
18357
  getQueryById: (id) => {
@@ -16940,6 +18505,7 @@ var Sp00kyClient = class {
16940
18505
  const s = this.services;
16941
18506
  s.connectionSupervisor.dispose();
16942
18507
  for (const dispose of this.disposers.splice(0)) dispose();
18508
+ this.channels.close();
16943
18509
  this.runtime.dispose();
16944
18510
  await this.featureFlags.closeAll();
16945
18511
  await this.appReleases.closeAll();
@@ -17003,7 +18569,50 @@ var Sp00kyClient = class {
17003
18569
  await this.runtime.run(preload(this.env, this.registerInput(q.tableName, q, "10m")), { signal: options?.signal });
17004
18570
  }
17005
18571
  async subscribe(queryHash, callback, options) {
17006
- return this.runtime.subscribe(queryHash, callback, options);
18572
+ if (!this.channels.enabled) return this.runtime.subscribe(queryHash, callback, options);
18573
+ const tap = this.channels.tap(callback);
18574
+ const stop = this.runtime.subscribe(queryHash, tap.feed, options);
18575
+ return () => {
18576
+ stop();
18577
+ tap.stop();
18578
+ };
18579
+ }
18580
+ /**
18581
+ * Open a peer to peer channel to `peers` (your own id is skipped; a
18582
+ * connection forms only when both sides list each other) for the rows
18583
+ * `queries` match, each a query on a `-- @channel` table. Only its `where`
18584
+ * counts, evaluated against each row; the query is never registered. Writes
18585
+ * to matching rows (plain or the channel's own) reach the peers. Close it
18586
+ * when done.
18587
+ */
18588
+ channel(queries, peers) {
18589
+ const handle = this.channels.open(this.channelScopes(queries), peers);
18590
+ const scoped = (table) => {
18591
+ this.channels.assertCarries(handle, table);
18592
+ return handle;
18593
+ };
18594
+ return {
18595
+ tables: handle.tables,
18596
+ state: handle.state,
18597
+ subscribe: handle.subscribe,
18598
+ setPeers: handle.setPeers,
18599
+ setQueries: (next) => handle.setScopes(this.channelScopes(next)),
18600
+ close: handle.close,
18601
+ update: (table, id, data, options) => this.writeUpdate(table, id, data, options, scoped(table)),
18602
+ create: (id, data) => this.writeCreate(id, data, scoped(id.slice(0, id.indexOf(":")))),
18603
+ delete: (table, id) => this.writeDelete(table, id, scoped(table))
18604
+ };
18605
+ }
18606
+ channelScopes(queries) {
18607
+ return (Array.isArray(queries) ? queries : [queries]).map((q) => {
18608
+ const final = "build" in q && typeof q.build === "function" ? q.build() : q;
18609
+ const plan = final.innerQuery?.selectQuery?.plan;
18610
+ if (!plan) throw new Error("channel(): pass a query, e.g. db.query('message').where({ conversation })");
18611
+ return this.channels.scope(plan, {
18612
+ isOne: final.isOne,
18613
+ ignoreUnused: final.ignoresUnusedArgs
18614
+ });
18615
+ });
17007
18616
  }
17008
18617
  deregisterQuery(queryHash) {
17009
18618
  const entry = this.runtime.state.queries.get(queryHash);
@@ -17023,21 +18632,44 @@ var Sp00kyClient = class {
17023
18632
  if (Number.isFinite(ms)) this.runtime.update(recordPhase(queryHash, "frontend", ms));
17024
18633
  }
17025
18634
  async create(id, data) {
17026
- return (await this.runtime.run(write(this.env, {
17027
- kind: "create",
17028
- recordId: id,
17029
- data
17030
- }))).record ?? data;
18635
+ return await this.writeCreate(id, data);
17031
18636
  }
17032
- async update(_table, id, data, options) {
17033
- return (await this.runtime.run(write(this.env, {
17034
- kind: "update",
17035
- recordId: id,
17036
- data,
17037
- options
17038
- }))).record ?? data;
18637
+ async update(table, id, data, options) {
18638
+ return await this.writeUpdate(table, id, data, options);
18639
+ }
18640
+ async delete(table, id) {
18641
+ await this.writeDelete(table, id);
18642
+ }
18643
+ async writeCreate(id, data, scope) {
18644
+ const table = id.slice(0, id.indexOf(":"));
18645
+ const { persistent, ephemeral } = this.channels.split(table, data);
18646
+ this.channels.localCreate(table, id, data, scope);
18647
+ return {
18648
+ ...(await this.runtime.run(write(this.env, {
18649
+ kind: "create",
18650
+ recordId: id,
18651
+ data: persistent
18652
+ }))).record ?? persistent,
18653
+ ...ephemeral ?? {}
18654
+ };
18655
+ }
18656
+ async writeUpdate(table, id, data, options, scope) {
18657
+ const tableName = table || id.slice(0, id.indexOf(":"));
18658
+ const { persistent, ephemeral } = this.channels.split(tableName, data);
18659
+ this.channels.local(tableName, id, persistent, ephemeral, scope);
18660
+ if (ephemeral && Object.keys(persistent).length === 0) return data;
18661
+ return {
18662
+ ...(await this.runtime.run(write(this.env, {
18663
+ kind: "update",
18664
+ recordId: id,
18665
+ data: persistent,
18666
+ options
18667
+ }))).record ?? persistent,
18668
+ ...ephemeral ?? {}
18669
+ };
17039
18670
  }
17040
- async delete(_table, id) {
18671
+ async writeDelete(table, id, scope) {
18672
+ await this.channels.localDelete(table || id.slice(0, id.indexOf(":")), id, scope);
17041
18673
  await this.runtime.run(write(this.env, {
17042
18674
  kind: "delete",
17043
18675
  recordId: id
@@ -17191,4 +18823,4 @@ var Sp00kyClient = class {
17191
18823
  };
17192
18824
 
17193
18825
  //#endregion
17194
- export { AppReleaseHandle, AppReleaseModule, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DEFAULT_LOCAL_OP_TIMEOUT_MS, FeatureFlagHandle, FeatureFlagModule, IMPERSONATION_ACCESS, ImpersonationTimers, LocalOpTimeoutError, MATERIALIZATION_SAMPLE_WINDOW, ORIGIN_TOKEN_KEY, PUSH_ENDPOINT_KEY, PUSH_PAYLOAD_VERSION, REFUSAL_RECHECK_MIN_MS, SESSION_POLL_MS, SIGN_OUT_HOOK_TIMEOUT_MS, Sp00kyClient, WebPushError, WebPushModule, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, decodeTokenClaims, detectWebPushSupport, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, impersonationFromToken, isBlurhashValid, isImagePath, isPushPayload, renewDelayMs, sameImpersonation, semverGt, textToHtml };
18826
+ export { AppReleaseHandle, AppReleaseModule, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DEFAULT_LOCAL_OP_TIMEOUT_MS, FeatureFlagHandle, FeatureFlagModule, IMPERSONATION_ACCESS, ImpersonationTimers, LocalOpTimeoutError, MATERIALIZATION_SAMPLE_WINDOW, ORIGIN_TOKEN_KEY, PUSH_ENDPOINT_KEY, PUSH_PAYLOAD_VERSION, REFUSAL_RECHECK_MIN_MS, SESSION_POLL_MS, SIGN_OUT_HOOK_TIMEOUT_MS, Sp00kyClient, WebPushError, WebPushModule, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, decodeTokenClaims, detectWebPushSupport, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, impersonationFromToken, isBlurhashValid, isImagePath, isPushPayload, matchesWhere, renewDelayMs, sameImpersonation, semverGt, surrealSignal, textToHtml, unusedArgs, webrtcTransport };