Files
dbx/apps/desktop/src/lib/tabs/tabResultCache.ts
T

852 lines
35 KiB
TypeScript

import type { QueryResult, QueryTab } from "@/types/database";
import { decode, encode } from "@msgpack/msgpack";
import { toRaw } from "vue";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { apiUrl } from "@/lib/common/webPath";
const DB_NAME = "dbx-tab-runtime-cache";
const DB_VERSION = 2;
const RESULT_STORE = "resultSnapshots";
const RESULT_METADATA_STORE = "resultSnapshotMetadata";
const DEFAULT_PERSISTENT_CACHE_BYTES = 512 * 1024 * 1024;
const DEFAULT_ORPHAN_GRACE_MS = 24 * 60 * 60 * 1000;
const DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
const PAYLOAD_MAGIC = "DBX_TAB_RESULT_CACHE";
const PAYLOAD_VERSION = 1;
const PAYLOAD_CODEC = "msgpack-columnar";
type CellValue = QueryResult["rows"][number][number];
export interface TabResultSnapshot {
result?: QueryResult;
results?: QueryResult[];
activeResultIndex?: number;
resultEditorFingerprint?: string;
/**
* Source ordering retained while a local grid sort is active. It must travel
* with the snapshot so clearing the sort after a cache/archive restore can
* still return to the original result order.
*/
resultLocalSortOriginalRows?: QueryResult["rows"];
resultLocalSortOriginalLargeValueCells?: QueryResult["large_value_cells"];
resultLocalSortOriginalMongoDocuments?: QueryResult["mongo_documents"];
resultLocalSortOriginalMongoCopyDocuments?: QueryResult["mongo_copy_documents"];
resultRuns?: QueryTab["resultRuns"];
activeResultRunId?: string;
/**
* Logical-result identity for the tab-switch view snapshot cache. Required at
* the tab level because a data tab has no result run to carry it; query tabs
* additionally carry it per run through `resultRuns`.
*/
resultViewGeneration?: string;
queryAnalysis?: QueryTab["queryAnalysis"];
querySourceColumns?: QueryTab["querySourceColumns"];
queryWriteTargets?: QueryTab["queryWriteTargets"];
resultColumnComments?: QueryTab["resultColumnComments"];
queryDisplaySourceColumns?: QueryTab["queryDisplaySourceColumns"];
queryEditabilityReason?: QueryTab["queryEditabilityReason"];
mongoEditTarget?: QueryTab["mongoEditTarget"];
tableMeta?: QueryTab["tableMeta"];
resultPageSql?: string;
resultPageLimit?: number;
resultPageOffset?: number;
resultCountSql?: string;
resultTotalRowCount?: number;
cachedAt: number;
}
interface ColumnarQueryResult {
columns: string[];
spatial_columns?: QueryResult["spatial_columns"];
spatial_values?: QueryResult["spatial_values"];
large_value_cells?: QueryResult["large_value_cells"];
execution_error?: true;
statement_index?: number;
column_types?: string[];
local_column_filters?: QueryResult["local_column_filters"];
columnValues: CellValue[][];
rowCount: number;
mongo_documents?: unknown[];
mongo_copy_documents?: unknown[];
affected_rows: number;
execution_time_ms: number;
truncated?: boolean;
has_more?: boolean;
sourceLabel?: string;
sourceStatement?: string;
sourceFrom?: number;
sourceTo?: number;
}
type QueryResultRunSnapshot = NonNullable<QueryTab["resultRuns"]>[number];
interface ColumnarQueryResultRun extends Omit<QueryResultRunSnapshot, "result" | "results"> {
result?: ColumnarQueryResult;
results?: ColumnarQueryResult[];
}
interface TabResultSnapshotPayload extends Omit<TabResultSnapshot, "result" | "results" | "resultRuns"> {
result?: ColumnarQueryResult;
results?: ColumnarQueryResult[];
resultRuns?: ColumnarQueryResultRun[];
}
interface TabResultCacheEnvelope {
magic: typeof PAYLOAD_MAGIC;
version: typeof PAYLOAD_VERSION;
codec: typeof PAYLOAD_CODEC;
cachedAt: number;
rowCount: number;
columnCount: number;
payload: TabResultSnapshotPayload;
}
export type ResultCacheBackendName = "indexed-db" | "runtime";
export interface ResultCacheMetadata {
key: string;
rowCount: number;
columnCount: number;
byteSize: number;
createdAt: number;
lastAccessedAt: number;
ownerId?: string;
}
export interface ResultCachePruneOptions {
liveKeys: string[];
maxBytes: number;
orphanGraceMs: number;
maxAgeMs?: number;
}
export interface ResultCachePruneResult {
deletedEntries: number;
deletedBytes: number;
orphanDeletions: number;
remainingEntries: number;
remainingBytes: number;
}
export interface ResultCacheBackend {
name: ResultCacheBackendName;
available: () => boolean;
read: (key: string) => Promise<Uint8Array | undefined>;
write: (key: string, bytes: Uint8Array, stats: { rowCount: number; columnCount: number }, ownerId?: string) => Promise<boolean>;
delete: (key: string) => Promise<void>;
listMetadata: () => Promise<ResultCacheMetadata[]>;
prune: (options: ResultCachePruneOptions) => Promise<ResultCachePruneResult>;
deleteOwner: (ownerId: string) => Promise<void>;
}
const resultCacheDiagnostics = {
cacheBytes: 0,
evictions: 0,
serializationCount: 0,
serializationDurationMs: 0,
serializedBytes: 0,
orphanDeletions: 0,
corruptSnapshots: 0,
};
export function getResultCacheDiagnostics() {
return { ...resultCacheDiagnostics };
}
function indexedDb(): IDBFactory | undefined {
return typeof globalThis.indexedDB === "undefined" ? undefined : globalThis.indexedDB;
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
});
}
let dbPromise: Promise<IDBDatabase | null> | undefined;
const cacheKeyVersions = new Map<string, number>();
function bumpCacheKeyVersion(key: string): number {
const version = (cacheKeyVersions.get(key) ?? 0) + 1;
cacheKeyVersions.set(key, version);
return version;
}
function isCurrentCacheKeyVersion(key: string, version: number): boolean {
return cacheKeyVersions.get(key) === version;
}
function clearCacheKeyVersionIfCurrent(key: string, version: number) {
if (isCurrentCacheKeyVersion(key, version)) cacheKeyVersions.delete(key);
}
function openCacheDb(): Promise<IDBDatabase | null> {
if (dbPromise) return dbPromise;
const idb = indexedDb();
if (!idb) return Promise.resolve(null);
dbPromise = new Promise((resolve) => {
const request = idb.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(RESULT_STORE)) db.createObjectStore(RESULT_STORE);
if (!db.objectStoreNames.contains(RESULT_METADATA_STORE)) db.createObjectStore(RESULT_METADATA_STORE);
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => {
console.warn("[DBX][tab-result-cache:open:error]", request.error);
resolve(null);
};
request.onblocked = () => resolve(null);
});
return dbPromise;
}
async function writeIndexedDbCache(key: string, bytes: Uint8Array, stats: { rowCount: number; columnCount: number }, ownerId?: string): Promise<boolean> {
const db = await openCacheDb();
if (!db) return false;
const previous = (await requestToPromise(db.transaction(RESULT_METADATA_STORE, "readonly").objectStore(RESULT_METADATA_STORE).get(key))) as ResultCacheMetadata | undefined;
const transaction = db.transaction([RESULT_STORE, RESULT_METADATA_STORE], "readwrite");
const metadataStore = transaction.objectStore(RESULT_METADATA_STORE);
const now = Date.now();
await Promise.all([
requestToPromise(transaction.objectStore(RESULT_STORE).put(bytes, key)),
requestToPromise(
metadataStore.put(
{
key,
rowCount: stats.rowCount,
columnCount: stats.columnCount,
byteSize: bytes.byteLength,
createdAt: previous?.createdAt ?? now,
lastAccessedAt: now,
ownerId,
} satisfies ResultCacheMetadata,
key,
),
),
]);
return true;
}
async function readIndexedDbCache(key: string): Promise<Uint8Array | undefined> {
const db = await openCacheDb();
if (!db) return undefined;
const value = await requestToPromise(db.transaction(RESULT_STORE, "readonly").objectStore(RESULT_STORE).get(key));
if (isBinaryPayload(value)) {
const previous = (await requestToPromise(db.transaction(RESULT_METADATA_STORE, "readonly").objectStore(RESULT_METADATA_STORE).get(key))) as ResultCacheMetadata | undefined;
const store = db.transaction(RESULT_METADATA_STORE, "readwrite").objectStore(RESULT_METADATA_STORE);
const now = Date.now();
await requestToPromise(store.put(previous ? { ...previous, lastAccessedAt: now } : { key, rowCount: 0, columnCount: 0, byteSize: value.byteLength, createdAt: now, lastAccessedAt: now }, key));
}
return isBinaryPayload(value) ? new Uint8Array(value) : undefined;
}
async function deleteIndexedDbCache(key: string): Promise<void> {
const db = await openCacheDb();
if (!db) return;
const transaction = db.transaction([RESULT_STORE, RESULT_METADATA_STORE], "readwrite");
await Promise.all([requestToPromise(transaction.objectStore(RESULT_STORE).delete(key)), requestToPromise(transaction.objectStore(RESULT_METADATA_STORE).delete(key))]);
}
async function listIndexedDbCacheMetadata(): Promise<ResultCacheMetadata[]> {
const db = await openCacheDb();
if (!db) return [];
const transaction = db.transaction([RESULT_STORE, RESULT_METADATA_STORE], "readonly");
const metadataRequest = transaction.objectStore(RESULT_METADATA_STORE).getAll();
const keysRequest = transaction.objectStore(RESULT_STORE).getAllKeys();
const [metadata, keys] = await Promise.all([requestToPromise(metadataRequest) as Promise<ResultCacheMetadata[]>, requestToPromise(keysRequest)]);
const byKey = new Map(metadata.map((entry) => [entry.key, entry]));
const missingKeys = keys.map(String).filter((key) => !byKey.has(key));
const migratedMetadata: ResultCacheMetadata[] = [];
for (const key of missingKeys) {
const value = await requestToPromise(db.transaction(RESULT_STORE, "readonly").objectStore(RESULT_STORE).get(key));
const now = Date.now();
const entry: ResultCacheMetadata = {
key,
rowCount: 0,
columnCount: 0,
byteSize: isBinaryPayload(value) ? value.byteLength : 0,
createdAt: now,
lastAccessedAt: now,
};
byKey.set(key, entry);
migratedMetadata.push(entry);
}
if (migratedMetadata.length) {
const migration = db.transaction(RESULT_METADATA_STORE, "readwrite").objectStore(RESULT_METADATA_STORE);
await Promise.all(migratedMetadata.map((entry) => requestToPromise(migration.put(entry, entry.key))));
}
return [...byKey.values()];
}
export function selectResultCachePruneKeys(metadata: ResultCacheMetadata[], options: ResultCachePruneOptions, now = Date.now()): { keys: string[]; result: ResultCachePruneResult } {
const live = new Set(options.liveKeys);
const ordered = [...metadata].sort((left, right) => left.lastAccessedAt - right.lastAccessedAt || left.key.localeCompare(right.key));
const deleted = new Set<string>();
let remainingBytes = ordered.reduce((total, entry) => total + entry.byteSize, 0);
let orphanDeletions = 0;
for (const entry of ordered) {
if (live.has(entry.key)) continue;
const orphanExpired = now - entry.createdAt >= Math.max(0, options.orphanGraceMs);
const ageExpired = options.maxAgeMs !== undefined && now - entry.lastAccessedAt >= Math.max(0, options.maxAgeMs);
if (!orphanExpired && !ageExpired) continue;
deleted.add(entry.key);
remainingBytes -= entry.byteSize;
if (orphanExpired) orphanDeletions += 1;
}
for (const entry of ordered) {
if (remainingBytes <= Math.max(0, options.maxBytes)) break;
if (live.has(entry.key) || deleted.has(entry.key)) continue;
deleted.add(entry.key);
remainingBytes -= entry.byteSize;
}
const deletedBytes = ordered.filter((entry) => deleted.has(entry.key)).reduce((total, entry) => total + entry.byteSize, 0);
return {
keys: [...deleted],
result: {
deletedEntries: deleted.size,
deletedBytes,
orphanDeletions,
remainingEntries: ordered.length - deleted.size,
remainingBytes,
},
};
}
async function pruneIndexedDbCache(options: ResultCachePruneOptions): Promise<ResultCachePruneResult> {
const selection = selectResultCachePruneKeys(await listIndexedDbCacheMetadata(), options);
await Promise.all(selection.keys.map(deleteIndexedDbCache));
return selection.result;
}
async function deleteIndexedDbCacheOwner(ownerId: string): Promise<void> {
const metadata = await listIndexedDbCacheMetadata();
await Promise.all(metadata.filter((entry) => entry.ownerId === ownerId).map((entry) => deleteIndexedDbCache(entry.key)));
}
function clonePlain<T>(value: T): T {
const raw = toRaw(value);
if (typeof structuredClone === "function") return structuredClone(raw);
try {
return JSON.parse(JSON.stringify(raw)) as T;
} catch {
return raw;
}
}
function cloneLocalColumnFilters(filters: QueryResult["local_column_filters"]): QueryResult["local_column_filters"] {
return filters ? Object.fromEntries(Object.entries(filters).map(([columnIndex, values]) => [columnIndex, [...values]])) : undefined;
}
function stripSessionIds(result: QueryResult | undefined): QueryResult | undefined {
if (!result) return undefined;
return {
columns: [...result.columns],
execution_error: result.execution_error,
statement_index: result.statement_index,
column_types: result.column_types ? [...result.column_types] : undefined,
local_column_filters: cloneLocalColumnFilters(result.local_column_filters),
spatial_columns: result.spatial_columns?.map((entry) => ({ column_index: entry.column_index, srid: entry.srid })),
spatial_values: result.spatial_values?.map((row) => [...row]),
large_value_cells: result.large_value_cells?.map((cell) => ({ ...cell })),
rows: result.rows.map((row) => [...row]),
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
mongo_copy_documents: result.mongo_copy_documents ? clonePlain(result.mongo_copy_documents) : undefined,
affected_rows: result.affected_rows,
execution_time_ms: result.execution_time_ms,
truncated: result.truncated,
session_id: undefined,
has_more: result.has_more,
sourceLabel: result.sourceLabel,
sourceStatement: result.sourceStatement,
sourceFrom: result.sourceFrom,
sourceTo: result.sourceTo,
};
}
function stripResultSessionIds(results: QueryResult[] | undefined): QueryResult[] | undefined {
return results?.map((result) => stripSessionIds(result)!);
}
function stripResultRunSessionIds(resultRuns: QueryTab["resultRuns"]): QueryTab["resultRuns"] {
return resultRuns?.map((run) => ({
...run,
result: stripSessionIds(run.result),
results: stripResultSessionIds(run.results),
resultLocalSortOriginalRows: run.resultLocalSortOriginalRows?.map((row) => [...row]),
resultLocalSortOriginalLargeValueCells: run.resultLocalSortOriginalLargeValueCells?.map((cell) => ({ ...cell })),
resultLocalSortOriginalMongoDocuments: run.resultLocalSortOriginalMongoDocuments ? clonePlain(run.resultLocalSortOriginalMongoDocuments) : undefined,
resultLocalSortOriginalMongoCopyDocuments: run.resultLocalSortOriginalMongoCopyDocuments ? clonePlain(run.resultLocalSortOriginalMongoCopyDocuments) : undefined,
resultSessionId: undefined,
}));
}
function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult | undefined {
if (!result) return undefined;
const columnValues = result.columns.map((_, colIndex) => result.rows.map((row) => row[colIndex] ?? null));
return removeUndefinedFields({
columns: [...result.columns],
execution_error: result.execution_error,
statement_index: result.statement_index,
column_types: result.column_types ? [...result.column_types] : undefined,
local_column_filters: cloneLocalColumnFilters(result.local_column_filters),
spatial_columns: result.spatial_columns?.map((entry) => ({ column_index: entry.column_index, srid: entry.srid })),
spatial_values: result.spatial_values?.map((row) => [...row]),
large_value_cells: result.large_value_cells?.map((cell) => ({ ...cell })),
columnValues,
rowCount: result.rows.length,
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
mongo_copy_documents: result.mongo_copy_documents ? clonePlain(result.mongo_copy_documents) : undefined,
affected_rows: result.affected_rows,
execution_time_ms: result.execution_time_ms,
truncated: result.truncated,
has_more: result.has_more,
sourceLabel: result.sourceLabel,
sourceStatement: result.sourceStatement,
sourceFrom: result.sourceFrom,
sourceTo: result.sourceTo,
});
}
function fromColumnarResult(result: ColumnarQueryResult | undefined): QueryResult | undefined {
if (!result) return undefined;
const rows = Array.from({ length: result.rowCount }, (_, rowIndex) => result.columnValues.map((values) => values[rowIndex] ?? null));
return {
columns: [...result.columns],
execution_error: result.execution_error,
statement_index: result.statement_index,
column_types: result.column_types ? [...result.column_types] : undefined,
local_column_filters: cloneLocalColumnFilters(result.local_column_filters),
spatial_columns: result.spatial_columns?.map((entry) => ({ column_index: entry.column_index, srid: entry.srid })),
spatial_values: result.spatial_values?.map((row) => [...row]),
large_value_cells: result.large_value_cells?.map((cell) => ({ ...cell })),
rows,
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
mongo_copy_documents: result.mongo_copy_documents ? clonePlain(result.mongo_copy_documents) : undefined,
affected_rows: result.affected_rows,
execution_time_ms: result.execution_time_ms,
truncated: result.truncated,
session_id: undefined,
has_more: result.has_more,
sourceLabel: result.sourceLabel,
sourceStatement: result.sourceStatement,
sourceFrom: result.sourceFrom,
sourceTo: result.sourceTo,
};
}
function snapshotToPayload(snapshot: TabResultSnapshot): TabResultSnapshotPayload {
return removeUndefinedFields({
...snapshot,
result: toColumnarResult(snapshot.result),
results: snapshot.results?.map((result) => toColumnarResult(result)!),
resultRuns: snapshot.resultRuns?.map((run) =>
removeUndefinedFields({
...run,
result: toColumnarResult(run.result),
results: run.results?.map((result) => toColumnarResult(result)!),
}),
),
});
}
function payloadToSnapshot(payload: TabResultSnapshotPayload): TabResultSnapshot {
return {
...payload,
result: fromColumnarResult(payload.result),
results: payload.results?.map((result) => fromColumnarResult(result)!),
resultRuns: payload.resultRuns?.map((run) => ({
...run,
result: fromColumnarResult(run.result),
results: run.results?.map((result) => fromColumnarResult(result)!),
})),
};
}
function resultStats(snapshot: TabResultSnapshot): { rowCount: number; columnCount: number } {
const activeRun = snapshot.resultRuns?.find((run) => run.id === snapshot.activeResultRunId) ?? snapshot.resultRuns?.[0];
const result = snapshot.result ?? snapshot.results?.[snapshot.activeResultIndex ?? 0] ?? snapshot.results?.[0] ?? activeRun?.result ?? activeRun?.results?.[activeRun.activeResultIndex ?? 0] ?? activeRun?.results?.[0];
return {
rowCount: result?.rows.length ?? 0,
columnCount: result?.columns.length ?? 0,
};
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}
return btoa(binary);
}
function base64ToBytes(value: string): Uint8Array {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes;
}
function canUseRemoteRuntimeCache(): boolean {
return typeof btoa !== "undefined" && typeof atob !== "undefined" && (isTauriRuntime() || typeof fetch !== "undefined");
}
async function writeRemoteRuntimeCache(key: string, bytes: Uint8Array, stats: { rowCount: number; columnCount: number }, ownerId?: string): Promise<boolean> {
if (!canUseRemoteRuntimeCache()) return false;
try {
if (isTauriRuntime()) {
const { invoke } = await import("@tauri-apps/api/core");
await invoke("save_tab_runtime_cache", {
key,
payloadBase64: bytesToBase64(bytes),
rowCount: stats.rowCount,
columnCount: stats.columnCount,
ownerId,
});
return true;
}
const response = await fetch(apiUrl("/api/tab-runtime-cache"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key,
payloadBase64: bytesToBase64(bytes),
rowCount: stats.rowCount,
columnCount: stats.columnCount,
ownerId,
}),
});
return response.ok;
} catch (error) {
console.warn("[DBX][tab-result-cache:remote-write:error]", { key, error });
return false;
}
}
function normalizeRuntimeMetadata(entry: Record<string, unknown>): ResultCacheMetadata {
return {
key: String(entry.key ?? ""),
rowCount: Number(entry.rowCount ?? 0),
columnCount: Number(entry.columnCount ?? 0),
byteSize: Number(entry.byteSize ?? 0),
createdAt: Number(entry.createdAt ?? 0),
lastAccessedAt: Number(entry.lastAccessedAt ?? 0),
ownerId: typeof entry.ownerId === "string" ? entry.ownerId : undefined,
};
}
async function listRemoteRuntimeCacheMetadata(): Promise<ResultCacheMetadata[]> {
if (!canUseRemoteRuntimeCache()) return [];
if (isTauriRuntime()) {
const { invoke } = await import("@tauri-apps/api/core");
const entries = await invoke<Record<string, unknown>[]>("list_tab_runtime_cache_metadata");
return entries.map(normalizeRuntimeMetadata);
}
const response = await fetch(apiUrl("/api/tab-runtime-cache/metadata"));
if (!response.ok) return [];
return ((await response.json()) as Record<string, unknown>[]).map(normalizeRuntimeMetadata);
}
async function pruneRemoteRuntimeCache(options: ResultCachePruneOptions): Promise<ResultCachePruneResult> {
if (isTauriRuntime()) {
const { invoke } = await import("@tauri-apps/api/core");
return invoke<ResultCachePruneResult>("prune_tab_runtime_cache", { request: options });
}
const response = await fetch(apiUrl("/api/tab-runtime-cache/prune"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(options),
});
if (!response.ok) throw new Error(`Runtime cache prune failed: ${response.status}`);
return response.json() as Promise<ResultCachePruneResult>;
}
async function deleteRemoteRuntimeCacheOwner(ownerId: string): Promise<void> {
try {
if (isTauriRuntime()) {
const { invoke } = await import("@tauri-apps/api/core");
await invoke("delete_tab_runtime_cache_owner", { ownerId });
return;
}
await fetch(apiUrl(`/api/tab-runtime-cache/owner?owner_id=${encodeURIComponent(ownerId)}`), { method: "DELETE" });
} catch {
// Cache deletion is best-effort; the entry expires server-side anyway.
}
}
async function readRemoteRuntimeCache(key: string): Promise<Uint8Array | undefined> {
if (!canUseRemoteRuntimeCache()) return undefined;
try {
if (isTauriRuntime()) {
const { invoke } = await import("@tauri-apps/api/core");
const entry = await invoke<{ payloadBase64?: string } | null>("load_tab_runtime_cache", { key });
return entry?.payloadBase64 ? base64ToBytes(entry.payloadBase64) : undefined;
}
const response = await fetch(apiUrl(`/api/tab-runtime-cache?key=${encodeURIComponent(key)}`));
if (!response.ok) return undefined;
const entry = (await response.json()) as { payloadBase64?: string } | null;
return entry?.payloadBase64 ? base64ToBytes(entry.payloadBase64) : undefined;
} catch (error) {
console.warn("[DBX][tab-result-cache:remote-read:error]", { key, error });
return undefined;
}
}
async function deleteRemoteRuntimeCache(key: string): Promise<void> {
if (!canUseRemoteRuntimeCache()) return;
try {
if (isTauriRuntime()) {
const { invoke } = await import("@tauri-apps/api/core");
await invoke("delete_tab_runtime_cache", { key });
return;
}
await fetch(apiUrl(`/api/tab-runtime-cache?key=${encodeURIComponent(key)}`), { method: "DELETE" });
} catch {
// Best-effort delete (the entry expires server-side). Deliberately silent: logging
// here after a vitest run finishes races worker teardown and has failed CI runs
// ("Closing rpc while onUserConsoleLog was pending").
}
}
const indexedDbBackend: ResultCacheBackend = {
name: "indexed-db",
available: () => indexedDb() !== undefined,
read: readIndexedDbCache,
write: writeIndexedDbCache,
delete: deleteIndexedDbCache,
listMetadata: listIndexedDbCacheMetadata,
prune: pruneIndexedDbCache,
deleteOwner: deleteIndexedDbCacheOwner,
};
const runtimeBackend: ResultCacheBackend = {
name: "runtime",
available: canUseRemoteRuntimeCache,
read: readRemoteRuntimeCache,
write: writeRemoteRuntimeCache,
delete: deleteRemoteRuntimeCache,
listMetadata: listRemoteRuntimeCacheMetadata,
prune: pruneRemoteRuntimeCache,
deleteOwner: deleteRemoteRuntimeCacheOwner,
};
export interface ResultCacheRuntimeConfig {
primary: ResultCacheBackendName;
fallbackEnabled: boolean;
}
export function resultCacheRuntimeConfig(tauriRuntime = isTauriRuntime(), environment = import.meta.env): ResultCacheRuntimeConfig {
const configured = environment.VITE_DBX_RESULT_CACHE_BACKEND;
const primary = tauriRuntime || configured === "http" || configured === "runtime" ? "runtime" : "indexed-db";
return { primary, fallbackEnabled: environment.VITE_DBX_RESULT_CACHE_FALLBACK !== "false" };
}
export function resultCacheBackendOrder(tauriRuntime = isTauriRuntime(), config = resultCacheRuntimeConfig(tauriRuntime)): ResultCacheBackendName[] {
return config.primary === "runtime" ? ["runtime", "indexed-db"] : ["indexed-db", "runtime"];
}
function availableResultCacheBackends(includeLegacyFallback = true): ResultCacheBackend[] {
const byName: Record<ResultCacheBackendName, ResultCacheBackend> = {
"indexed-db": indexedDbBackend,
runtime: runtimeBackend,
};
const config = resultCacheRuntimeConfig();
const order = resultCacheBackendOrder(isTauriRuntime(), config);
const selected = includeLegacyFallback || config.fallbackEnabled ? order : order.slice(0, 1);
return selected.map((name) => byName[name]).filter((backend) => backend.available());
}
export async function writeResultCacheBackends(backends: ResultCacheBackend[], key: string, bytes: Uint8Array, stats: { rowCount: number; columnCount: number }, isCurrent: () => boolean = () => true, ownerId?: string): Promise<boolean> {
for (const backend of backends) {
if (!isCurrent()) return false;
try {
if (await backend.write(key, bytes, stats, ownerId)) return true;
} catch (error) {
console.warn("[DBX][tab-result-cache:write:error]", { key, backend: backend.name, error });
}
}
return false;
}
export async function readResultCacheBackends(backends: ResultCacheBackend[], key: string): Promise<{ bytes: Uint8Array; backend: ResultCacheBackend } | undefined> {
for (const backend of backends) {
try {
const bytes = await backend.read(key);
if (bytes) return { bytes, backend };
} catch (error) {
console.warn("[DBX][tab-result-cache:read:error]", { key, backend: backend.name, error });
}
}
return undefined;
}
export async function promoteFallbackResultCacheRead(backends: ResultCacheBackend[], key: string, cached: { bytes: Uint8Array; backend: ResultCacheBackend }, stats: { rowCount: number; columnCount: number }, ownerId?: string): Promise<boolean> {
const primary = backends[0];
if (!primary || cached.backend === primary) return false;
return primary.write(key, cached.bytes, stats, ownerId);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function removeUndefinedFields<T>(value: T): T {
if (Array.isArray(value)) return value.map((item) => removeUndefinedFields(item)) as T;
if (!isRecord(value)) return value;
return Object.fromEntries(
Object.entries(value)
.filter(([, entryValue]) => entryValue !== undefined)
.map(([key, entryValue]) => [key, removeUndefinedFields(entryValue)]),
) as T;
}
function isBinaryPayload(value: unknown): value is Uint8Array {
return value instanceof Uint8Array || value instanceof ArrayBuffer;
}
export function encodeTabResultSnapshot(snapshot: TabResultSnapshot): Uint8Array {
const stats = resultStats(snapshot);
const envelope: TabResultCacheEnvelope = {
magic: PAYLOAD_MAGIC,
version: PAYLOAD_VERSION,
codec: PAYLOAD_CODEC,
cachedAt: snapshot.cachedAt,
rowCount: stats.rowCount,
columnCount: stats.columnCount,
payload: snapshotToPayload(snapshot),
};
return encode(removeUndefinedFields(envelope));
}
export function decodeTabResultSnapshot(bytes: Uint8Array | ArrayBuffer): TabResultSnapshot | undefined {
let decoded: unknown;
try {
decoded = decode(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
} catch {
resultCacheDiagnostics.corruptSnapshots += 1;
return undefined;
}
if (!isRecord(decoded)) return undefined;
if (decoded.magic !== PAYLOAD_MAGIC || decoded.version !== PAYLOAD_VERSION || decoded.codec !== PAYLOAD_CODEC) {
return undefined;
}
if (!isRecord(decoded.payload)) return undefined;
// SAFETY: The validated envelope is produced by encodeTabResultSnapshot, so its record payload has the snapshot shape expected here.
return payloadToSnapshot(decoded.payload as unknown as TabResultSnapshotPayload);
}
export function tabResultCacheKey(tabId: string): string {
return `tab:${tabId}:result`;
}
export function buildTabResultSnapshot(tab: QueryTab): TabResultSnapshot | undefined {
if (!tab.result && !tab.results && !tab.resultRuns?.length) return undefined;
return {
result: stripSessionIds(tab.result),
results: stripResultSessionIds(tab.results),
activeResultIndex: tab.activeResultIndex,
resultEditorFingerprint: tab.resultEditorFingerprint,
resultLocalSortOriginalRows: tab.resultLocalSortOriginalRows?.map((row) => [...row]),
resultLocalSortOriginalMongoDocuments: tab.resultLocalSortOriginalMongoDocuments ? clonePlain(tab.resultLocalSortOriginalMongoDocuments) : undefined,
resultLocalSortOriginalMongoCopyDocuments: tab.resultLocalSortOriginalMongoCopyDocuments ? clonePlain(tab.resultLocalSortOriginalMongoCopyDocuments) : undefined,
resultRuns: stripResultRunSessionIds(tab.resultRuns),
activeResultRunId: tab.activeResultRunId,
resultViewGeneration: tab.resultViewGeneration,
queryAnalysis: tab.queryAnalysis ? clonePlain(tab.queryAnalysis) : undefined,
querySourceColumns: tab.querySourceColumns ? [...tab.querySourceColumns] : undefined,
queryWriteTargets: tab.queryWriteTargets?.map((target) => ({ ...target, sourceColumns: [...target.sourceColumns] })),
resultColumnComments: tab.resultColumnComments ? clonePlain(tab.resultColumnComments) : undefined,
queryDisplaySourceColumns: tab.queryDisplaySourceColumns ? [...tab.queryDisplaySourceColumns] : undefined,
queryEditabilityReason: tab.queryEditabilityReason,
mongoEditTarget: tab.mongoEditTarget ? clonePlain(tab.mongoEditTarget) : undefined,
tableMeta: tab.tableMeta ? clonePlain(tab.tableMeta) : undefined,
resultPageSql: tab.resultPageSql,
resultPageLimit: tab.resultPageLimit,
resultPageOffset: tab.resultPageOffset,
resultCountSql: tab.resultCountSql,
resultTotalRowCount: tab.resultTotalRowCount,
cachedAt: Date.now(),
};
}
function afterActiveResultPaint(): Promise<void> {
if (typeof window === "undefined" || typeof requestAnimationFrame === "undefined") return Promise.resolve();
return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
resolve();
};
requestAnimationFrame(finish);
// Background WebViews and test DOMs may throttle animation frames indefinitely.
setTimeout(finish, 100);
});
}
export async function writeTabResultSnapshot(key: string, snapshot: TabResultSnapshot | undefined, ownerId?: string): Promise<boolean> {
if (!snapshot) return false;
const version = bumpCacheKeyVersion(key);
await afterActiveResultPaint();
if (!isCurrentCacheKeyVersion(key, version)) return false;
const startedAt = typeof performance === "undefined" ? Date.now() : performance.now();
const encoded = encodeTabResultSnapshot(snapshot);
const finishedAt = typeof performance === "undefined" ? Date.now() : performance.now();
resultCacheDiagnostics.serializationCount += 1;
resultCacheDiagnostics.serializationDurationMs += finishedAt - startedAt;
resultCacheDiagnostics.serializedBytes += encoded.byteLength;
const stats = resultStats(snapshot);
try {
return await writeResultCacheBackends(availableResultCacheBackends(false), key, encoded, stats, () => isCurrentCacheKeyVersion(key, version), ownerId);
} finally {
clearCacheKeyVersionIfCurrent(key, version);
}
}
export async function readTabResultSnapshot(key: string): Promise<TabResultSnapshot | undefined> {
const backends = availableResultCacheBackends(true);
const cached = await readResultCacheBackends(backends, key);
if (!cached) return undefined;
const snapshot = decodeTabResultSnapshot(cached.bytes);
if (!snapshot) return undefined;
void promoteFallbackResultCacheRead(backends, key, cached, resultStats(snapshot));
return snapshot;
}
export async function deleteTabResultSnapshot(key: string): Promise<void> {
const version = bumpCacheKeyVersion(key);
const clearVersionLater = () => {
const cleanup = () => clearCacheKeyVersionIfCurrent(key, version);
if (typeof window !== "undefined") window.setTimeout(cleanup, 5000);
else setTimeout(cleanup, 5000);
};
try {
await Promise.all(availableResultCacheBackends().map((backend) => backend.delete(key)));
clearVersionLater();
} catch (error) {
console.warn("[DBX][tab-result-cache:delete:error]", { key, error });
clearVersionLater();
}
}
export async function pruneTabResultSnapshots(liveKeys: Iterable<string>, options: Partial<Omit<ResultCachePruneOptions, "liveKeys">> = {}): Promise<ResultCachePruneResult | undefined> {
const primary = availableResultCacheBackends(false)[0];
if (!primary) return undefined;
const result = await primary.prune({
liveKeys: [...new Set(liveKeys)],
maxBytes: options.maxBytes ?? DEFAULT_PERSISTENT_CACHE_BYTES,
orphanGraceMs: options.orphanGraceMs ?? DEFAULT_ORPHAN_GRACE_MS,
maxAgeMs: options.maxAgeMs ?? DEFAULT_MAX_AGE_MS,
});
resultCacheDiagnostics.cacheBytes = result.remainingBytes;
resultCacheDiagnostics.evictions += result.deletedEntries;
resultCacheDiagnostics.orphanDeletions += result.orphanDeletions;
return result;
}
export async function deleteTabResultSnapshotsForOwner(ownerId: string): Promise<void> {
await Promise.all(availableResultCacheBackends(true).map((backend) => backend.deleteOwner(ownerId)));
}