@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/AGENTS.md +2 -1
- package/dist/index.d.ts +310 -2
- package/dist/index.js +1126 -10
- package/dist/types.d.ts +26 -0
- package/package.json +3 -3
- package/src/index.ts +7 -0
- package/src/modules/devtools/index.ts +17 -1
- package/src/modules/devtools/storage-info.ts +25 -0
- package/src/modules/sync/sync.ts +25 -2
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/connection-supervisor.test.ts +34 -0
- package/src/services/database/connection-supervisor.ts +66 -2
- package/src/services/database/local-migrator.ts +1 -0
- package/src/services/database/sqlite-cache-engine.ts +4 -0
- package/src/sp00ky.ts +133 -2
- package/src/types.ts +26 -0
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
|
-
|
|
5905
|
-
|
|
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.
|
|
6487
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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.
|
|
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 };
|