fix(editor): protect external SQL files from conflicting changes
This commit is contained in:
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 });
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user