refactor(desktop): organize lib modules by domain
This commit is contained in:
1 parent
90f0594e93
commit
7e7c4944e9
700 files changed
+1292
-1264
No files matched your search
@@ -0,0 +1,140 @@
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
import { decodeTabResultSnapshot, encodeTabResultSnapshot, type TabResultSnapshot } from "@/lib/tabs/tabResultCache";
|
||||
|
||||
const ARCHIVE_MAGIC = "DBX_QUERY_RESULT_ARCHIVE";
|
||||
const ARCHIVE_VERSION = 1;
|
||||
const ARCHIVE_CODEC = "msgpack-tab-result-snapshot";
|
||||
|
||||
export interface QueryResultArchiveTab {
|
||||
title: string;
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
sql: string;
|
||||
lastExecutedSql?: string;
|
||||
resultBaseSql?: string;
|
||||
resultSortedSql?: string;
|
||||
}
|
||||
|
||||
export interface DecodedQueryResultArchive {
|
||||
createdAt: number;
|
||||
tab: QueryResultArchiveTab;
|
||||
snapshot: TabResultSnapshot;
|
||||
}
|
||||
|
||||
interface QueryResultArchiveEnvelope {
|
||||
magic: typeof ARCHIVE_MAGIC;
|
||||
version: typeof ARCHIVE_VERSION;
|
||||
codec: typeof ARCHIVE_CODEC;
|
||||
createdAt: number;
|
||||
tab: QueryResultArchiveTab;
|
||||
snapshot: Uint8Array;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isBinaryLike(value: unknown): boolean {
|
||||
return value instanceof ArrayBuffer || ArrayBuffer.isView(value);
|
||||
}
|
||||
|
||||
function removeUndefinedFields<T>(value: T): T {
|
||||
if (Array.isArray(value)) return value.map((item) => removeUndefinedFields(item)) as T;
|
||||
if (isBinaryLike(value)) return value;
|
||||
if (!isRecord(value)) return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([, entryValue]) => entryValue !== undefined)
|
||||
.map(([key, entryValue]) => [key, removeUndefinedFields(entryValue)]),
|
||||
) as T;
|
||||
}
|
||||
|
||||
function archiveTabMetadata(tab: QueryTab): QueryResultArchiveTab {
|
||||
return removeUndefinedFields({
|
||||
title: tab.title,
|
||||
connectionId: tab.connectionId,
|
||||
database: tab.database,
|
||||
schema: tab.schema,
|
||||
sql: tab.sql,
|
||||
lastExecutedSql: tab.lastExecutedSql,
|
||||
resultBaseSql: tab.resultBaseSql,
|
||||
resultSortedSql: tab.resultSortedSql,
|
||||
});
|
||||
}
|
||||
|
||||
function isArchiveTab(value: unknown): value is QueryResultArchiveTab {
|
||||
if (!isRecord(value)) return false;
|
||||
return typeof value.title === "string" && typeof value.connectionId === "string" && typeof value.database === "string" && typeof value.sql === "string";
|
||||
}
|
||||
|
||||
function binaryPayload(value: unknown): Uint8Array | undefined {
|
||||
if (value instanceof Uint8Array) return value;
|
||||
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function transformBytes(bytes: Uint8Array, stream: CompressionStream | DecompressionStream): Promise<Uint8Array> {
|
||||
const output = new Response(stream.readable).arrayBuffer();
|
||||
const writer = stream.writable.getWriter();
|
||||
await writer.write(bytes.slice());
|
||||
await writer.close();
|
||||
return new Uint8Array(await output);
|
||||
}
|
||||
|
||||
async function gzipBytes(bytes: Uint8Array): Promise<Uint8Array> {
|
||||
if (typeof CompressionStream === "undefined") return bytes;
|
||||
try {
|
||||
return await transformBytes(bytes, new CompressionStream("gzip"));
|
||||
} catch {
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
async function gunzipBytes(bytes: Uint8Array): Promise<Uint8Array> {
|
||||
if (bytes[0] !== 0x1f || bytes[1] !== 0x8b || typeof DecompressionStream === "undefined") return bytes;
|
||||
return transformBytes(bytes, new DecompressionStream("gzip"));
|
||||
}
|
||||
|
||||
export function defaultQueryResultArchiveFileName(title: string | undefined): string {
|
||||
const base = (title ?? "")
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9._-]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.slice(0, 80);
|
||||
return `${base || "query-results"}.dbxresults`;
|
||||
}
|
||||
|
||||
export async function encodeQueryResultArchive(tab: QueryTab, snapshot: TabResultSnapshot): Promise<Uint8Array> {
|
||||
const envelope: QueryResultArchiveEnvelope = {
|
||||
magic: ARCHIVE_MAGIC,
|
||||
version: ARCHIVE_VERSION,
|
||||
codec: ARCHIVE_CODEC,
|
||||
createdAt: Date.now(),
|
||||
tab: archiveTabMetadata(tab),
|
||||
snapshot: encodeTabResultSnapshot(snapshot),
|
||||
};
|
||||
return gzipBytes(encode(removeUndefinedFields(envelope)));
|
||||
}
|
||||
|
||||
export async function decodeQueryResultArchive(bytes: Uint8Array | ArrayBuffer): Promise<DecodedQueryResultArchive | undefined> {
|
||||
try {
|
||||
const rawBytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
const decoded = decode(await gunzipBytes(rawBytes));
|
||||
if (!isRecord(decoded)) return undefined;
|
||||
if (decoded.magic !== ARCHIVE_MAGIC || decoded.version !== ARCHIVE_VERSION || decoded.codec !== ARCHIVE_CODEC) return undefined;
|
||||
if (!isArchiveTab(decoded.tab)) return undefined;
|
||||
const snapshotBytes = binaryPayload(decoded.snapshot);
|
||||
if (!snapshotBytes) return undefined;
|
||||
const snapshot = decodeTabResultSnapshot(snapshotBytes);
|
||||
if (!snapshot) return undefined;
|
||||
return {
|
||||
createdAt: typeof decoded.createdAt === "number" ? decoded.createdAt : Date.now(),
|
||||
tab: decoded.tab,
|
||||
snapshot,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
|
||||
const ARCHIVE_MIME_TYPE = "application/vnd.dbx.results";
|
||||
const ARCHIVE_EXTENSIONS = ["dbxresults"];
|
||||
|
||||
function downloadArchiveFile(fileName: string, bytes: Uint8Array): string {
|
||||
const blob = new Blob([bytes.slice().buffer], { type: ARCHIVE_MIME_TYPE });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
return fileName;
|
||||
}
|
||||
|
||||
function openArchiveFileInBrowser(): Promise<Uint8Array | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".dbxresults,application/octet-stream,application/vnd.dbx.results";
|
||||
input.onchange = async () => {
|
||||
try {
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
resolve(new Uint8Array(await file.arrayBuffer()));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveQueryResultArchiveFile(fileName: string, bytes: Uint8Array): Promise<string | undefined> {
|
||||
if (!isTauriRuntime()) return downloadArchiveFile(fileName, bytes);
|
||||
|
||||
const [{ save }, { writeFile }] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
|
||||
const path = await save({
|
||||
defaultPath: fileName,
|
||||
filters: [{ name: "DBX Result Archive", extensions: ARCHIVE_EXTENSIONS }],
|
||||
});
|
||||
if (!path) return undefined;
|
||||
await writeFile(path, bytes);
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function openQueryResultArchiveFile(): Promise<Uint8Array | undefined> {
|
||||
if (!isTauriRuntime()) return openArchiveFileInBrowser();
|
||||
|
||||
const [{ open }, { readFile }] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [{ name: "DBX Result Archive", extensions: ARCHIVE_EXTENSIONS }],
|
||||
});
|
||||
const path = Array.isArray(selected) ? selected[0] : selected;
|
||||
if (!path) return undefined;
|
||||
return readFile(path);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
// Lake/external tables (e.g. Paimon in StarRocks) return this error on a data
|
||||
// read when no snapshot exists yet, while metadata reads (DESC/SHOW CREATE)
|
||||
// still succeed. executeTabSql surfaces query failures as an "Error" result, so
|
||||
// callers can detect this case and fall back to a structure-only (LIMIT 0)
|
||||
// preview instead of showing a cryptic server error.
|
||||
const NO_SNAPSHOT_ERROR_PATTERN = /there is currently no snapshot/i;
|
||||
|
||||
export function isNoSnapshotErrorResult(result: QueryResult | undefined | null): boolean {
|
||||
if (!result || !result.columns.includes("Error") || result.rows.length === 0) return false;
|
||||
return NO_SNAPSHOT_ERROR_PATTERN.test(String(result.rows[0]?.[0] ?? ""));
|
||||
}
|
||||
Reference in new issue
Block a user