@zeronsh/orbit 0.3.3 → 0.3.5

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.
@@ -407,7 +407,7 @@ var Store = class {
407
407
  // persistence
408
408
  #kv;
409
409
  #dirtyRows = /* @__PURE__ */ new Set();
410
- // "tablepkKey"
410
+ // `${table}\u0000${pkKey}` (NUL separator: cannot appear in a table name)
411
411
  #dirtyPending = /* @__PURE__ */ new Set();
412
412
  #cleared = false;
413
413
  #flushTimer;
@@ -521,6 +521,20 @@ var Store = class {
521
521
  pendingMutations() {
522
522
  return this.#pending.map((p) => p.mutation).filter((m) => m !== void 0);
523
523
  }
524
+ /** Drop a single pending mutation by id — e.g. an undeliverable "poison" mutation
525
+ * the server keeps rejecting (oversized frame). Removes its optimistic overlay and,
526
+ * on the next flush, its persisted `p/` entry, so it is neither replayed on reconnect
527
+ * nor restored on reload. Returns whether such a mutation was present. */
528
+ dropPending(id) {
529
+ const idx = this.#pending.findIndex((p2) => p2.id === id);
530
+ if (idx === -1) return false;
531
+ const [p] = this.#pending.splice(idx, 1);
532
+ this.#dirtyPending.add(p.id);
533
+ for (const op of p.ops) this.#touch(op.tableName, this.#key(op.tableName, op.value));
534
+ this.#scheduleFlush();
535
+ this.#notify();
536
+ return true;
537
+ }
524
538
  /** All rows for `table` with the optimistic overlay applied. */
525
539
  effectiveRows(table2) {
526
540
  const merged = new Map(this.#table(table2));
@@ -592,29 +606,36 @@ var Store = class {
592
606
  }
593
607
  const kv = this.#kv;
594
608
  if (!kv) return;
609
+ const clearOps = [];
595
610
  if (this.#cleared) {
596
- const dels = [];
597
- for (const [k] of await kv.entries("e/")) dels.push(kv.del(k));
598
- await Promise.all(dels);
611
+ for (const [k] of await kv.entries("e/")) clearOps.push({ type: "del", key: k });
599
612
  this.#cleared = false;
600
613
  }
601
- const ps = [];
614
+ const ops = [];
602
615
  for (const dk of this.#dirtyRows) {
603
616
  const sep = dk.indexOf("\0");
604
617
  const table2 = dk.slice(0, sep);
605
618
  const pkKey2 = dk.slice(sep + 1);
606
619
  const row = this.#tables.get(table2)?.get(pkKey2);
607
620
  const key = `e/${table2}/${pkKey2}`;
608
- ps.push(row ? kv.set(key, { t: table2, k: pkKey2, v: row }) : kv.del(key));
621
+ ops.push(row ? { type: "set", key, value: { t: table2, k: pkKey2, v: row } } : { type: "del", key });
609
622
  }
610
623
  this.#dirtyRows.clear();
611
624
  for (const id of this.#dirtyPending) {
612
625
  const p = this.#pending.find((x) => x.id === id);
613
- ps.push(p ? kv.set(`p/${id}`, p) : kv.del(`p/${id}`));
626
+ ops.push(p ? { type: "set", key: `p/${id}`, value: p } : { type: "del", key: `p/${id}` });
614
627
  }
615
628
  this.#dirtyPending.clear();
616
- await Promise.all(ps);
617
- if (this.#cookieDirty && this.#cookie !== void 0) {
629
+ const cookieDirty = this.#cookieDirty && this.#cookie !== void 0;
630
+ if (kv.batch) {
631
+ if (cookieDirty) ops.push({ type: "set", key: "cookie", value: this.#cookie });
632
+ await kv.batch([...clearOps, ...ops]);
633
+ if (cookieDirty) this.#cookieDirty = false;
634
+ return;
635
+ }
636
+ await Promise.all(clearOps.map((o) => kv.del(o.key)));
637
+ await Promise.all(ops.map((o) => o.type === "set" ? kv.set(o.key, o.value) : kv.del(o.key)));
638
+ if (cookieDirty) {
618
639
  await kv.set("cookie", this.#cookie);
619
640
  this.#cookieDirty = false;
620
641
  }
@@ -638,6 +659,22 @@ function typeRank(v) {
638
659
  return 4;
639
660
  }
640
661
  }
662
+ function compareStringsUtf8(a, b) {
663
+ if (a === b) return 0;
664
+ const ia = a[Symbol.iterator]();
665
+ const ib = b[Symbol.iterator]();
666
+ for (; ; ) {
667
+ const na = ia.next();
668
+ const nb = ib.next();
669
+ if (na.done) return nb.done ? 0 : -1;
670
+ if (nb.done) return 1;
671
+ if (na.value !== nb.value) {
672
+ const ca = na.value.codePointAt(0);
673
+ const cb = nb.value.codePointAt(0);
674
+ if (ca !== cb) return ca < cb ? -1 : 1;
675
+ }
676
+ }
677
+ }
641
678
  function compareValues(a, b) {
642
679
  if (isNull(a) && isNull(b)) return 0;
643
680
  if (isNull(a)) return -1;
@@ -645,7 +682,7 @@ function compareValues(a, b) {
645
682
  if (typeof a === typeof b) {
646
683
  if (typeof a === "number") return a < b ? -1 : a > b ? 1 : 0;
647
684
  if (typeof a === "boolean") return a === b ? 0 : a ? 1 : -1;
648
- if (typeof a === "string") return a < b ? -1 : a > b ? 1 : 0;
685
+ if (typeof a === "string") return compareStringsUtf8(a, b);
649
686
  return 0;
650
687
  }
651
688
  return typeRank(a) - typeRank(b);
@@ -1679,6 +1716,8 @@ var Orbit = class {
1679
1716
  #onError;
1680
1717
  /** Whether the id was supplied explicitly (then we never override it from the KV). */
1681
1718
  #idFromOpts;
1719
+ /** Releases the Web-Locks persistence leadership (held by the first tab). */
1720
+ #releasePersistLock;
1682
1721
  constructor(opts) {
1683
1722
  this.#opts = opts;
1684
1723
  this.#schema = opts.schema;
@@ -1797,6 +1836,7 @@ var Orbit = class {
1797
1836
  close() {
1798
1837
  this.#closed = true;
1799
1838
  this.#ws?.close();
1839
+ this.#releasePersistLock?.();
1800
1840
  }
1801
1841
  // --- internals ----------------------------------------------------------
1802
1842
  /** Resolve the client context (a value or sync getter); `{}` when unset. */
@@ -1804,8 +1844,39 @@ var Orbit = class {
1804
1844
  const c = this.#context;
1805
1845
  return typeof c === "function" ? c() : c ?? {};
1806
1846
  }
1847
+ /**
1848
+ * Web-Locks single-writer election for the persisted cache. Two tabs sharing one
1849
+ * IndexedDB (and the restored clientID) would each flush their own dirty rows,
1850
+ * pending mutations, and cookie into the same flat keyspace with no coordination —
1851
+ * last-writer-wins can persist a cookie covering rows only the *other* tab wrote,
1852
+ * and both tabs would drive the same server-side CVR. Only the first tab (the
1853
+ * lock holder) gets persistence; later tabs run memory-only with their own fresh
1854
+ * clientID (a full server sync — correct, just uncached). The lock auto-releases
1855
+ * when the tab dies, so the next reload elects a new leader. Environments without
1856
+ * Web Locks (Node, tests, old browsers) keep today's behavior.
1857
+ */
1858
+ async #acquirePersistLock() {
1859
+ const locks = globalThis.navigator?.locks;
1860
+ if (!locks) return true;
1861
+ return new Promise((resolve2) => {
1862
+ const req = locks.request("zeronsh-orbit-persist", { ifAvailable: true }, (lock) => {
1863
+ if (lock === null) {
1864
+ resolve2(false);
1865
+ return;
1866
+ }
1867
+ resolve2(true);
1868
+ return new Promise((release) => {
1869
+ this.#releasePersistLock = release;
1870
+ });
1871
+ });
1872
+ void Promise.resolve(req).catch(() => resolve2(true));
1873
+ });
1874
+ }
1807
1875
  /** Hydrate persisted state (if any), restore unconfirmed mutations, then connect. */
1808
1876
  async #init() {
1877
+ if (this.#kv && !await this.#acquirePersistLock()) {
1878
+ this.#kv = void 0;
1879
+ }
1809
1880
  if (this.#kv) {
1810
1881
  try {
1811
1882
  await this.#store.hydrate(this.#kv);
@@ -1851,8 +1922,13 @@ var Orbit = class {
1851
1922
  requestID: Math.random().toString(36).slice(2)
1852
1923
  }];
1853
1924
  this.#unconfirmedPushes.set(id, msg);
1854
- void this.#kv?.set("nextMutationID", this.#nextMutationID);
1855
- this.#send(msg);
1925
+ const kv = this.#kv;
1926
+ if (kv) {
1927
+ void kv.set("nextMutationID", this.#nextMutationID).catch(() => {
1928
+ }).then(() => this.#send(msg));
1929
+ } else {
1930
+ this.#send(msg);
1931
+ }
1856
1932
  }
1857
1933
  async #connect() {
1858
1934
  if (this.#closed || this.#connecting) return;
@@ -1874,10 +1950,29 @@ var Orbit = class {
1874
1950
  ws.addEventListener("message", (ev) => {
1875
1951
  this.#onMessage(JSON.parse(ev.data));
1876
1952
  });
1877
- ws.addEventListener("close", () => {
1953
+ ws.addEventListener("close", (ev) => {
1878
1954
  this.#connecting = false;
1879
1955
  if (this.#ws === ws) this.#ws = void 0;
1880
1956
  this.#poke = null;
1957
+ if (ev.code === 1009 && this.#unconfirmedPushes.size > 0) {
1958
+ let poisonId;
1959
+ let maxSize = -1;
1960
+ for (const [id, msg] of this.#unconfirmedPushes) {
1961
+ const size = JSON.stringify(msg).length;
1962
+ if (size > maxSize) {
1963
+ maxSize = size;
1964
+ poisonId = id;
1965
+ }
1966
+ }
1967
+ if (poisonId !== void 0) {
1968
+ this.#unconfirmedPushes.delete(poisonId);
1969
+ this.#store.dropPending(poisonId);
1970
+ this.#onError?.({
1971
+ kind: "mutation-too-large",
1972
+ message: `dropped mutation ${poisonId} (~${maxSize} bytes): server closed with code 1009 (message too big)`
1973
+ });
1974
+ }
1975
+ }
1881
1976
  this.#scheduleReconnect();
1882
1977
  });
1883
1978
  ws.addEventListener("error", () => ws.close());
@@ -1955,6 +2050,12 @@ var MemoryKV = class {
1955
2050
  async entries(prefix) {
1956
2051
  return [...this.#m].filter(([k]) => k.startsWith(prefix));
1957
2052
  }
2053
+ async batch(ops) {
2054
+ for (const op of ops) {
2055
+ if (op.type === "set") this.#m.set(op.key, op.value);
2056
+ else this.#m.delete(op.key);
2057
+ }
2058
+ }
1958
2059
  };
1959
2060
  function wrap(req) {
1960
2061
  return new Promise((resolve2, reject) => {
@@ -1987,16 +2088,33 @@ var IDBKV = class {
1987
2088
  }
1988
2089
  async entries(prefix) {
1989
2090
  const s = await this.#store("readonly");
1990
- const [keys, vals] = await Promise.all([wrap(s.getAllKeys()), wrap(s.getAll())]);
1991
- const out = [];
1992
- for (let i = 0; i < keys.length; i++) {
1993
- const k = String(keys[i]);
1994
- if (k.startsWith(prefix)) out.push([k, vals[i]]);
1995
- }
1996
- return out;
2091
+ const successor = prefix.slice(0, -1) + String.fromCharCode(prefix.charCodeAt(prefix.length - 1) + 1);
2092
+ const range = IDBKeyRange.bound(prefix, successor, false, true);
2093
+ const [keys, vals] = await Promise.all([wrap(s.getAllKeys(range)), wrap(s.getAll(range))]);
2094
+ return keys.map((k, i) => [String(k), vals[i]]);
2095
+ }
2096
+ /**
2097
+ * All ops in ONE readwrite transaction: atomic under a crash (IndexedDB
2098
+ * transactions are all-or-nothing) and one commit instead of one per key —
2099
+ * the flush of a large poke goes from N transactions to 1.
2100
+ */
2101
+ async batch(ops) {
2102
+ if (ops.length === 0) return;
2103
+ const db = await this.#dbp;
2104
+ await new Promise((resolve2, reject) => {
2105
+ const tx = db.transaction("kv", "readwrite");
2106
+ const s = tx.objectStore("kv");
2107
+ for (const op of ops) {
2108
+ if (op.type === "set") s.put(op.value, op.key);
2109
+ else s.delete(op.key);
2110
+ }
2111
+ tx.oncomplete = () => resolve2();
2112
+ tx.onerror = () => reject(tx.error);
2113
+ tx.onabort = () => reject(tx.error);
2114
+ });
1997
2115
  }
1998
2116
  };
1999
2117
 
2000
2118
  export { IDBKV, MaterializedView, MemoryKV, MemorySource, MemorySourceProvider, Orbit, PROTOCOL_VERSION, Query, QueryManager, SchemaQuery, SourceConnection, Store, StoreProvider, TypedQuery, View, boolean, buildPipeline, buildSchemaQueries, collectOps, compareValues, createBuilder, createOrbitApi, createSchema, defineMutation, defineQuery, evaluate, hashAST, hashString, json, nodeToRow, number, optional, parseTTL, relationships, string, table, tablesOf, unwrapSingular, validateArgs, valuesEqual };
2001
- //# sourceMappingURL=chunk-BJPEQCCN.js.map
2002
- //# sourceMappingURL=chunk-BJPEQCCN.js.map
2119
+ //# sourceMappingURL=chunk-B575ZNM2.js.map
2120
+ //# sourceMappingURL=chunk-B575ZNM2.js.map