@spooky-sync/core 0.0.1-canary.162 → 0.0.1-canary.164

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
@@ -1081,8 +1081,12 @@ var ConnectionSupervisor = class ConnectionSupervisor {
1081
1081
  disposed = false;
1082
1082
  heartbeatTimer = null;
1083
1083
  heartbeatInFlight = false;
1084
+ /** Consecutive failed probes. See {@link FAILURES_BEFORE_TEARDOWN}. */
1085
+ heartbeatFailures = 0;
1084
1086
  reviveTimer = null;
1085
1087
  reviveAttempts = 0;
1088
+ /** Timestamp of the last wake-triggered probe, for rate limiting. */
1089
+ lastWakeProbeAt = 0;
1086
1090
  reviving = false;
1087
1091
  /**
1088
1092
  * Set while the browser reports itself offline. Retrying a socket against a
@@ -1091,6 +1095,23 @@ var ConnectionSupervisor = class ConnectionSupervisor {
1091
1095
  suspended = false;
1092
1096
  teardown = [];
1093
1097
  static REVIVE_BASE_MS = 1e3;
1098
+ /**
1099
+ * How many consecutive heartbeat failures it takes to tear the socket down.
1100
+ *
1101
+ * The probe rides the same serialized queue as every other RPC (deliberately
1102
+ * — see {@link beat}), which means it cannot distinguish a WEDGED queue from
1103
+ * a merely BUSY one. A single slow window (a large sync burst, one heavy
1104
+ * app query) used to be enough to force-close a perfectly healthy socket,
1105
+ * and the resulting reconnect re-registered every active query about a
1106
+ * second later. That self-inflicted teardown manufactured the very reconnect
1107
+ * storms this class exists to survive. A genuinely dead socket still fails
1108
+ * every probe, so it is torn down one interval later than before.
1109
+ */
1110
+ static FAILURES_BEFORE_TEARDOWN = 2;
1111
+ /** Retry delay after an inconclusive (first) heartbeat failure. */
1112
+ static HEARTBEAT_RETRY_MS = 5e3;
1113
+ /** Floor between probes triggered by wake events (tab focus, pageshow). */
1114
+ static WAKE_PROBE_MIN_INTERVAL_MS = 1e4;
1094
1115
  constructor(remote, logger, config) {
1095
1116
  this.remote = remote;
1096
1117
  this.logger = logger.child({ service: "ConnectionSupervisor" });
@@ -1244,12 +1265,26 @@ var ConnectionSupervisor = class ConnectionSupervisor {
1244
1265
  this.heartbeatInFlight = true;
1245
1266
  try {
1246
1267
  await withTimeout(this.remote.query("RETURN true"), this.config.heartbeatTimeoutMs, `Heartbeat timed out after ${this.config.heartbeatTimeoutMs}ms`);
1268
+ this.heartbeatFailures = 0;
1247
1269
  this.startHeartbeat();
1248
1270
  } catch (err) {
1271
+ this.heartbeatFailures++;
1272
+ if (this.heartbeatFailures < ConnectionSupervisor.FAILURES_BEFORE_TEARDOWN) {
1273
+ this.logger.debug({
1274
+ err,
1275
+ failures: this.heartbeatFailures,
1276
+ Category: "sp00ky-client::ConnectionSupervisor::heartbeat"
1277
+ }, "Heartbeat failed; re-probing before tearing the socket down");
1278
+ this.stopHeartbeat();
1279
+ if (!this.disposed && !this.suspended) this.heartbeatTimer = setTimeout(() => void this.beat(), Math.min(ConnectionSupervisor.HEARTBEAT_RETRY_MS, this.config.heartbeatIntervalMs));
1280
+ return;
1281
+ }
1249
1282
  this.logger.warn({
1250
1283
  err,
1284
+ failures: this.heartbeatFailures,
1251
1285
  Category: "sp00ky-client::ConnectionSupervisor::heartbeat"
1252
- }, "Heartbeat failed; tearing the socket down to force a reconnect");
1286
+ }, "Heartbeat failed repeatedly; tearing the socket down to force a reconnect");
1287
+ this.heartbeatFailures = 0;
1253
1288
  await this.remote.forceClose();
1254
1289
  if (this.remote.getStatus() !== "connected") this.scheduleRevive();
1255
1290
  } finally {
@@ -1297,6 +1332,9 @@ var ConnectionSupervisor = class ConnectionSupervisor {
1297
1332
  */
1298
1333
  wake(reason) {
1299
1334
  if (this.disposed || this.suspended) return;
1335
+ const now = Date.now();
1336
+ if (this.remote.getStatus() === "connected" && now - this.lastWakeProbeAt < ConnectionSupervisor.WAKE_PROBE_MIN_INTERVAL_MS) return;
1337
+ this.lastWakeProbeAt = now;
1300
1338
  this.logger.debug({
1301
1339
  reason,
1302
1340
  state: this.state,
@@ -1353,7 +1391,7 @@ var LocalMigrator = class {
1353
1391
  return;
1354
1392
  }
1355
1393
  await this.recreateDatabase(database);
1356
- const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_preload SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_window SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n ";
1394
+ const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_preload SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_window SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_blob SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n ";
1357
1395
  const statements = this.splitStatements(fullSchema);
1358
1396
  for (let i = 0; i < statements.length; i++) {
1359
1397
  const statement = statements[i];
@@ -2025,6 +2063,7 @@ const SYSTEM_TABLES = [
2025
2063
  "_00_window",
2026
2064
  "_00_schema",
2027
2065
  "_00_pending_mutations",
2066
+ "_00_blob",
2028
2067
  "_00_user_feature",
2029
2068
  "_00_app_release"
2030
2069
  ];
@@ -5276,6 +5315,14 @@ var Sp00kySync = class Sp00kySync {
5276
5315
  * initial connect. See {@link subscribeToReconnect}.
5277
5316
  */
5278
5317
  needsResubscribe = false;
5318
+ /** When the last reconnect-driven full refetch ran, for burst coalescing. */
5319
+ lastReconnectRefetchAt = 0;
5320
+ /**
5321
+ * Minimum gap between reconnect-driven full refetches. Long enough to absorb
5322
+ * a flapping socket (the SDK reconnect ladder starts at 1s), short enough
5323
+ * that a genuine drop minutes later still refetches.
5324
+ */
5325
+ static RECONNECT_REFETCH_COOLDOWN_MS = 1e4;
5279
5326
  events = createSyncEventSystem();
5280
5327
  currentUserId = null;
5281
5328
  tabRole = "solo";
@@ -5901,8 +5948,21 @@ var Sp00kySync = class Sp00kySync {
5901
5948
  client.subscribe("connected", () => {
5902
5949
  if (!this.needsResubscribe) return;
5903
5950
  this.needsResubscribe = false;
5904
- this.logger.info({ Category: "sp00ky-client::Sp00kySync::onReconnect" }, "Remote reconnected, refetching active queries");
5905
- for (const hash of this.dataModule.getActiveQueryHashes()) this.scheduler.enqueueDownEvent({
5951
+ const sinceLast = Date.now() - this.lastReconnectRefetchAt;
5952
+ if (sinceLast < Sp00kySync.RECONNECT_REFETCH_COOLDOWN_MS) {
5953
+ this.logger.debug({
5954
+ sinceLast,
5955
+ Category: "sp00ky-client::Sp00kySync::onReconnect"
5956
+ }, "Reconnected again within the cooldown; skipping duplicate refetch");
5957
+ return;
5958
+ }
5959
+ this.lastReconnectRefetchAt = Date.now();
5960
+ const hashes = this.dataModule.getActiveQueryHashes();
5961
+ this.logger.info({
5962
+ queries: hashes.length,
5963
+ Category: "sp00ky-client::Sp00kySync::onReconnect"
5964
+ }, "Remote reconnected, refetching active queries");
5965
+ for (const hash of hashes) this.scheduler.enqueueDownEvent({
5906
5966
  type: "register",
5907
5967
  payload: { hash }
5908
5968
  });
@@ -6483,8 +6543,8 @@ async function walkOpfs(maxEntries = 2e3, maxDepth = 8) {
6483
6543
 
6484
6544
  //#endregion
6485
6545
  //#region src/modules/devtools/index.ts
6486
- const CORE_VERSION = "0.0.1-canary.162";
6487
- const WASM_VERSION = "0.0.1-canary.162";
6546
+ const CORE_VERSION = "0.0.1-canary.164";
6547
+ const WASM_VERSION = "0.0.1-canary.164";
6488
6548
  const SURREAL_VERSION = "3.0.3";
6489
6549
  var DevToolsService = class DevToolsService {
6490
6550
  eventsHistory = [];
@@ -6501,6 +6561,11 @@ var DevToolsService = class DevToolsService {
6501
6561
  setTabsInfoProvider(provider) {
6502
6562
  this.tabsInfoProvider = provider;
6503
6563
  }
6564
+ /** Blob cache counters for the panel, wired by Sp00kyClient. */
6565
+ blobInfoProvider = null;
6566
+ setBlobInfoProvider(provider) {
6567
+ this.blobInfoProvider = provider;
6568
+ }
6504
6569
  localTables = [];
6505
6570
  localTablesFetching = false;
6506
6571
  localTablesAt = 0;
@@ -6740,6 +6805,14 @@ var DevToolsService = class DevToolsService {
6740
6805
  info.browser.error = e instanceof Error ? e.message : String(e);
6741
6806
  }
6742
6807
  info.opfs = await walkOpfs();
6808
+ try {
6809
+ info.blobs = this.blobInfoProvider?.();
6810
+ } catch (e) {
6811
+ this.logger.warn({
6812
+ err: e,
6813
+ Category: "sp00ky-client::DevToolsService::getStorageInfo"
6814
+ }, "Blob cache diagnostics failed");
6815
+ }
6743
6816
  const stats = globalThis.__sqliteStats;
6744
6817
  if (stats && typeof stats === "object") info.sqliteStats = {
6745
6818
  ...stats,
@@ -9579,22 +9652,1022 @@ var TabsCoordinator = class {
9579
9652
  }
9580
9653
  };
9581
9654
 
9655
+ //#endregion
9656
+ //#region src/services/blobs/blob-store.ts
9657
+ const BLOB_ROOT_DIR = "sp00ky-blobs";
9658
+ /**
9659
+ * Marks a half-written file. Committed names can never contain a literal `.`
9660
+ * (see {@link encodeSegment}), so this suffix is unambiguous — anything wearing
9661
+ * it at walk time is a write that died before commit, and is swept.
9662
+ */
9663
+ const PART_MARKER = ".part-";
9664
+ /** OPFS names are capped around 255 bytes; stay well clear. */
9665
+ const MAX_SEGMENT_LENGTH = 200;
9666
+ /** Walk guards, mirroring `walkOpfs` in the DevTools storage report. */
9667
+ const MAX_WALK_DEPTH = 12;
9668
+ const MAX_WALK_ENTRIES = 5e3;
9669
+ /** A path segment we refuse to store — the caller degrades to no persistence. */
9670
+ var BlobKeyError = class extends Error {
9671
+ constructor(message) {
9672
+ super(message);
9673
+ this.name = "BlobKeyError";
9674
+ }
9675
+ };
9676
+ /**
9677
+ * Percent-encode a path segment, and escape `.` on top of that. Escaping the
9678
+ * dot is what makes {@link PART_MARKER} safe: a real file called `x.part-1`
9679
+ * would otherwise be swept as a torn write on the next boot.
9680
+ */
9681
+ function encodeSegment(segment) {
9682
+ const encoded = encodeURIComponent(segment).replace(/\./g, "%2E");
9683
+ if (encoded.length > MAX_SEGMENT_LENGTH) throw new BlobKeyError(`path segment too long to store (${encoded.length} > ${MAX_SEGMENT_LENGTH})`);
9684
+ return encoded;
9685
+ }
9686
+ function decodeSegment(segment) {
9687
+ return decodeURIComponent(segment);
9688
+ }
9689
+ /** Path segments, `..`/`.`/empty stripped so a crafted path can't escape the root. */
9690
+ function pathSegments(path) {
9691
+ return path.split("/").filter((s) => s.length > 0 && s !== "." && s !== "..");
9692
+ }
9693
+ /** `<bucket>/<...path>` — the directory chain plus filename for a key. */
9694
+ function keySegments(key) {
9695
+ const parts = pathSegments(key.path);
9696
+ if (parts.length === 0) throw new BlobKeyError(`empty file path for bucket "${key.bucket}"`);
9697
+ return [key.bucket, ...parts].map(encodeSegment);
9698
+ }
9699
+ /** Stable manifest row id. Mirrors the on-disk layout, decoded. */
9700
+ function blobKeyId(key) {
9701
+ return `${key.bucket}/${pathSegments(key.path).join("/")}`;
9702
+ }
9703
+ /** Whether OPFS is usable for read AND write in this environment. Writes need
9704
+ * `createWritable`, which arrived late in Safari; without it we run
9705
+ * memory-only rather than dragging in a sync-access-handle worker. */
9706
+ function opfsWritableSupported() {
9707
+ if (typeof navigator === "undefined" || !navigator.storage?.getDirectory) return false;
9708
+ return typeof (globalThis.FileSystemFileHandle?.prototype)?.createWritable === "function";
9709
+ }
9710
+ function supportsHandleMove() {
9711
+ return typeof (globalThis.FileSystemFileHandle?.prototype)?.move === "function";
9712
+ }
9713
+ function isNotFound(err) {
9714
+ return err instanceof Error && err.name === "NotFoundError";
9715
+ }
9716
+ /** OPFS-backed byte storage. All calls are async — nothing blocks the main thread. */
9717
+ var OpfsBlobStore = class {
9718
+ persistent = true;
9719
+ ns;
9720
+ useTempCommit = supportsHandleMove();
9721
+ tempCounter = 0;
9722
+ tempToken = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID().slice(0, 8) : "tmp";
9723
+ constructor(namespace) {
9724
+ this.ns = namespace;
9725
+ }
9726
+ get namespace() {
9727
+ return this.ns;
9728
+ }
9729
+ setNamespace(namespace) {
9730
+ this.ns = namespace;
9731
+ }
9732
+ async root(create) {
9733
+ const opfs = await navigator.storage.getDirectory();
9734
+ return this.resolveDir(opfs, [BLOB_ROOT_DIR, encodeSegment(this.ns)], create);
9735
+ }
9736
+ async resolveDir(from, segments, create) {
9737
+ let dir = from;
9738
+ for (const segment of segments) try {
9739
+ dir = await dir.getDirectoryHandle(segment, { create });
9740
+ } catch (err) {
9741
+ if (!create && isNotFound(err)) return null;
9742
+ throw err;
9743
+ }
9744
+ return dir;
9745
+ }
9746
+ async read(key) {
9747
+ const segments = keySegments(key);
9748
+ const root = await this.root(false);
9749
+ if (!root) return null;
9750
+ const dir = await this.resolveDir(root, segments.slice(0, -1), false);
9751
+ if (!dir) return null;
9752
+ try {
9753
+ return await (await dir.getFileHandle(segments[segments.length - 1])).getFile();
9754
+ } catch (err) {
9755
+ if (isNotFound(err)) return null;
9756
+ throw err;
9757
+ }
9758
+ }
9759
+ async write(key, bytes) {
9760
+ const segments = keySegments(key);
9761
+ const root = await this.root(true);
9762
+ if (!root) throw new Error("OPFS root unavailable");
9763
+ const dir = await this.resolveDir(root, segments.slice(0, -1), true);
9764
+ const name = segments[segments.length - 1];
9765
+ const target = this.useTempCommit ? `${name}${PART_MARKER}${this.tempToken}-${this.tempCounter++}` : name;
9766
+ const handle = await dir.getFileHandle(target, { create: true });
9767
+ const writable = await handle.createWritable();
9768
+ try {
9769
+ await writable.write(bytes);
9770
+ await writable.close();
9771
+ } catch (err) {
9772
+ await writable.abort?.().catch(() => {});
9773
+ await dir.removeEntry(target).catch(() => {});
9774
+ throw err;
9775
+ }
9776
+ if (this.useTempCommit) await handle.move(name);
9777
+ return bytes.size;
9778
+ }
9779
+ async remove(key) {
9780
+ const segments = keySegments(key);
9781
+ const root = await this.root(false);
9782
+ if (!root) return;
9783
+ const dir = await this.resolveDir(root, segments.slice(0, -1), false);
9784
+ if (!dir) return;
9785
+ try {
9786
+ await dir.removeEntry(segments[segments.length - 1]);
9787
+ } catch (err) {
9788
+ if (!isNotFound(err)) throw err;
9789
+ }
9790
+ }
9791
+ async list() {
9792
+ const root = await this.root(false);
9793
+ if (!root) return [];
9794
+ const out = [];
9795
+ await this.walk(root, [], out, 0);
9796
+ return out;
9797
+ }
9798
+ async walk(dir, trail, out, depth) {
9799
+ if (depth > MAX_WALK_DEPTH || out.length >= MAX_WALK_ENTRIES) return;
9800
+ for await (const [name, handle] of dir.entries()) {
9801
+ if (out.length >= MAX_WALK_ENTRIES) return;
9802
+ if (handle.kind === "directory") {
9803
+ await this.walk(handle, [...trail, name], out, depth + 1);
9804
+ continue;
9805
+ }
9806
+ if (name.includes(PART_MARKER)) {
9807
+ await dir.removeEntry(name).catch(() => {});
9808
+ continue;
9809
+ }
9810
+ if (trail.length === 0) continue;
9811
+ try {
9812
+ const file = await handle.getFile();
9813
+ out.push({
9814
+ key: {
9815
+ bucket: decodeSegment(trail[0]),
9816
+ path: [...trail.slice(1), name].map(decodeSegment).join("/")
9817
+ },
9818
+ size: file.size,
9819
+ mtime: file.lastModified
9820
+ });
9821
+ } catch {}
9822
+ }
9823
+ }
9824
+ async clear() {
9825
+ const opfs = await navigator.storage.getDirectory();
9826
+ const root = await this.resolveDir(opfs, [BLOB_ROOT_DIR], false);
9827
+ if (!root) return;
9828
+ try {
9829
+ await root.removeEntry(encodeSegment(this.ns), { recursive: true });
9830
+ } catch (err) {
9831
+ if (!isNotFound(err)) throw err;
9832
+ }
9833
+ }
9834
+ };
9835
+ /**
9836
+ * In-memory byte store. Used when OPFS is unavailable (the cache still dedupes
9837
+ * and serves within the tab, matching the pre-existing behaviour) and as the
9838
+ * test double for {@link BlobCache} — `blob-cache.test.ts` runs in node.
9839
+ */
9840
+ var MemoryBlobStore = class {
9841
+ persistent = false;
9842
+ ns;
9843
+ files = /* @__PURE__ */ new Map();
9844
+ clock = 0;
9845
+ constructor(namespace = "anon") {
9846
+ this.ns = namespace;
9847
+ }
9848
+ get namespace() {
9849
+ return this.ns;
9850
+ }
9851
+ setNamespace(namespace) {
9852
+ this.ns = namespace;
9853
+ }
9854
+ bucketFiles() {
9855
+ let ns = this.files.get(this.ns);
9856
+ if (!ns) {
9857
+ ns = /* @__PURE__ */ new Map();
9858
+ this.files.set(this.ns, ns);
9859
+ }
9860
+ return ns;
9861
+ }
9862
+ async read(key) {
9863
+ keySegments(key);
9864
+ return this.bucketFiles().get(blobKeyId(key))?.blob ?? null;
9865
+ }
9866
+ async write(key, bytes) {
9867
+ keySegments(key);
9868
+ this.bucketFiles().set(blobKeyId(key), {
9869
+ blob: bytes,
9870
+ mtime: ++this.clock
9871
+ });
9872
+ return bytes.size;
9873
+ }
9874
+ async remove(key) {
9875
+ this.bucketFiles().delete(blobKeyId(key));
9876
+ }
9877
+ async list() {
9878
+ const out = [];
9879
+ for (const [id, entry] of this.bucketFiles()) {
9880
+ const slash = id.indexOf("/");
9881
+ out.push({
9882
+ key: {
9883
+ bucket: id.slice(0, slash),
9884
+ path: id.slice(slash + 1)
9885
+ },
9886
+ size: entry.blob.size,
9887
+ mtime: entry.mtime
9888
+ });
9889
+ }
9890
+ return out;
9891
+ }
9892
+ async clear() {
9893
+ this.files.delete(this.ns);
9894
+ }
9895
+ };
9896
+
9897
+ //#endregion
9898
+ //#region src/services/blobs/blob-manifest.ts
9899
+ /**
9900
+ * Metadata index for the blob cache, persisted in the `_00_blob` table of the
9901
+ * local engine.
9902
+ *
9903
+ * The manifest is deliberately NOT the source of truth for existence — OPFS is.
9904
+ * Several existing recovery paths destroy the local store while leaving OPFS
9905
+ * intact (the SQLite leader's wipe-on-pool-open, the memory fallback when OPFS
9906
+ * refuses to open for SQLite, IndexedDB corruption recovery on the SurrealDB
9907
+ * engine). Treating the manifest as an index that `reconcile()` can rebuild
9908
+ * from a directory walk means those paths cost metadata, not the offline cache.
9909
+ *
9910
+ * Runtime reads hit the in-memory map; writes are batched. `lastAccess` changes
9911
+ * on every single cache hit, so flushing each one would turn an image render
9912
+ * into a local-DB write.
9913
+ */
9914
+ const BLOB_TABLE = "_00_blob";
9915
+ function rowToEntry(id, row) {
9916
+ const bucket = typeof row.bucket === "string" ? row.bucket : "";
9917
+ const path = typeof row.path === "string" ? row.path : "";
9918
+ if (!bucket || !path) return null;
9919
+ return {
9920
+ id,
9921
+ bucket,
9922
+ path,
9923
+ size: Number(row.size) || 0,
9924
+ contentType: typeof row.contentType === "string" ? row.contentType : "",
9925
+ createdAt: Number(row.createdAt) || 0,
9926
+ lastAccess: Number(row.lastAccess) || 0,
9927
+ hits: Number(row.hits) || 0,
9928
+ pinned: row.pinned === true
9929
+ };
9930
+ }
9931
+ function entryToRow(entry) {
9932
+ return {
9933
+ bucket: entry.bucket,
9934
+ path: entry.path,
9935
+ size: entry.size,
9936
+ contentType: entry.contentType,
9937
+ createdAt: entry.createdAt,
9938
+ lastAccess: entry.lastAccess,
9939
+ hits: entry.hits,
9940
+ pinned: entry.pinned
9941
+ };
9942
+ }
9943
+ var BlobManifest = class {
9944
+ entries = /* @__PURE__ */ new Map();
9945
+ /** Ids whose in-memory state has not been written back yet. */
9946
+ dirty = /* @__PURE__ */ new Set();
9947
+ removed = /* @__PURE__ */ new Set();
9948
+ flushing = null;
9949
+ constructor(local) {
9950
+ this.local = local;
9951
+ }
9952
+ /**
9953
+ * Hydrate from the rows matching `keys`. Ids come from the OPFS listing, so
9954
+ * this never needs a full-table scan (and therefore never needs a QueryPlan).
9955
+ * Any read failure yields an empty manifest: reconcile then rebuilds every
9956
+ * row from disk, which is exactly the desired degradation.
9957
+ */
9958
+ async load(ids) {
9959
+ this.entries.clear();
9960
+ this.dirty.clear();
9961
+ this.removed.clear();
9962
+ if (ids.length === 0) return;
9963
+ let rows = [];
9964
+ try {
9965
+ rows = await this.local.selectByIds(BLOB_TABLE, ids.map((id) => new RecordId(BLOB_TABLE, id)));
9966
+ } catch {
9967
+ return;
9968
+ }
9969
+ for (const row of rows) {
9970
+ const id = readRowId(row);
9971
+ if (!id) continue;
9972
+ const entry = rowToEntry(id, row);
9973
+ if (entry) this.entries.set(id, entry);
9974
+ }
9975
+ }
9976
+ get(key) {
9977
+ return this.entries.get(blobKeyId(key));
9978
+ }
9979
+ getById(id) {
9980
+ return this.entries.get(id);
9981
+ }
9982
+ all() {
9983
+ return [...this.entries.values()];
9984
+ }
9985
+ totalBytes() {
9986
+ let total = 0;
9987
+ for (const entry of this.entries.values()) total += entry.size;
9988
+ return total;
9989
+ }
9990
+ pinnedBytes() {
9991
+ let total = 0;
9992
+ for (const entry of this.entries.values()) if (entry.pinned) total += entry.size;
9993
+ return total;
9994
+ }
9995
+ put(entry) {
9996
+ this.entries.set(entry.id, entry);
9997
+ this.removed.delete(entry.id);
9998
+ this.dirty.add(entry.id);
9999
+ }
10000
+ touch(id, now) {
10001
+ const entry = this.entries.get(id);
10002
+ if (!entry) return;
10003
+ entry.lastAccess = now;
10004
+ entry.hits += 1;
10005
+ this.dirty.add(id);
10006
+ }
10007
+ setPinned(id, pinned) {
10008
+ const entry = this.entries.get(id);
10009
+ if (!entry || entry.pinned === pinned) return false;
10010
+ entry.pinned = pinned;
10011
+ this.dirty.add(id);
10012
+ return true;
10013
+ }
10014
+ remove(id) {
10015
+ if (!this.entries.delete(id)) return;
10016
+ this.dirty.delete(id);
10017
+ this.removed.add(id);
10018
+ }
10019
+ /** Forget everything without scheduling deletes — for a bucket switch, where
10020
+ * the rows belong to the store we are leaving and must stay put. */
10021
+ reset() {
10022
+ this.entries.clear();
10023
+ this.dirty.clear();
10024
+ this.removed.clear();
10025
+ }
10026
+ hasPendingWrites() {
10027
+ return this.dirty.size > 0 || this.removed.size > 0;
10028
+ }
10029
+ /**
10030
+ * Write back pending changes. Serialized: a second concurrent flush awaits
10031
+ * the first rather than racing it into the same rows. Failures are swallowed
10032
+ * on purpose — a lost metadata write costs an LRU timestamp, and the entry is
10033
+ * rebuilt from disk on the next reconcile.
10034
+ */
10035
+ async flush() {
10036
+ if (this.flushing) return this.flushing;
10037
+ if (!this.hasPendingWrites()) return;
10038
+ const run = this.doFlush();
10039
+ this.flushing = run;
10040
+ try {
10041
+ await run;
10042
+ } finally {
10043
+ this.flushing = null;
10044
+ }
10045
+ }
10046
+ async doFlush() {
10047
+ const dirty = [...this.dirty];
10048
+ const removed = [...this.removed];
10049
+ this.dirty.clear();
10050
+ this.removed.clear();
10051
+ for (const id of dirty) {
10052
+ const entry = this.entries.get(id);
10053
+ if (!entry) continue;
10054
+ try {
10055
+ await this.local.upsert(BLOB_TABLE, new RecordId(BLOB_TABLE, id), entryToRow(entry), "replace");
10056
+ } catch {}
10057
+ }
10058
+ for (const id of removed) try {
10059
+ await this.local.delete(BLOB_TABLE, new RecordId(BLOB_TABLE, id));
10060
+ } catch {}
10061
+ }
10062
+ };
10063
+ /** Row ids come back as a `RecordId` (SurrealDB) or a `table:id` string (SQLite). */
10064
+ function readRowId(row) {
10065
+ const raw = row.id;
10066
+ if (raw instanceof RecordId) return String(raw.id);
10067
+ if (typeof raw === "string") {
10068
+ const prefix = `${BLOB_TABLE}:`;
10069
+ return raw.startsWith(prefix) ? raw.slice(prefix.length) : raw;
10070
+ }
10071
+ return null;
10072
+ }
10073
+
10074
+ //#endregion
10075
+ //#region src/services/blobs/blob-cache.ts
10076
+ /**
10077
+ * Three-layer cache for bucket files.
10078
+ *
10079
+ * L0 object URLs, refcounted, per tab (this class)
10080
+ * L1 bytes in OPFS, durable (BlobStore)
10081
+ * L2 the remote bucket over the sync WS (injected `fetchRemote`)
10082
+ *
10083
+ * Nothing is ever dropped because it got old. A TTL would silently break the
10084
+ * offline case this cache exists to serve: the row referencing an image is in
10085
+ * the local cache indefinitely, so the image has to be too. Bytes only ever go
10086
+ * away for one of four reasons:
10087
+ *
10088
+ * 1. the app invalidated them — `bucket.put()`/`bucket.delete()` on that path;
10089
+ * 2. boot reconcile found no file behind the row (or a torn `.part-` write);
10090
+ * 3. a read found a size that disagrees with the manifest;
10091
+ * 4. the cache is over budget, and this is the least recently used unpinned
10092
+ * entry that nothing is currently rendering.
10093
+ */
10094
+ /** Evict down to this fraction of the budget, so eviction is not per-write. */
10095
+ const LOW_WATER = .8;
10096
+ /** Object URLs kept alive at zero references, so list scrolling doesn't thrash. */
10097
+ const HOT_URL_LIMIT = 32;
10098
+ /** Parallel remote reads during `prefetch`. Shares the sync socket's queue. */
10099
+ const PREFETCH_CONCURRENCY = 3;
10100
+ /** Debounce on manifest write-back. Every cache hit moves `lastAccess`, so
10101
+ * flushing eagerly would turn rendering a list of avatars into a write storm. */
10102
+ const FLUSH_DEBOUNCE_MS = 2e3;
10103
+ function defaultUrlFactory() {
10104
+ if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") return void 0;
10105
+ return {
10106
+ create: (blob) => URL.createObjectURL(blob),
10107
+ revoke: (url) => URL.revokeObjectURL(url)
10108
+ };
10109
+ }
10110
+ function isQuotaError(err) {
10111
+ return err instanceof Error && (err.name === "QuotaExceededError" || err.name === "NS_ERROR_FILE_NO_DEVICE_SPACE");
10112
+ }
10113
+ var BlobCache = class {
10114
+ store;
10115
+ manifest;
10116
+ fetchRemote;
10117
+ headRemote;
10118
+ logger;
10119
+ now;
10120
+ urlFactory;
10121
+ maxBytes;
10122
+ persistPaused = false;
10123
+ /** Set after a quota failure survives one forced eviction. */
10124
+ persistDisabled = false;
10125
+ urls = /* @__PURE__ */ new Map();
10126
+ /** Ids at zero references, oldest first — the hot-URL window. */
10127
+ idleUrls = [];
10128
+ inflight = /* @__PURE__ */ new Map();
10129
+ flushTimer = null;
10130
+ onPageHide = () => {
10131
+ this.manifest.flush();
10132
+ };
10133
+ /**
10134
+ * Resolves once the manifest has been reconciled against disk. Reads await
10135
+ * it, so `start()` does NOT have to be awaited on the boot path — blocking
10136
+ * boot on an OPFS directory walk delayed the WebSocket connect (and with it
10137
+ * the connection supervisor) for no benefit.
10138
+ */
10139
+ ready = Promise.resolve();
10140
+ hits = 0;
10141
+ misses = 0;
10142
+ evictedEntries = 0;
10143
+ evictedBytes = 0;
10144
+ reconciledEntries = 0;
10145
+ constructor(opts) {
10146
+ this.store = opts.store;
10147
+ this.manifest = opts.manifest;
10148
+ this.fetchRemote = opts.fetchRemote;
10149
+ this.headRemote = opts.headRemote;
10150
+ this.logger = opts.logger.child({ service: "BlobCache" });
10151
+ this.maxBytes = opts.maxBytes;
10152
+ this.now = opts.now ?? (() => Date.now());
10153
+ this.urlFactory = opts.urls ?? defaultUrlFactory();
10154
+ if (typeof addEventListener === "function") addEventListener("pagehide", this.onPageHide);
10155
+ }
10156
+ /** Coalesce manifest write-back. Metadata only, so losing the tail costs an
10157
+ * LRU timestamp that reconcile reseeds from the file mtime. */
10158
+ scheduleFlush() {
10159
+ if (this.flushTimer) return;
10160
+ this.flushTimer = setTimeout(() => {
10161
+ this.flushTimer = null;
10162
+ this.manifest.flush();
10163
+ }, FLUSH_DEBOUNCE_MS);
10164
+ this.flushTimer.unref?.();
10165
+ }
10166
+ /**
10167
+ * Resolve the bytes for `key`, filling L1 on the way when `persist` is on.
10168
+ * Returns null when the file does not exist remotely and is not cached.
10169
+ */
10170
+ async read(key, options = {}) {
10171
+ await this.ready;
10172
+ const id = blobKeyId(key);
10173
+ const persist = options.persist !== false && !this.persistDisabled;
10174
+ if (!options.reload) {
10175
+ const cached = await this.readLocal(key, id, options);
10176
+ if (cached) {
10177
+ this.hits++;
10178
+ if (options.pin) this.manifest.setPinned(id, true);
10179
+ return cached;
10180
+ }
10181
+ }
10182
+ this.misses++;
10183
+ const bytes = await this.fetchDeduped(key, id);
10184
+ if (!bytes) {
10185
+ await this.dropLocal(key, id);
10186
+ return null;
10187
+ }
10188
+ if (persist) await this.persist(key, id, bytes, options.pin === true);
10189
+ return bytes;
10190
+ }
10191
+ /**
10192
+ * An object URL for `key`, refcounted. Callers MUST `release()`; the URL is
10193
+ * revoked once the last holder lets go and it falls out of the hot window.
10194
+ */
10195
+ async acquireUrl(key, options = {}) {
10196
+ if (!this.urlFactory) return null;
10197
+ const id = blobKeyId(key);
10198
+ if (options.reload) this.revokeUrl(id);
10199
+ else {
10200
+ const live = this.urls.get(id);
10201
+ if (live) return this.lease(id, live);
10202
+ }
10203
+ const blob = await this.read(key, options);
10204
+ if (!blob) return null;
10205
+ const existing = this.urls.get(id);
10206
+ if (existing) return this.lease(id, existing);
10207
+ const entry = {
10208
+ url: this.urlFactory.create(blob),
10209
+ refCount: 0
10210
+ };
10211
+ this.urls.set(id, entry);
10212
+ return this.lease(id, entry);
10213
+ }
10214
+ lease(id, entry) {
10215
+ entry.refCount++;
10216
+ const idle = this.idleUrls.indexOf(id);
10217
+ if (idle !== -1) this.idleUrls.splice(idle, 1);
10218
+ let released = false;
10219
+ return {
10220
+ url: entry.url,
10221
+ release: () => {
10222
+ if (released) return;
10223
+ released = true;
10224
+ this.releaseUrl(id);
10225
+ }
10226
+ };
10227
+ }
10228
+ releaseUrl(id) {
10229
+ const entry = this.urls.get(id);
10230
+ if (!entry) return;
10231
+ entry.refCount--;
10232
+ if (entry.refCount > 0) return;
10233
+ this.idleUrls.push(id);
10234
+ while (this.idleUrls.length > HOT_URL_LIMIT) {
10235
+ const evicted = this.idleUrls.shift();
10236
+ const stale = this.urls.get(evicted);
10237
+ if (stale && stale.refCount <= 0) this.revokeUrl(evicted);
10238
+ }
10239
+ }
10240
+ revokeUrl(id) {
10241
+ const entry = this.urls.get(id);
10242
+ if (!entry) return;
10243
+ this.urls.delete(id);
10244
+ const idle = this.idleUrls.indexOf(id);
10245
+ if (idle !== -1) this.idleUrls.splice(idle, 1);
10246
+ this.urlFactory?.revoke(entry.url);
10247
+ }
10248
+ /** L1 lookup with the size check that catches torn and cross-tab writes. */
10249
+ async readLocal(key, id, options) {
10250
+ let blob;
10251
+ try {
10252
+ blob = await this.store.read(key);
10253
+ } catch (err) {
10254
+ if (err instanceof BlobKeyError) return null;
10255
+ this.logger.warn({
10256
+ err,
10257
+ id
10258
+ }, "blob read from local store failed");
10259
+ return null;
10260
+ }
10261
+ const entry = this.manifest.getById(id);
10262
+ if (!blob) {
10263
+ if (entry) this.manifest.remove(id);
10264
+ return null;
10265
+ }
10266
+ if (entry && entry.size !== blob.size) {
10267
+ await this.dropLocal(key, id);
10268
+ return null;
10269
+ }
10270
+ if (options.revalidate === "head" && !await this.headMatches(key, blob.size)) {
10271
+ await this.dropLocal(key, id);
10272
+ return null;
10273
+ }
10274
+ if (entry) this.manifest.touch(id, this.now());
10275
+ else this.manifest.put({
10276
+ id,
10277
+ bucket: key.bucket,
10278
+ path: key.path,
10279
+ size: blob.size,
10280
+ contentType: blob.type ?? "",
10281
+ createdAt: this.now(),
10282
+ lastAccess: this.now(),
10283
+ hits: 1,
10284
+ pinned: false
10285
+ });
10286
+ this.scheduleFlush();
10287
+ return blob;
10288
+ }
10289
+ /** True when the remote agrees with the cached size, or cannot be reached. */
10290
+ async headMatches(key, size) {
10291
+ if (!this.headRemote) return true;
10292
+ try {
10293
+ const head = await this.headRemote(key);
10294
+ const remoteSize = Number(head?.size);
10295
+ return !Number.isFinite(remoteSize) || remoteSize === size;
10296
+ } catch {
10297
+ return true;
10298
+ }
10299
+ }
10300
+ fetchDeduped(key, id) {
10301
+ const running = this.inflight.get(id);
10302
+ if (running) return running;
10303
+ const promise = this.fetchRemote(key).finally(() => this.inflight.delete(id));
10304
+ this.inflight.set(id, promise);
10305
+ return promise;
10306
+ }
10307
+ async persist(key, id, bytes, pin) {
10308
+ if (this.persistPaused) return;
10309
+ try {
10310
+ await this.writeThrough(key, id, bytes, pin);
10311
+ } catch (err) {
10312
+ if (!isQuotaError(err)) {
10313
+ if (!(err instanceof BlobKeyError)) this.logger.warn({
10314
+ err,
10315
+ id
10316
+ }, "blob write failed");
10317
+ return;
10318
+ }
10319
+ await this.evictTo(Math.floor(this.maxBytes * .5));
10320
+ try {
10321
+ await this.writeThrough(key, id, bytes, pin);
10322
+ } catch (retryErr) {
10323
+ this.persistDisabled = true;
10324
+ this.logger.warn({ err: retryErr }, "blob cache disabled: storage quota exhausted");
10325
+ }
10326
+ }
10327
+ }
10328
+ async writeThrough(key, id, bytes, pin) {
10329
+ const size = await this.store.write(key, bytes);
10330
+ const now = this.now();
10331
+ const previous = this.manifest.getById(id);
10332
+ this.manifest.put({
10333
+ id,
10334
+ bucket: key.bucket,
10335
+ path: key.path,
10336
+ size,
10337
+ contentType: bytes.type ?? "",
10338
+ createdAt: previous?.createdAt ?? now,
10339
+ lastAccess: now,
10340
+ hits: (previous?.hits ?? 0) + 1,
10341
+ pinned: pin || previous?.pinned === true
10342
+ });
10343
+ this.scheduleFlush();
10344
+ await this.enforceBudget();
10345
+ }
10346
+ /** Forget one path everywhere. Called on `bucket.put()`/`bucket.delete()`. */
10347
+ async invalidate(key) {
10348
+ await this.dropLocal(key, blobKeyId(key));
10349
+ }
10350
+ async dropLocal(key, id) {
10351
+ this.revokeUrl(id);
10352
+ this.manifest.remove(id);
10353
+ this.scheduleFlush();
10354
+ try {
10355
+ await this.store.remove(key);
10356
+ } catch (err) {
10357
+ if (!(err instanceof BlobKeyError)) this.logger.warn({
10358
+ err,
10359
+ id
10360
+ }, "blob delete failed");
10361
+ }
10362
+ }
10363
+ setPinned(key, pinned) {
10364
+ if (this.manifest.setPinned(blobKeyId(key), pinned)) this.scheduleFlush();
10365
+ }
10366
+ /**
10367
+ * Bring total bytes under budget by dropping the least recently used
10368
+ * entries. Pinned entries and anything with a live object URL are skipped —
10369
+ * evicting bytes that a mounted `<img>` is displaying would blank it.
10370
+ */
10371
+ async enforceBudget() {
10372
+ if (this.manifest.totalBytes() <= this.maxBytes) {
10373
+ this.persistPaused = false;
10374
+ return;
10375
+ }
10376
+ const remaining = await this.evictTo(Math.floor(this.maxBytes * LOW_WATER));
10377
+ if (remaining > this.maxBytes) {
10378
+ if (!this.persistPaused) this.logger.warn({
10379
+ totalBytes: remaining,
10380
+ budgetBytes: this.maxBytes,
10381
+ pinnedBytes: this.manifest.pinnedBytes()
10382
+ }, "blob cache over budget with nothing evictable; pausing new writes");
10383
+ this.persistPaused = true;
10384
+ } else this.persistPaused = false;
10385
+ }
10386
+ /** Evict LRU-first until at or below `target`. Returns the resulting total. */
10387
+ async evictTo(target) {
10388
+ let total = this.manifest.totalBytes();
10389
+ if (total <= target) return total;
10390
+ const candidates = this.manifest.all().filter((e) => !e.pinned && (this.urls.get(e.id)?.refCount ?? 0) <= 0).sort((a, b) => a.lastAccess - b.lastAccess);
10391
+ const dropped = [];
10392
+ for (const entry of candidates) {
10393
+ if (total <= target) break;
10394
+ await this.dropLocal({
10395
+ bucket: entry.bucket,
10396
+ path: entry.path
10397
+ }, entry.id);
10398
+ total -= entry.size;
10399
+ this.evictedEntries++;
10400
+ this.evictedBytes += entry.size;
10401
+ dropped.push(entry.id);
10402
+ }
10403
+ if (dropped.length > 0) this.logger.info({
10404
+ count: dropped.length,
10405
+ totalBytes: total,
10406
+ budgetBytes: this.maxBytes
10407
+ }, "blob cache evicted least-recently-used entries");
10408
+ return total;
10409
+ }
10410
+ /**
10411
+ * Rebuild the manifest from what is actually on disk. OPFS wins on existence
10412
+ * in both directions: files with no row get a row (seeded from mtime), rows
10413
+ * are only loaded for files that exist, and torn `.part-` writes are swept by
10414
+ * the walk itself.
10415
+ *
10416
+ * Rows whose file vanished outside our control (a browser origin eviction)
10417
+ * are left in `_00_blob`. They are inert — `load()` only ever asks for ids it
10418
+ * found on disk — and are overwritten if that path is cached again.
10419
+ */
10420
+ async reconcile() {
10421
+ let stats;
10422
+ try {
10423
+ stats = await this.store.list();
10424
+ } catch (err) {
10425
+ this.logger.warn({ err }, "blob cache reconcile failed to list local store");
10426
+ return;
10427
+ }
10428
+ await this.manifest.load(stats.map((s) => blobKeyId(s.key)));
10429
+ let rebuilt = 0;
10430
+ for (const stat of stats) {
10431
+ const id = blobKeyId(stat.key);
10432
+ const existing = this.manifest.getById(id);
10433
+ if (!existing) {
10434
+ this.manifest.put({
10435
+ id,
10436
+ bucket: stat.key.bucket,
10437
+ path: stat.key.path,
10438
+ size: stat.size,
10439
+ contentType: "",
10440
+ createdAt: stat.mtime,
10441
+ lastAccess: stat.mtime,
10442
+ hits: 0,
10443
+ pinned: false
10444
+ });
10445
+ rebuilt++;
10446
+ } else if (existing.size !== stat.size) this.manifest.put({
10447
+ ...existing,
10448
+ size: stat.size
10449
+ });
10450
+ }
10451
+ this.reconciledEntries = stats.length;
10452
+ if (rebuilt > 0) this.logger.info({
10453
+ rebuilt,
10454
+ entries: stats.length
10455
+ }, "blob cache rebuilt manifest rows from disk");
10456
+ await this.enforceBudget();
10457
+ await this.manifest.flush();
10458
+ }
10459
+ /** Warm the cache for offline use. Skips anything already cached. */
10460
+ async prefetch(keys) {
10461
+ const queue = keys.filter((key) => !this.manifest.get(key));
10462
+ let cursor = 0;
10463
+ const worker = async () => {
10464
+ while (cursor < queue.length) {
10465
+ const key = queue[cursor++];
10466
+ try {
10467
+ await this.read(key);
10468
+ } catch (err) {
10469
+ this.logger.warn({
10470
+ err,
10471
+ id: blobKeyId(key)
10472
+ }, "blob prefetch failed");
10473
+ }
10474
+ }
10475
+ };
10476
+ await Promise.all(Array.from({ length: Math.min(PREFETCH_CONCURRENCY, queue.length) }, worker));
10477
+ }
10478
+ /** Bind to the boot bucket and hydrate the manifest from disk. Separate from
10479
+ * {@link setNamespace} because boot must reconcile even when the namespace
10480
+ * it lands on is the one the store was constructed with. */
10481
+ async start(namespace) {
10482
+ this.store.setNamespace(namespace);
10483
+ this.ready = this.reconcile().catch(() => {});
10484
+ await this.ready;
10485
+ }
10486
+ /** Repoint at another local bucket. The bytes of the old one stay on disk so
10487
+ * switching back (or signing back in) is still warm. */
10488
+ async setNamespace(namespace) {
10489
+ if (namespace === this.store.namespace) return;
10490
+ await this.manifest.flush();
10491
+ for (const id of [...this.urls.keys()]) this.revokeUrl(id);
10492
+ this.inflight.clear();
10493
+ this.manifest.reset();
10494
+ this.store.setNamespace(namespace);
10495
+ this.persistPaused = false;
10496
+ this.ready = this.reconcile().catch(() => {});
10497
+ await this.ready;
10498
+ }
10499
+ setMaxBytes(maxBytes) {
10500
+ this.maxBytes = maxBytes;
10501
+ }
10502
+ /** Delete every cached byte in the current namespace. */
10503
+ async clear() {
10504
+ for (const id of [...this.urls.keys()]) this.revokeUrl(id);
10505
+ for (const entry of this.manifest.all()) this.manifest.remove(entry.id);
10506
+ this.inflight.clear();
10507
+ try {
10508
+ await this.store.clear();
10509
+ } catch (err) {
10510
+ this.logger.warn({ err }, "blob cache clear failed");
10511
+ }
10512
+ await this.manifest.flush();
10513
+ }
10514
+ async flush() {
10515
+ await this.manifest.flush();
10516
+ }
10517
+ /** Flush metadata and drop every object URL. Must run before the local store
10518
+ * closes — the flush writes through it. */
10519
+ async close() {
10520
+ if (this.flushTimer) {
10521
+ clearTimeout(this.flushTimer);
10522
+ this.flushTimer = null;
10523
+ }
10524
+ if (typeof removeEventListener === "function") removeEventListener("pagehide", this.onPageHide);
10525
+ await this.manifest.flush();
10526
+ for (const id of [...this.urls.keys()]) this.revokeUrl(id);
10527
+ this.inflight.clear();
10528
+ }
10529
+ stats() {
10530
+ return {
10531
+ entries: this.manifest.all().length,
10532
+ totalBytes: this.manifest.totalBytes(),
10533
+ budgetBytes: this.maxBytes,
10534
+ pinnedBytes: this.manifest.pinnedBytes(),
10535
+ evictedEntries: this.evictedEntries,
10536
+ evictedBytes: this.evictedBytes,
10537
+ reconciledEntries: this.reconciledEntries,
10538
+ hits: this.hits,
10539
+ misses: this.misses,
10540
+ persistent: this.store.persistent && !this.persistDisabled,
10541
+ persistPaused: this.persistPaused
10542
+ };
10543
+ }
10544
+ };
10545
+
10546
+ //#endregion
10547
+ //#region src/services/blobs/index.ts
10548
+ /** Ceiling on the default budget, before the quota fraction is applied. */
10549
+ const MAX_DEFAULT_BUDGET_BYTES = 512 * 1024 * 1024;
10550
+ /** Share of the origin quota the blob cache may claim by default. The local
10551
+ * database, the SQLite pool and any app storage share the same quota. */
10552
+ const QUOTA_FRACTION = .25;
10553
+ /** Used until `resolveBlobBudget()` reports back, and when there is no
10554
+ * `navigator.storage.estimate()` to ask. */
10555
+ const FALLBACK_BLOB_BUDGET_BYTES = 128 * 1024 * 1024;
10556
+ /**
10557
+ * Budget from the real origin quota when the browser will tell us, otherwise a
10558
+ * conservative constant. Async because `estimate()` is; call it once at init
10559
+ * and hand the result to {@link BlobCache.setMaxBytes}.
10560
+ */
10561
+ async function resolveBlobBudget(configured) {
10562
+ if (typeof configured === "number" && configured > 0) return configured;
10563
+ try {
10564
+ const { quota } = await navigator.storage.estimate() ?? {};
10565
+ if (typeof quota === "number" && quota > 0) return Math.min(MAX_DEFAULT_BUDGET_BYTES, Math.floor(quota * QUOTA_FRACTION));
10566
+ } catch {}
10567
+ return FALLBACK_BLOB_BUDGET_BYTES;
10568
+ }
10569
+ /**
10570
+ * Build the cache for a client. Falls back to an in-memory store when OPFS
10571
+ * cannot be written (Safari before `createWritable`, private modes, non-browser
10572
+ * hosts): the cache still dedupes and serves within the tab, which is exactly
10573
+ * the behaviour that existed before it — nothing regresses, nothing persists.
10574
+ */
10575
+ function createBlobCache(opts) {
10576
+ return new BlobCache({
10577
+ store: opts.store ?? (opfsWritableSupported() ? new OpfsBlobStore(opts.namespace) : new MemoryBlobStore(opts.namespace)),
10578
+ manifest: new BlobManifest(opts.local),
10579
+ fetchRemote: opts.fetchRemote,
10580
+ headRemote: opts.headRemote,
10581
+ logger: opts.logger,
10582
+ maxBytes: opts.maxBytes ?? FALLBACK_BLOB_BUDGET_BYTES
10583
+ });
10584
+ }
10585
+
9582
10586
  //#endregion
9583
10587
  //#region src/sp00ky.ts
10588
+ /** Coerce whatever the `.get()` RPC hands back into a Blob. */
10589
+ function bucketContentToBlob(content) {
10590
+ if (content == null) return null;
10591
+ if (content instanceof Blob) return content;
10592
+ if (typeof content === "string") return new Blob([content]);
10593
+ if (content instanceof ArrayBuffer) return new Blob([content]);
10594
+ if (ArrayBuffer.isView(content)) return new Blob([content]);
10595
+ return null;
10596
+ }
9584
10597
  var BucketHandle = class {
9585
- constructor(bucketName, remote) {
10598
+ constructor(bucketName, remote, blobs) {
9586
10599
  this.bucketName = bucketName;
9587
10600
  this.remote = remote;
10601
+ this.blobs = blobs;
9588
10602
  }
9589
10603
  async put(path, content) {
9590
10604
  await this.remote.query(`RETURN f"${this.bucketName}:/${path}".put($content);`, { content });
10605
+ await this.blobs?.invalidate({
10606
+ bucket: this.bucketName,
10607
+ path
10608
+ });
9591
10609
  }
9592
10610
  async get(path) {
9593
10611
  const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".get();`);
9594
10612
  return result;
9595
10613
  }
10614
+ /**
10615
+ * Read through the local blob cache: OPFS first, the bucket second. Unlike
10616
+ * {@link get} this survives a reload and works offline. Returns null when the
10617
+ * file exists in neither place.
10618
+ */
10619
+ async read(path, options) {
10620
+ if (!this.blobs) return bucketContentToBlob(await this.get(path));
10621
+ return this.blobs.read({
10622
+ bucket: this.bucketName,
10623
+ path
10624
+ }, options);
10625
+ }
10626
+ /**
10627
+ * A refcounted object URL for `path`, suitable for `<img src>`. The caller
10628
+ * MUST call `release()` when the URL goes off screen. Returns null when the
10629
+ * file does not exist, or when object URLs are unavailable (non-browser).
10630
+ */
10631
+ async url(path, options) {
10632
+ if (!this.blobs) return null;
10633
+ return this.blobs.acquireUrl({
10634
+ bucket: this.bucketName,
10635
+ path
10636
+ }, options);
10637
+ }
10638
+ /** Exempt `path` from pressure eviction. Pinned bytes never expire. */
10639
+ pin(path) {
10640
+ this.blobs?.setPinned({
10641
+ bucket: this.bucketName,
10642
+ path
10643
+ }, true);
10644
+ }
10645
+ unpin(path) {
10646
+ this.blobs?.setPinned({
10647
+ bucket: this.bucketName,
10648
+ path
10649
+ }, false);
10650
+ }
10651
+ /** Drop `path` from the local cache without touching the remote file. */
10652
+ async evict(path) {
10653
+ await this.blobs?.invalidate({
10654
+ bucket: this.bucketName,
10655
+ path
10656
+ });
10657
+ }
10658
+ /** Warm the cache for offline use. Already-cached paths are skipped. */
10659
+ async prefetch(paths) {
10660
+ await this.blobs?.prefetch(paths.map((path) => ({
10661
+ bucket: this.bucketName,
10662
+ path
10663
+ })));
10664
+ }
9596
10665
  async delete(path) {
9597
10666
  await this.remote.query(`RETURN f"${this.bucketName}:/${path}".delete();`);
10667
+ await this.blobs?.invalidate({
10668
+ bucket: this.bucketName,
10669
+ path
10670
+ });
9598
10671
  }
9599
10672
  async exists(path) {
9600
10673
  const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".exists();`);
@@ -9647,6 +10720,7 @@ function writeBootBucketHint(bucketId) {
9647
10720
  var Sp00kyClient = class {
9648
10721
  local;
9649
10722
  remote;
10723
+ blobs;
9650
10724
  connectionSupervisor;
9651
10725
  persistenceClient;
9652
10726
  migrator;
@@ -9730,6 +10804,15 @@ var Sp00kyClient = class {
9730
10804
  this.local = createLocalEngine(this.config.localEngine, this.config.database, logger, { shared: tabsSupport.supported });
9731
10805
  this.remote = new RemoteDatabaseService(this.config.database, logger);
9732
10806
  this.connectionSupervisor = new ConnectionSupervisor(this.remote, logger);
10807
+ this.blobs = createBlobCache({
10808
+ local: this.local,
10809
+ namespace: ANON_USER_ID,
10810
+ logger,
10811
+ fetchRemote: async (key) => bucketContentToBlob(await this.rawBucket(key.bucket).get(key.path)),
10812
+ headRemote: (key) => this.rawBucket(key.bucket).head(key.path),
10813
+ maxBytes: this.config.blobCache?.maxBytes,
10814
+ store: this.config.blobCache?.enabled === false ? new MemoryBlobStore(ANON_USER_ID) : void 0
10815
+ });
9733
10816
  if (config.persistenceClient === "surrealdb") this.persistenceClient = new SurrealDBPersistenceClient(this.local, logger);
9734
10817
  else if (config.persistenceClient === "localstorage" || !config.persistenceClient) this.persistenceClient = new LocalStoragePersistenceClient(logger);
9735
10818
  else this.persistenceClient = config.persistenceClient;
@@ -9792,6 +10875,7 @@ var Sp00kyClient = class {
9792
10875
  };
9793
10876
  });
9794
10877
  }
10878
+ this.devTools.setBlobInfoProvider(() => this.blobs.stats());
9795
10879
  }
9796
10880
  /** The shared-tabs role machinery, wired to this client's modules. */
9797
10881
  buildTabsCoordinator() {
@@ -9800,7 +10884,7 @@ var Sp00kyClient = class {
9800
10884
  return new TabsCoordinator({
9801
10885
  tabId,
9802
10886
  fingerprint: computeTabsFingerprint({
9803
- coreVersion: "0.0.1-canary.162",
10887
+ coreVersion: "0.0.1-canary.164",
9804
10888
  schemaHash: hash53(this.config.schemaSurql),
9805
10889
  endpoint: this.config.database.endpoint ?? "",
9806
10890
  namespace: this.config.database.namespace,
@@ -9942,6 +11026,17 @@ var Sp00kyClient = class {
9942
11026
  await this.remote.connect();
9943
11027
  this.connectionSupervisor.start();
9944
11028
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Remote database connected");
11029
+ (async () => {
11030
+ try {
11031
+ this.blobs.setMaxBytes(await resolveBlobBudget(this.config.blobCache?.maxBytes));
11032
+ await this.blobs.start(bootBucket);
11033
+ } catch (e) {
11034
+ this.logger.warn({
11035
+ err: e,
11036
+ Category: "sp00ky-client::Sp00kyClient::init"
11037
+ }, "Blob cache failed to start; bucket files will not be cached locally");
11038
+ }
11039
+ })();
9945
11040
  this.streamProcessor.setStateKeySuffix(bootBucket);
9946
11041
  await this.streamProcessor.init();
9947
11042
  this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
@@ -10037,6 +11132,7 @@ var Sp00kyClient = class {
10037
11132
  from: this.local.currentBucketId,
10038
11133
  Category: "sp00ky-client::Sp00kyClient::doSwitchBucket"
10039
11134
  }, "Switching local bucket");
11135
+ const leavingBucket = this.local.currentBucketId;
10040
11136
  await this.sync.prepareBucketSwitch();
10041
11137
  this.dataModule.quiesce();
10042
11138
  this.crdtManager.closeAll({ flush: false });
@@ -10062,6 +11158,16 @@ var Sp00kyClient = class {
10062
11158
  if (this.local.usesSurqlSchema) await this.migrator.provision(this.config.schemaSurql);
10063
11159
  await this.local.queryUngated("DELETE _00_query;");
10064
11160
  }
11161
+ try {
11162
+ if (this.config.blobCache?.clearOnSignOut && target === ANON_USER_ID && leavingBucket !== ANON_USER_ID) await this.blobs.clear();
11163
+ await this.blobs.setNamespace(target);
11164
+ } catch (e) {
11165
+ this.logger.warn({
11166
+ err: e,
11167
+ target,
11168
+ Category: "sp00ky-client::Sp00kyClient::doSwitchBucket"
11169
+ }, "Blob cache bucket switch failed");
11170
+ }
10065
11171
  this.streamProcessor.setStateKeySuffix(target);
10066
11172
  await this.streamProcessor.reset();
10067
11173
  this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
@@ -10096,6 +11202,7 @@ var Sp00kyClient = class {
10096
11202
  await this.appReleases.closeAll();
10097
11203
  this.crdtManager.closeAll();
10098
11204
  this.crdtManager.dispose();
11205
+ await this.blobs.close().catch(() => {});
10099
11206
  if (this.tabsCoordinator) await this.tabsCoordinator.stop();
10100
11207
  await this.local.close();
10101
11208
  await this.remote.close();
@@ -10301,7 +11408,16 @@ var Sp00kyClient = class {
10301
11408
  return this.dataModule.run(backend, path, payload, options);
10302
11409
  }
10303
11410
  bucket(name) {
10304
- return new BucketHandle(name, this.remote);
11411
+ return new BucketHandle(name, this.remote, this.blobs);
11412
+ }
11413
+ /** Cache-free handle. The blob cache reads the remote through this, so a
11414
+ * cache miss can't loop back into the cache. */
11415
+ rawBucket(name) {
11416
+ return new BucketHandle(name, this.remote, null);
11417
+ }
11418
+ /** Blob cache counters for DevTools. */
11419
+ getBlobCacheStats() {
11420
+ return this.blobs.stats();
10305
11421
  }
10306
11422
  create(id, data) {
10307
11423
  return this.dataModule.create(id, data);
@@ -10336,4 +11452,4 @@ var Sp00kyClient = class {
10336
11452
  };
10337
11453
 
10338
11454
  //#endregion
10339
- export { AppReleaseHandle, AppReleaseModule, AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, FeatureFlagHandle, FeatureFlagModule, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, createAuthEventSystem, cursorColorFromName, fileToUint8Array, semverGt, textToHtml };
11455
+ export { AppReleaseHandle, AppReleaseModule, AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, FeatureFlagHandle, FeatureFlagModule, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, fileToUint8Array, semverGt, textToHtml };