feat(export): add SQL INSERT export crash diagnostics

This commit is contained in:
zipg authored and GitHub committed 2026-09-08 00:43:39 +08:00
1 parent 9ae2e70030
commit d44798f70f
10 files changed
+298 -5

No files matched your search

@@ -16,6 +16,8 @@ import { clearDataGridClipboardCopy, rememberDataGridClipboardCopy } from "@/lib
import { buildDataGridCopyInsertStatement, type DataGridCopyInsertMode, type DataGridTableMeta } from "@/lib/dataGrid/dataGridSql";
import { formatSqlInsert, formatTsv } from "@/lib/export/exportFormats";
import { showSqlInsertModeDialog, type SqlInsertMode } from "@/lib/export/sqlInsertMode";
import { summarizeExportRows } from "@/lib/export/exportDiagnostics";
import { appendDebugLog, appendNativeProcessMemoryLog, getBrowserMemorySnapshot, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { uuid } from "@/lib/common/utils";
import { useSettingsStore } from "@/stores/settingsStore";
import { expandNestedJsonStringsForCopy } from "@/lib/common/jsonCopyValue";
@@ -1334,18 +1336,76 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function exportSql(rowIds?: number[]) {
await runExclusiveExport(async () => {
const exportId = uuid();
const exportStartedAt = performance.now();
const logExportStage = (stage: string, details: Record<string, unknown> = {}, sampleNativeMemory = false) => {
if (!isDebugLoggingEnabled()) return;
appendDebugLog("info", `[DBX][export:sql:${stage}]`, {
exportId,
elapsedMs: Math.round(performance.now() - exportStartedAt),
...details,
browserMemory: getBrowserMemorySnapshot(),
});
if (sampleNativeMemory) void appendNativeProcessMemoryLog(`export-sql-${stage}`, { exportId });
};
logExportStage(
"start",
{
context: context.value,
databaseType: databaseType.value,
exportAllRows: rowIds === undefined,
requestedRowCount: rowIds?.length ?? null,
hasCompleteLocalResult: hasCompleteLocalResult?.value ?? null,
},
true,
);
const insertMode = await showSqlInsertModeDialog();
if (insertMode === null) return;
if (insertMode === null) {
logExportStage("cancelled", { stage: "insert-mode-dialog" });
return;
}
logExportStage("mode-selected", { insertMode });
try {
// Step 1: table-data context — existing backend table export
if (await exportFullTableDataViaBackend("sql", rowIds, "name", true, insertMode)) return;
logExportStage("backend-export-start");
const handledByBackend = await exportFullTableDataViaBackend("sql", rowIds, "name", true, insertMode);
logExportStage("backend-export-finished", { handledByBackend });
if (handledByBackend) {
logExportStage("done", { path: "backend" });
return;
}
// Step 2: query-result context — NEW backend streaming with background task
if (await exportQueryResultSqlViaBackend(rowIds, insertMode)) return;
logExportStage("query-backend-export-start");
const handledQueryByBackend = await exportQueryResultSqlViaBackend(rowIds, insertMode);
logExportStage("query-backend-export-finished", { handledByBackend: handledQueryByBackend });
if (handledQueryByBackend) {
logExportStage("done", { path: "query-backend" });
return;
}
// Step 3: fallback — local export (Web and edge-case scenarios)
const result = await resultToExport(rowIds, undefined, true, false);
logExportStage(
"result-ready",
{
columns: result.columns.length,
rows: result.rows.length,
values: isDebugLoggingEnabled() ? summarizeExportRows(result.rows) : undefined,
},
true,
);
logExportStage("row-remap-start");
const exportData = sqlInsertExportData(result);
logExportStage("row-remap-done", {
columns: exportData.columns.length,
rows: exportData.rows.length,
values: isDebugLoggingEnabled() ? summarizeExportRows(exportData.rows) : undefined,
});
logExportStage("sql-build-start");
const content = await formatSqlInsert({
databaseType: databaseType.value,
identifierQuote: options.identifierQuote?.value,
@@ -1358,10 +1418,23 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
rows: exportData.rows,
insertMode,
});
await saveTextFile(content, exportFileName(tableMeta.value?.tableName || "export", "sql", { preferFallback: true }), "SQL", "sql");
logExportStage(
"sql-build-done",
{
sqlChars: content.length,
},
true,
);
logExportStage("save-start", { contentChars: content.length }, true);
await saveTextFile(content, exportFileName(tableMeta.value?.tableName || "export", "sql", { preferFallback: true }), "SQL", "sql", { exportId, operation: "sql-insert-all" });
logExportStage("done", { contentChars: content.length });
toast(t("grid.exported"));
} catch (e: any) {
toast(t("grid.exportFailed", { message: translateBackendError(t, e) }), 5000);
logExportStage("error", {
errorName: e?.name || typeof e,
errorMessage: e?.message || String(e),
});
}
});
}
+44
View File
@@ -12,6 +12,21 @@ interface DebugLogEntry {
message: string;
}
export interface BrowserMemorySnapshot {
jsHeapUsedBytes?: number;
jsHeapTotalBytes?: number;
jsHeapLimitBytes?: number;
deviceMemoryGiB?: number;
hardwareConcurrency?: number;
}
export interface NativeProcessMemorySnapshot {
residentBytes: number;
virtualBytes: number;
totalMemoryBytes: number;
usedMemoryBytes: number;
}
let installed = false;
let originalConsole: Partial<Record<DebugLogLevel, (...args: unknown[]) => void>> = {};
@@ -115,6 +130,35 @@ export function appendDebugLog(level: DebugLogLevel, ...args: unknown[]) {
safeLocalStorageSet(DEBUG_LOG_ENTRIES_KEY, JSON.stringify(entries.slice(-MAX_DEBUG_LOG_ENTRIES)));
}
export function getBrowserMemorySnapshot(): BrowserMemorySnapshot {
const snapshot: BrowserMemorySnapshot = {};
const memory = (typeof performance !== "undefined" ? performance : undefined) as
| (Performance & {
memory?: { usedJSHeapSize?: number; totalJSHeapSize?: number; jsHeapSizeLimit?: number };
})
| undefined;
if (memory?.memory?.usedJSHeapSize !== undefined) snapshot.jsHeapUsedBytes = memory.memory.usedJSHeapSize;
if (memory?.memory?.totalJSHeapSize !== undefined) snapshot.jsHeapTotalBytes = memory.memory.totalJSHeapSize;
if (memory?.memory?.jsHeapSizeLimit !== undefined) snapshot.jsHeapLimitBytes = memory.memory.jsHeapSizeLimit;
if (typeof navigator !== "undefined") {
const deviceMemory = (navigator as Navigator & { deviceMemory?: number }).deviceMemory;
if (deviceMemory !== undefined) snapshot.deviceMemoryGiB = deviceMemory;
if (navigator.hardwareConcurrency) snapshot.hardwareConcurrency = navigator.hardwareConcurrency;
}
return snapshot;
}
export async function appendNativeProcessMemoryLog(event: string, context: Record<string, unknown> = {}) {
if (!isDebugLoggingEnabled() || !isTauriRuntimeLike()) return;
try {
const { invoke } = await import("@tauri-apps/api/core");
const memory = await invoke<NativeProcessMemorySnapshot>("get_process_memory_info");
appendDebugLog("info", `[DBX][native-memory:${event}]`, { ...context, ...memory });
} catch (error) {
appendDebugLog("warn", `[DBX][native-memory:${event}:error]`, { ...context, error });
}
}
export function setDebugLoggingEnabled(enabled: boolean) {
safeLocalStorageSet(DEBUG_LOG_ENABLED_KEY, enabled ? "1" : "0");
if (enabled) {
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { summarizeExportRows } from "@/lib/export/exportDiagnostics";
describe("summarizeExportRows", () => {
it("reports shape, value kinds, and large-cell sizes without retaining values", () => {
const large = "中".repeat(1024 * 1024);
const summary = summarizeExportRows([[null, "ok", 1, true], [large]]);
expect(summary.rowCount).toBe(2);
expect(summary.cellCount).toBe(5);
expect(summary.nullCellCount).toBe(1);
expect(summary.stringCellCount).toBe(2);
expect(summary.numberCellCount).toBe(1);
expect(summary.booleanCellCount).toBe(1);
expect(summary.cellsAtLeast1MiB).toBe(1);
expect(summary.rowsAtLeast1MiB).toBe(1);
expect(summary.maxValueUtf8Bytes).toBe(3 * 1024 * 1024);
expect(summary.minCellsPerRow).toBe(1);
expect(summary.maxCellsPerRow).toBe(4);
});
it("handles empty and ragged results", () => {
expect(summarizeExportRows([])).toMatchObject({ rowCount: 0, cellCount: 0, minCellsPerRow: 0, maxCellsPerRow: 0 });
expect(summarizeExportRows([[], ["x"]])).toMatchObject({ rowCount: 2, cellCount: 1, minCellsPerRow: 0, maxCellsPerRow: 1 });
});
});
@@ -0,0 +1,102 @@
import type { CellValue } from "@/lib/dataGrid/cellValue";
export interface ExportRowsDiagnostics {
rowCount: number;
cellCount: number;
nullCellCount: number;
stringCellCount: number;
numberCellCount: number;
booleanCellCount: number;
totalValueChars: number;
totalValueUtf8Bytes: number;
maxValueChars: number;
maxValueUtf8Bytes: number;
cellsAtLeast1MiB: number;
cellsAtLeast10MiB: number;
rowsAtLeast1MiB: number;
minCellsPerRow: number;
maxCellsPerRow: number;
}
const MEBIBYTE = 1024 * 1024;
function utf8ByteLength(value: string): number {
let bytes = 0;
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 0x7f) bytes += 1;
else if (code <= 0x7ff) bytes += 2;
else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) {
const next = value.charCodeAt(index + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
bytes += 4;
index += 1;
} else bytes += 3;
} else bytes += 3;
}
return bytes;
}
export function summarizeExportRows(rows: readonly (readonly CellValue[])[]): ExportRowsDiagnostics {
let cellCount = 0;
let nullCellCount = 0;
let stringCellCount = 0;
let numberCellCount = 0;
let booleanCellCount = 0;
let totalValueChars = 0;
let totalValueUtf8Bytes = 0;
let maxValueChars = 0;
let maxValueUtf8Bytes = 0;
let cellsAtLeast1MiB = 0;
let cellsAtLeast10MiB = 0;
let rowsAtLeast1MiB = 0;
let minCellsPerRow = rows.length === 0 ? 0 : Number.POSITIVE_INFINITY;
let maxCellsPerRow = 0;
for (const row of rows) {
minCellsPerRow = Math.min(minCellsPerRow, row.length);
maxCellsPerRow = Math.max(maxCellsPerRow, row.length);
let rowHasLargeCell = false;
for (const value of row) {
cellCount += 1;
if (value === null) {
nullCellCount += 1;
continue;
}
if (typeof value === "string") stringCellCount += 1;
else if (typeof value === "number") numberCellCount += 1;
else booleanCellCount += 1;
const text = String(value);
const utf8Bytes = utf8ByteLength(text);
totalValueChars += text.length;
totalValueUtf8Bytes += utf8Bytes;
maxValueChars = Math.max(maxValueChars, text.length);
maxValueUtf8Bytes = Math.max(maxValueUtf8Bytes, utf8Bytes);
if (utf8Bytes >= MEBIBYTE) {
cellsAtLeast1MiB += 1;
rowHasLargeCell = true;
}
if (utf8Bytes >= 10 * MEBIBYTE) cellsAtLeast10MiB += 1;
}
if (rowHasLargeCell) rowsAtLeast1MiB += 1;
}
return {
rowCount: rows.length,
cellCount,
nullCellCount,
stringCellCount,
numberCellCount,
booleanCellCount,
totalValueChars,
totalValueUtf8Bytes,
maxValueChars,
maxValueUtf8Bytes,
cellsAtLeast1MiB,
cellsAtLeast10MiB,
rowsAtLeast1MiB,
minCellsPerRow: Number.isFinite(minCellsPerRow) ? minCellsPerRow : 0,
maxCellsPerRow,
};
}
+18 -1
View File
@@ -1,6 +1,20 @@
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { appendDebugLog, getBrowserMemorySnapshot, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
export async function saveTextFile(content: string, defaultFileName: string, filterName: string, filterExt: string) {
export async function saveTextFile(content: string, defaultFileName: string, filterName: string, filterExt: string, diagnostics: { exportId?: string; operation?: string } = {}) {
const logSaveStage = (stage: string, details: Record<string, unknown> = {}) => {
if (!isDebugLoggingEnabled()) return;
appendDebugLog("info", `[DBX][export:save:${stage}]`, {
...diagnostics,
filterName,
filterExt,
contentChars: content.length,
...details,
browserMemory: getBrowserMemorySnapshot(),
});
};
logSaveStage("start");
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
@@ -8,7 +22,9 @@ export async function saveTextFile(content: string, defaultFileName: string, fil
defaultPath: defaultFileName,
filters: [{ name: filterName, extensions: [filterExt] }],
});
logSaveStage("dialog-result", { selected: !!path });
if (path) await writeTextFile(path, content);
if (path) logSaveStage("write-done");
return;
}
@@ -19,6 +35,7 @@ export async function saveTextFile(content: string, defaultFileName: string, fil
a.download = defaultFileName;
a.click();
URL.revokeObjectURL(url);
logSaveStage("browser-download-triggered");
}
export function sanitizeExportBaseName(value: string): string {