fix(editor): protect external SQL files from conflicting changes

This commit is contained in:
zipg authored and GitHub committed 2026-08-06 20:13:59 +08:00
1 parent 575d87c022
commit 89a35d6e02
29 files changed
+1125 -65

No files matched your search

@@ -57,4 +57,56 @@ describe("openTabsPersistence originalSql round-trip", () => {
expect(restored.database).toBe("dbx_catalog_completion");
expect(restored.catalog).toBe("dbx_mysql_catalog");
});
it("preserves external file versions and acknowledged state across tab restore", () => {
const version = { sizeBytes: 9, modifiedNs: "100", contentHash: "original" };
const ignoredVersion = { sizeBytes: 9, modifiedNs: "200", contentHash: "changed" };
const [restored] = roundTrip([
queryTab({
sql: "SELECT 1",
originalSql: "SELECT 1",
externalSqlPath: "/tmp/query.sql",
externalSqlFileVersion: version,
externalSqlIgnoredFileVersion: ignoredVersion,
externalSqlFileMissing: true,
}),
]);
expect(restored.externalSqlFileVersion).toEqual(version);
expect(restored.externalSqlIgnoredFileVersion).toEqual(ignoredVersion);
expect(restored.externalSqlFileMissing).toBe(true);
});
it("preserves the disk baseline for a dirty external file after an ignored change", () => {
const version = { sizeBytes: 9, modifiedNs: "100", contentHash: "original" };
const ignoredVersion = { sizeBytes: 9, modifiedNs: "200", contentHash: "changed" };
const [restored] = roundTrip([
queryTab({
sql: "SELECT 2",
originalSql: "SELECT 1",
externalSqlPath: "/tmp/query.sql",
externalSqlFileVersion: version,
externalSqlIgnoredFileVersion: ignoredVersion,
}),
]);
expect(restored.sql).toBe("SELECT 2");
expect(restored.originalSql).toBe("SELECT 1");
expect(restored.externalSqlIgnoredFileVersion).toEqual(ignoredVersion);
});
it("preserves the disk baseline for a dirty external file acknowledged as missing", () => {
const [restored] = roundTrip([
queryTab({
sql: "SELECT 2",
originalSql: "SELECT 1",
externalSqlPath: "/tmp/query.sql",
externalSqlFileMissing: true,
}),
]);
expect(restored.sql).toBe("SELECT 2");
expect(restored.originalSql).toBe("SELECT 1");
expect(restored.externalSqlFileMissing).toBe(true);
});
});
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { externalSqlFileContentMatchesBaseline, externalSqlFileMetadataMatches, externalSqlFileVersionWasIgnored } from "@/lib/sql/externalSqlFileChanges";
import type { ExternalSqlFileSnapshot } from "@/lib/backend/tauri";
import type { QueryTab } from "@/types/database";
const originalVersion = {
sizeBytes: 9,
modifiedNs: "100",
contentHash: "original",
};
const changedSnapshot: ExternalSqlFileSnapshot = {
content: "select 2;",
version: {
sizeBytes: 9,
modifiedNs: "200",
contentHash: "changed",
},
};
function tab(overrides: Partial<QueryTab> = {}): QueryTab {
return {
id: "tab-1",
title: "demo.sql",
connectionId: "",
database: "",
sql: "select 1;",
originalSql: "select 1;",
externalSqlPath: "/tmp/demo.sql",
externalSqlFileVersion: originalVersion,
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "query",
...overrides,
};
}
describe("external SQL file change detection", () => {
it("uses size and precise modification time for the metadata fast path", () => {
expect(externalSqlFileMetadataMatches(originalVersion, { kind: "present", sizeBytes: 9, modifiedNs: "100" })).toBe(true);
expect(externalSqlFileMetadataMatches(originalVersion, { kind: "present", sizeBytes: 9, modifiedNs: "101" })).toBe(false);
expect(externalSqlFileMetadataMatches(originalVersion, { kind: "missing" })).toBe(false);
});
it("treats identical raw content or identical decoded baseline text as unchanged", () => {
expect(externalSqlFileContentMatchesBaseline(tab(), { ...changedSnapshot, content: "select 1;" })).toBe(true);
expect(externalSqlFileContentMatchesBaseline(tab(), { ...changedSnapshot, version: originalVersion })).toBe(true);
expect(externalSqlFileContentMatchesBaseline(tab(), changedSnapshot)).toBe(false);
});
it("suppresses only the exact external version the user kept", () => {
expect(externalSqlFileVersionWasIgnored(tab({ externalSqlIgnoredFileVersion: changedSnapshot.version }), changedSnapshot)).toBe(true);
expect(externalSqlFileVersionWasIgnored(tab({ externalSqlIgnoredFileVersion: originalVersion }), changedSnapshot)).toBe(false);
});
});
@@ -26,6 +26,9 @@ export interface SavedOpenTab {
originalSql?: string;
savedSqlId?: string;
externalSqlPath?: string;
externalSqlFileVersion?: QueryTab["externalSqlFileVersion"];
externalSqlIgnoredFileVersion?: QueryTab["externalSqlIgnoredFileVersion"];
externalSqlFileMissing?: boolean;
lastExecutedSql?: string;
resultBaseSql?: string;
resultSortedSql?: string;
@@ -74,7 +77,7 @@ function shouldPersistTabSql(tab: QueryTab) {
function restoredOriginalSql(tab: SavedOpenTab, mode: QueryTab["mode"], sql: string) {
if (mode !== "query") return undefined;
if (tab.externalSqlPath) return sql;
if (tab.externalSqlPath) return tab.originalSql ?? sql;
if (tab.savedSqlId) return sql ? "" : undefined;
// Prefer the persisted originalSql so a clean prefilled query tab (sql === originalSql)
// restores clean instead of being marked dirty. Older saved state without this field
@@ -93,12 +96,14 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
...(tab.catalog !== undefined ? { catalog: tab.catalog } : {}),
schema: tab.schema,
sql: shouldPersistTabSql(tab) ? tab.sql : "",
// Only round-trip originalSql for plain query tabs (no savedSqlId / externalSqlPath):
// saved-SQL and external-file tabs re-derive it on restore, and persisting it here
// would duplicate their (potentially large) SQL text in the open-tabs state.
...(tab.originalSql !== undefined && !tab.savedSqlId && !tab.externalSqlPath ? { originalSql: tab.originalSql } : {}),
// Plain query tabs always round-trip originalSql. External-file tabs only persist it
// while dirty so their disk baseline survives restart without duplicating clean SQL.
...(tab.originalSql !== undefined && !tab.savedSqlId && (!tab.externalSqlPath || tab.sql !== tab.originalSql) ? { originalSql: tab.originalSql } : {}),
savedSqlId: tab.savedSqlId,
externalSqlPath: tab.externalSqlPath,
...(tab.externalSqlFileVersion ? { externalSqlFileVersion: tab.externalSqlFileVersion } : {}),
...(tab.externalSqlIgnoredFileVersion ? { externalSqlIgnoredFileVersion: tab.externalSqlIgnoredFileVersion } : {}),
...(tab.externalSqlFileMissing ? { externalSqlFileMissing: true } : {}),
...(tab.lastExecutedSql !== undefined ? { lastExecutedSql: tab.lastExecutedSql } : {}),
...(tab.resultBaseSql !== undefined ? { resultBaseSql: tab.resultBaseSql } : {}),
...(tab.resultSortedSql !== undefined ? { resultSortedSql: tab.resultSortedSql } : {}),
@@ -9,7 +9,13 @@ vi.mock("@tauri-apps/api/core", () => ({
invoke: mocks.invoke,
}));
import { readExternalSqlFile } from "@/lib/backend/tauri";
import { inspectExternalSqlFile, readExternalSqlFile, readExternalSqlFileSnapshot, writeExternalSqlFile } from "@/lib/backend/tauri";
const version = {
sizeBytes: 9,
modifiedNs: "123000000",
contentHash: "abc123",
};
describe("external SQL file API", () => {
beforeEach(() => {
@@ -17,12 +23,49 @@ describe("external SQL file API", () => {
});
it("returns editor content from the structured backend response", async () => {
mocks.invoke.mockResolvedValue({ kind: "content", content: "select 1;" });
mocks.invoke.mockResolvedValue({ kind: "content", content: "select 1;", version });
await expect(readExternalSqlFile("/tmp/demo.sql")).resolves.toBe("select 1;");
expect(mocks.invoke).toHaveBeenCalledWith("read_external_sql_file", { path: "/tmp/demo.sql" });
});
it("returns the disk version with an editor snapshot", async () => {
mocks.invoke.mockResolvedValue({ kind: "content", content: "select 1;", version });
await expect(readExternalSqlFileSnapshot("/tmp/demo.sql")).resolves.toEqual({ content: "select 1;", version });
});
it("inspects metadata without reading editor content", async () => {
mocks.invoke.mockResolvedValue({ kind: "present", sizeBytes: 9, modifiedNs: "123000000" });
await expect(inspectExternalSqlFile("/tmp/demo.sql")).resolves.toEqual({ kind: "present", sizeBytes: 9, modifiedNs: "123000000" });
expect(mocks.invoke).toHaveBeenCalledWith("inspect_external_sql_file", { path: "/tmp/demo.sql" });
});
it("passes the expected version to checked writes", async () => {
mocks.invoke.mockResolvedValue({ kind: "written", version });
await expect(writeExternalSqlFile("/tmp/demo.sql", "select 2;", { expectedContentHash: "abc123" })).resolves.toEqual({ kind: "written", version });
expect(mocks.invoke).toHaveBeenCalledWith("write_external_sql_file", {
path: "/tmp/demo.sql",
content: "select 2;",
expectedContentHash: "abc123",
expectedMissing: false,
});
});
it("passes an expected-missing precondition to recreate writes", async () => {
mocks.invoke.mockResolvedValue({ kind: "written", version });
await expect(writeExternalSqlFile("/tmp/demo.sql", "select 2;", { expectedMissing: true })).resolves.toEqual({ kind: "written", version });
expect(mocks.invoke).toHaveBeenCalledWith("write_external_sql_file", {
path: "/tmp/demo.sql",
content: "select 2;",
expectedContentHash: null,
expectedMissing: true,
});
});
it("maps oversized responses to a typed frontend error", async () => {
mocks.invoke.mockResolvedValue({ kind: "tooLarge", sizeBytes: 50 * 1024 ** 3, maxSizeBytes: 64 * 1024 ** 2 });
+2
View File
@@ -333,6 +333,8 @@ export const pendingOpenSqlFiles = forward("pendingOpenSqlFiles");
export const pendingOpenDbFiles = forward("pendingOpenDbFiles");
export const pendingOpenConnectionLinks = forward("pendingOpenConnectionLinks");
export const readExternalSqlFile = forward("readExternalSqlFile");
export const readExternalSqlFileSnapshot = forward("readExternalSqlFileSnapshot");
export const inspectExternalSqlFile = forward("inspectExternalSqlFile");
export const writeExternalSqlFile = forward("writeExternalSqlFile");
export const saveExternalSqlFile = forward("saveExternalSqlFile");
export const listSqlFilesInFolder = forward("listSqlFilesInFolder");
+10 -2
View File
@@ -1864,11 +1864,19 @@ export async function readExternalSqlFile(_path: string): Promise<string> {
throw new Error("Opening external SQL file paths is only available in the desktop app");
}
export async function writeExternalSqlFile(_path: string, _content: string): Promise<void> {
export async function readExternalSqlFileSnapshot(_path: string): Promise<import("@/lib/backend/tauri").ExternalSqlFileSnapshot> {
throw new Error("Opening external SQL file paths is only available in the desktop app");
}
export async function inspectExternalSqlFile(_path: string): Promise<import("@/lib/backend/tauri").ExternalSqlFileStatus> {
throw new Error("Inspecting external SQL file paths is only available in the desktop app");
}
export async function writeExternalSqlFile(_path: string, _content: string, _options: { expectedContentHash?: string; expectedMissing?: boolean } = {}): Promise<import("@/lib/backend/tauri").ExternalSqlFileWriteResult> {
throw new Error("Saving external SQL file paths is only available in the desktop app");
}
export async function saveExternalSqlFile(_defaultFileName: string, _content: string): Promise<string | null> {
export async function saveExternalSqlFile(_defaultFileName: string, _content: string): Promise<{ path: string; version: import("@/types/database").ExternalSqlFileVersion } | null> {
throw new Error("Saving SQL files locally is only available in the desktop app");
}
+29 -6
View File
@@ -56,6 +56,7 @@ import type {
SshConfigHostEntry,
TunnelProfile,
TransactionLog,
ExternalSqlFileVersion,
} from "@/types/database";
import { isTauriCommandUnavailable, normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
import type { CollectionInfo } from "@/types/database";
@@ -754,19 +755,41 @@ export async function pendingOpenConnectionLinks(): Promise<string[]> {
return invoke("pending_open_connection_links");
}
export async function readExternalSqlFile(path: string): Promise<string> {
const result = await invoke<{ kind: "content"; content: string } | { kind: "tooLarge"; sizeBytes: number; maxSizeBytes: number }>("read_external_sql_file", { path });
export interface ExternalSqlFileSnapshot {
content: string;
version: ExternalSqlFileVersion;
}
export type ExternalSqlFileStatus = { kind: "present"; sizeBytes: number; modifiedNs: string } | { kind: "missing" };
export type ExternalSqlFileWriteResult = { kind: "written"; version: ExternalSqlFileVersion } | { kind: "conflict"; currentVersion: ExternalSqlFileVersion } | { kind: "missing" };
export async function readExternalSqlFileSnapshot(path: string): Promise<ExternalSqlFileSnapshot> {
const result = await invoke<{ kind: "content"; content: string; version: ExternalSqlFileVersion } | { kind: "tooLarge"; sizeBytes: number; maxSizeBytes: number }>("read_external_sql_file", { path });
if (result.kind === "tooLarge") {
throw new ExternalSqlFileTooLargeError(result.sizeBytes, result.maxSizeBytes);
}
return result.content;
return { content: result.content, version: result.version };
}
export async function writeExternalSqlFile(path: string, content: string): Promise<void> {
return invoke("write_external_sql_file", { path, content });
export async function readExternalSqlFile(path: string): Promise<string> {
return (await readExternalSqlFileSnapshot(path)).content;
}
export async function saveExternalSqlFile(defaultFileName: string, content: string): Promise<string | null> {
export async function inspectExternalSqlFile(path: string): Promise<ExternalSqlFileStatus> {
return invoke("inspect_external_sql_file", { path });
}
export async function writeExternalSqlFile(path: string, content: string, options: { expectedContentHash?: string; expectedMissing?: boolean } = {}): Promise<ExternalSqlFileWriteResult> {
return invoke("write_external_sql_file", {
path,
content,
expectedContentHash: options.expectedContentHash ?? null,
expectedMissing: options.expectedMissing ?? false,
});
}
export async function saveExternalSqlFile(defaultFileName: string, content: string): Promise<{ path: string; version: ExternalSqlFileVersion } | null> {
return invoke("save_external_sql_file", { defaultFileName, content });
}
@@ -0,0 +1,14 @@
import type { ExternalSqlFileStatus, ExternalSqlFileSnapshot } from "@/lib/backend/tauri";
import type { ExternalSqlFileVersion, QueryTab } from "@/types/database";
export function externalSqlFileMetadataMatches(version: ExternalSqlFileVersion | undefined, status: ExternalSqlFileStatus): boolean {
return !!version && status.kind === "present" && version.sizeBytes === status.sizeBytes && version.modifiedNs === status.modifiedNs;
}
export function externalSqlFileContentMatchesBaseline(tab: QueryTab, snapshot: ExternalSqlFileSnapshot): boolean {
return tab.externalSqlFileVersion?.contentHash === snapshot.version.contentHash || tab.originalSql === snapshot.content;
}
export function externalSqlFileVersionWasIgnored(tab: QueryTab, snapshot: ExternalSqlFileSnapshot): boolean {
return tab.externalSqlIgnoredFileVersion?.contentHash === snapshot.version.contentHash;
}
@@ -141,6 +141,7 @@ export function tabTooltipLines(tab: QueryTab, t: Translate): { label: string; v
}
if (tab.mode === "query" && tab.externalSqlPath) {
lines.push({ label: t("tabs.tooltipFilePath"), value: tab.externalSqlPath });
if (tab.externalSqlFileMissing) lines.push({ label: t("tabs.tooltipFileStatus"), value: t("tabs.externalFileMissing") });
}
if (tab.mode === "data" && tab.tableMeta?.tableName) {
lines.push({ label: t("tabs.tooltipTable"), value: tab.tableMeta.tableName });