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

@@ -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 {