dbx/apps/desktop/src/lib/tabResultCache.ts

453 lines
16 KiB
TypeScript

import type { QueryResult, QueryTab } from "@/types/database";
import { decode, encode } from "@msgpack/msgpack";
import { toRaw } from "vue";
import { isTauriRuntime } from "@/lib/tauriRuntime";
const DB_NAME = "dbx-tab-runtime-cache";
const DB_VERSION = 1;
const RESULT_STORE = "resultSnapshots";
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;
resultRuns?: QueryTab["resultRuns"];
activeResultRunId?: string;
queryAnalysis?: QueryTab["queryAnalysis"];
querySourceColumns?: QueryTab["querySourceColumns"];
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[];
column_types?: string[];
columnValues: CellValue[][];
rowCount: number;
affected_rows: number;
execution_time_ms: number;
truncated?: boolean;
has_more?: boolean;
sourceLabel?: string;
sourceStatement?: string;
}
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;
}
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);
};
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;
}
function clonePlain<T>(value: T): T {
const raw = toRaw(value);
if (typeof structuredClone === "function") return structuredClone(raw);
return JSON.parse(JSON.stringify(raw)) as T;
}
function stripSessionIds(result: QueryResult | undefined): QueryResult | undefined {
if (!result) return undefined;
return {
columns: [...result.columns],
column_types: result.column_types ? [...result.column_types] : undefined,
rows: result.rows.map((row) => [...row]),
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,
};
}
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),
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],
column_types: result.column_types ? [...result.column_types] : undefined,
columnValues,
rowCount: result.rows.length,
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,
});
}
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],
column_types: result.column_types ? [...result.column_types] : undefined,
rows,
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,
};
}
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 }): 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,
});
return true;
}
const response = await fetch("/api/tab-runtime-cache", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key,
payloadBase64: bytesToBase64(bytes),
rowCount: stats.rowCount,
columnCount: stats.columnCount,
}),
});
return response.ok;
} catch (error) {
console.warn("[DBX][tab-result-cache:remote-write:error]", { key, error });
return false;
}
}
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(`/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(`/api/tab-runtime-cache?key=${encodeURIComponent(key)}`, { method: "DELETE" });
} catch (error) {
console.warn("[DBX][tab-result-cache:remote-delete:error]", { key, error });
}
}
function scheduleRemoteRuntimeCacheWrite(key: string, bytes: Uint8Array, stats: { rowCount: number; columnCount: number }, version: number) {
window.setTimeout(async () => {
if (!isCurrentCacheKeyVersion(key, version)) return;
await writeRemoteRuntimeCache(key, bytes, stats);
clearCacheKeyVersionIfCurrent(key, version);
}, 0);
}
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 {
const decoded = decode(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
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;
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,
resultRuns: stripResultRunSessionIds(tab.resultRuns),
activeResultRunId: tab.activeResultRunId,
queryAnalysis: tab.queryAnalysis ? clonePlain(tab.queryAnalysis) : undefined,
querySourceColumns: tab.querySourceColumns ? [...tab.querySourceColumns] : 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(),
};
}
export async function writeTabResultSnapshot(key: string, snapshot: TabResultSnapshot | undefined): Promise<boolean> {
if (!snapshot) return false;
const version = bumpCacheKeyVersion(key);
const encoded = encodeTabResultSnapshot(snapshot);
const stats = resultStats(snapshot);
let wroteLocal = false;
try {
const db = await openCacheDb();
if (db) {
const tx = db.transaction(RESULT_STORE, "readwrite");
await requestToPromise(tx.objectStore(RESULT_STORE).put(encoded, key));
wroteLocal = true;
}
} catch (error) {
console.warn("[DBX][tab-result-cache:write:error]", { key, error });
}
if (wroteLocal) {
if (typeof window === "undefined") {
void writeRemoteRuntimeCache(key, encoded, stats).finally(() => clearCacheKeyVersionIfCurrent(key, version));
} else {
scheduleRemoteRuntimeCacheWrite(key, encoded, stats, version);
}
return true;
}
if (!isCurrentCacheKeyVersion(key, version)) return false;
try {
return await writeRemoteRuntimeCache(key, encoded, stats);
} finally {
clearCacheKeyVersionIfCurrent(key, version);
}
}
export async function readTabResultSnapshot(key: string): Promise<TabResultSnapshot | undefined> {
try {
const db = await openCacheDb();
if (db) {
const value = await requestToPromise(db.transaction(RESULT_STORE, "readonly").objectStore(RESULT_STORE).get(key));
if (isBinaryPayload(value)) return decodeTabResultSnapshot(value);
if (value) return value as TabResultSnapshot;
}
const remoteBytes = await readRemoteRuntimeCache(key);
const remoteSnapshot = remoteBytes ? decodeTabResultSnapshot(remoteBytes) : undefined;
if (remoteBytes && remoteSnapshot && db) {
void requestToPromise(db.transaction(RESULT_STORE, "readwrite").objectStore(RESULT_STORE).put(remoteBytes, key));
}
return remoteSnapshot;
} catch (error) {
console.warn("[DBX][tab-result-cache:read:error]", { key, error });
const remoteBytes = await readRemoteRuntimeCache(key);
return remoteBytes ? decodeTabResultSnapshot(remoteBytes) : undefined;
}
}
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 {
const db = await openCacheDb();
if (db) await requestToPromise(db.transaction(RESULT_STORE, "readwrite").objectStore(RESULT_STORE).delete(key));
await deleteRemoteRuntimeCache(key);
clearVersionLater();
} catch (error) {
console.warn("[DBX][tab-result-cache:delete:error]", { key, error });
await deleteRemoteRuntimeCache(key);
clearVersionLater();
}
}