fix(saved-sql): scope SQL names and guard target persistence

This commit is contained in:
二丫讲梵
2026-09-17 22:35:56 +08:00
committed by GitHub
parent d1a2d51351
commit 58c1435d55
8 changed files with 715 additions and 73 deletions
+6
View File
@@ -2743,18 +2743,23 @@ async function changeActiveConnection(tabId: string, connectionId: string) {
if (!connection) return;
const initialDatabase = resolveDefaultDatabase(connection, []);
queryStore.updateConnection(tab.id, connectionId, initialDatabase);
let isCurrentTarget = queryStore.createExecutionTargetGuard(tab.id);
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId, database: initialDatabase, catalog: undefined, schema: undefined });
connectionStore.activeConnectionId = connectionId;
try {
await connectionStore.ensureConnected(connectionId);
if (!isCurrentTarget()) return;
const options = await getDatabaseOptions(connectionId);
if (!isCurrentTarget()) return;
const database = resolveDefaultDatabase(connection, options);
queryStore.updateDatabase(tab.id, database);
isCurrentTarget = queryStore.createExecutionTargetGuard(tab.id);
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId, database, catalog: undefined, schema: undefined });
if (connection.default_schema || connection.db_type === "oracle") {
try {
// A configured default wins. Otherwise Oracle returns the session's current schema first.
const orderedSchemas = connection.default_schema ? [] : await api.listSchemas(connectionId, database);
if (!isCurrentTarget()) return;
const schema = schemaAfterConnectionSwitch(connection.db_type, orderedSchemas, connection.default_schema);
const latestTab = queryStore.tabs.find((candidate) => candidate.id === tab.id);
if (schema && latestTab && latestTab.connectionId === connectionId) {
@@ -2766,6 +2771,7 @@ async function changeActiveConnection(tabId: string, connectionId: string) {
}
}
} catch (e: any) {
if (!isCurrentTarget()) return;
toast(
t("connection.connectFailed", {
message: translateBackendError(t, e),
@@ -21,10 +21,11 @@ import { focusSidebarRenameInput } from "@/lib/sidebar/sidebarRenameFocus";
import { savedSqlFolderBranchFileCount } from "@/lib/savedSql/savedSqlFolderCounts";
import { collectSavedSqlDirectoryImportFiles } from "@/lib/savedSql/savedSqlDirectoryImport";
import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
import { savedSqlDatabaseScopeKey } from "@/lib/savedSql/savedSqlDatabaseTree";
import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName";
import { savedSqlImportTarget } from "@/lib/savedSql/savedSqlImportTarget";
import { savedSqlExecutionTargetFromTab, type SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget";
import { exportSavedSqlFileContent } from "@/lib/savedSql/savedSqlExport";
import { uniqueSavedSqlExportFileName, exportSavedSqlFileContent } from "@/lib/savedSql/savedSqlExport";
import { orderedListRangeAnchorIndex, orderedListSelectionIntent } from "@/lib/selection/orderedListSelection";
import { resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
import type { SavedSqlFile, SavedSqlFolder } from "@/types/database";
@@ -96,6 +97,10 @@ function uniqueImportedName(name: string, takenNames: Set<string>) {
}
}
function savedSqlImportNameScopeKey(target: Pick<SavedSqlFile, "connectionId" | "catalog" | "database">, folderId?: string) {
return JSON.stringify([savedSqlDatabaseScopeKey(target), folderId || null]);
}
async function exportSingleFile(file: SavedSqlFile) {
try {
const loadedFile = await savedSqlStore.ensureFileContent(file.id);
@@ -136,10 +141,11 @@ async function exportFolderContents(folder?: SavedSqlFolder) {
await mkdir(childDir, { recursive: true });
await writeFolder(child, childDir);
}
const exportedNames = new Set<string>();
for (const file of savedSqlStore.filesInFolder(libraryFolder.id)) {
const loadedFile = await savedSqlStore.ensureFileContent(file.id);
if (!loadedFile) continue;
const filePath = await join(dir, sanitizeFileSystemSegment(ensureSqlExtension(file.name)));
const filePath = await join(dir, uniqueSavedSqlExportFileName(file.name, exportedNames));
await writeTextFile(filePath, loadedFile.sql);
}
};
@@ -157,10 +163,11 @@ async function exportFolderContents(folder?: SavedSqlFolder) {
if (unfiled.length > 0) {
const unfiledDir = await join(rootDir, sanitizeFileSystemSegment(t("sqlLibrary.unfiled")));
await mkdir(unfiledDir, { recursive: true });
const exportedNames = new Set<string>();
for (const file of unfiled) {
const loadedFile = await savedSqlStore.ensureFileContent(file.id);
if (!loadedFile) continue;
const filePath = await join(unfiledDir, sanitizeFileSystemSegment(ensureSqlExtension(file.name)));
const filePath = await join(unfiledDir, uniqueSavedSqlExportFileName(file.name, exportedNames));
await writeTextFile(filePath, loadedFile.sql);
}
}
@@ -218,18 +225,19 @@ async function importDirectoryIntoLibrary(targetFolder?: SavedSqlFolder) {
}
const folderCache = new Map<string, SavedSqlFolder>();
const takenNamesByFolder = new Map<string, Set<string>>();
const takenNamesByScope = new Map<string, Set<string>>();
const folderConnectionId = targetFolder?.connectionId ?? "";
for (const file of importFiles) {
const sourceTarget = resolveExternalSqlFileTarget(file.path, (connectionId) => !!connectionStore.getConfig(connectionId), unassociatedExternalSqlFileTarget());
const importTarget = savedSqlImportTarget(sourceTarget, targetFolder);
const folderId = await resolveImportedFolder(folderConnectionId, targetFolder?.id, file.folderNames, folderCache);
const folderKey = folderId || "";
let takenNames = takenNamesByFolder.get(folderKey);
const nameScopeKey = savedSqlImportNameScopeKey(importTarget, folderId);
let takenNames = takenNamesByScope.get(nameScopeKey);
if (!takenNames) {
takenNames = new Set((folderId ? savedSqlStore.filesInFolder(folderId) : savedSqlStore.filesWithoutFolder()).map((savedFile) => savedFile.name));
takenNamesByFolder.set(folderKey, takenNames);
const filesInTargetFolder = folderId ? savedSqlStore.filesInFolder(folderId) : savedSqlStore.filesWithoutFolder();
takenNames = new Set(filesInTargetFolder.filter((savedFile) => savedSqlDatabaseScopeKey(savedFile) === savedSqlDatabaseScopeKey(importTarget)).map((savedFile) => savedFile.name));
takenNamesByScope.set(nameScopeKey, takenNames);
}
const path = file.path;
const content = await api.readExternalSqlFile(path, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
@@ -445,7 +453,9 @@ async function openNewQueryInFolder(folder?: SavedSqlFolder) {
const connectionId = folder?.connectionId || connectionStore.activeConnectionId || connectionStore.connections[0]?.id;
if (!connectionId) return;
const takenNames = folder ? new Set(savedSqlStore.filesInFolder(folder.id).map((f) => f.name)) : new Set(savedSqlStore.filesWithoutFolder().map((f) => f.name));
const target = { connectionId, database: "" };
const filesInTargetFolder = folder ? savedSqlStore.filesInFolder(folder.id) : savedSqlStore.filesWithoutFolder();
const takenNames = new Set(filesInTargetFolder.filter((file) => savedSqlDatabaseScopeKey(file) === savedSqlDatabaseScopeKey(target)).map((file) => file.name));
const name = uniqueImportedName("new_query.sql", takenNames);
try {
const file = await savedSqlStore.saveFile({
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { uniqueSavedSqlExportFileName } from "@/lib/savedSql/savedSqlExport";
describe("saved SQL export names", () => {
it.each([
["report:daily.sql", "report:daily.sql", "report_daily (2).sql"],
["report_daily (2).sql", "report:daily.sql", "REPORT:DAILY.SQL"],
["report/daily", "report\\daily", "report_daily.sql"],
])("keeps all contents when sanitized names and generated suffixes collide: %j", (...names) => {
const taken = new Set<string>();
const output = new Map<string, string>();
names.forEach((name, index) => {
const path = uniqueSavedSqlExportFileName(name, taken);
expect(path).not.toMatch(/[<>:"/\\|?*]/);
output.set(path.toLocaleLowerCase(), `SELECT ${index};`);
});
expect([...output.values()]).toEqual(["SELECT 0;", "SELECT 1;", "SELECT 2;"]);
});
it("preserves a safe filename and adds suffixes only for collisions", () => {
const taken = new Set<string>();
expect(uniqueSavedSqlExportFileName("Report.SQL", taken)).toBe("Report.SQL");
expect(uniqueSavedSqlExportFileName("report", taken)).toBe("report (2).sql");
expect(uniqueSavedSqlExportFileName("report", taken)).toBe("report (3).sql");
});
});
@@ -1,5 +1,5 @@
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { ensureSqlExtension } from "@/lib/savedSql/savedSqlFileName";
import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName";
export function savedSqlExportFileName(name: string): string {
return (
@@ -9,6 +9,19 @@ export function savedSqlExportFileName(name: string): string {
);
}
export function uniqueSavedSqlExportFileName(name: string, takenNames: Set<string>): string {
const normalized = savedSqlExportFileName(name);
const occupied = new Set([...takenNames].map((taken) => taken.toLocaleLowerCase()));
const base = stripSqlExtension(normalized);
let candidate = normalized;
let counter = 2;
while (occupied.has(candidate.toLocaleLowerCase())) {
candidate = `${base} (${counter++}).sql`;
}
takenNames.add(candidate);
return candidate;
}
export async function exportSavedSqlFileContent(sql: string, fileName: string): Promise<"saved" | "cancelled"> {
const defaultFileName = savedSqlExportFileName(fileName);
if (isTauriRuntime()) {
+31 -2
View File
@@ -4,6 +4,8 @@ import { isRedisMonitorCommand, startRedisMonitor } from "@/lib/redis/redisMonit
import { uuid } from "@/lib/common/utils";
import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useToast } from "@/composables/useToast";
import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
import { sanitizeTabPageUiState } from "@/lib/tabs/tabUiState";
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserFilter, ObjectBrowserViewport, ObjectSource, ObjectSourceKind, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
@@ -4945,17 +4947,43 @@ export const useQueryStore = defineStore("query", () => {
return true;
}
const savedSqlTargetRequests = new WeakMap<QueryTab, number>();
let savedSqlTargetPersistenceActive = true;
onScopeDispose(() => {
savedSqlTargetPersistenceActive = false;
});
function createExecutionTargetGuard(id: string): () => boolean {
const tab = tabs.value.find((candidate) => candidate.id === id);
const target = savedSqlExecutionTargetFromTab(tab);
if (!tab || !target) return () => false;
const revision = savedSqlTargetRequests.get(tab);
const savedSqlId = tab.savedSqlId;
return () =>
savedSqlTargetPersistenceActive && tabs.value.includes(tab) && savedSqlTargetRequests.get(tab) === revision && tab.savedSqlId === savedSqlId && tab.connectionId === target.connectionId && tab.database === target.database && tab.catalog === target.catalog && tab.schema === target.schema;
}
function persistSavedSqlExecutionTarget(tab: QueryTab, options: UpdateExecutionTargetOptions) {
const revision = (savedSqlTargetRequests.get(tab) ?? 0) + 1;
savedSqlTargetRequests.set(tab, revision);
if (options.persistSavedSqlTarget === false || tab.mode !== "query" || !tab.savedSqlId) return;
const savedSqlStore = useSavedSqlStore();
const savedSqlId = tab.savedSqlId;
void savedSqlStore
.updateFileExecutionTarget(tab.savedSqlId, {
.updateFileExecutionTarget(savedSqlId, {
connectionId: tab.connectionId,
database: tab.database,
catalog: tab.catalog,
schema: tab.schema,
})
.catch((error) => console.warn("[DBX][saved-sql:target:error]", error));
.catch((error) => {
console.warn("[DBX][saved-sql:target:error]", error);
// A failed older request must not undo a newer target selection.
if (!savedSqlTargetPersistenceActive || savedSqlTargetRequests.get(tab) !== revision || tab.savedSqlId !== savedSqlId || !tabs.value.includes(tab)) return;
const saved = savedSqlStore.getFile(savedSqlId);
if (saved) applySavedSqlExecutionTarget(tab, saved);
useToast().toast(i18n.global.t("savedSql.saveFailed", { message: savedSqlErrorMessage(error, i18n.global.t) }), 5000);
});
}
function updateDatabase(id: string, database: string, options: UpdateExecutionTargetOptions = {}) {
@@ -8711,6 +8739,7 @@ export const useQueryStore = defineStore("query", () => {
hydrateSavedSqlTabs,
togglePinnedTab,
reorderTab,
createExecutionTargetGuard,
updateDatabase,
updateCatalog,
updateSchema,
+55 -49
View File
@@ -45,8 +45,7 @@ interface SavedSqlNameScope {
}
interface PendingSavedSqlName {
ownerId: string;
count: number;
owners: Map<string, number>;
}
export class SavedSqlNameConflictError extends Error {
@@ -106,9 +105,8 @@ function savedSqlNameKey(name: string): string {
return ensureSqlExtension(name).toLocaleLowerCase();
}
function savedSqlNameScopeKey(file: Pick<SavedSqlNameScope, "connectionId" | "folderId">): string {
// Execution targets do not change the SQL library's folder-scoped names.
return JSON.stringify(["library-folder", file.connectionId, file.folderId || null]);
function savedSqlNameScopeKey(file: Pick<SavedSqlNameScope, "connectionId" | "catalog" | "database" | "folderId">): string {
return JSON.stringify(["library-folder", savedSqlDatabaseScopeKey(file), file.folderId || null]);
}
function savedSqlNameIdentity(file: SavedSqlNameScope): string {
@@ -156,6 +154,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
const fileTargetRevisions = new Map<string, number>();
const pendingFileTargetSaves = new Map<string, Promise<SavedSqlFile | undefined>>();
const persistedFileTargets = new Map<string, SavedSqlExecutionTargetInput & { updatedAt: string }>();
const persistedFileTargetNameReleases = new Map<string, () => void>();
const pendingNamesByScope = new Map<string, Map<string, PendingSavedSqlName>>();
const version = ref(0);
@@ -218,11 +217,13 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
return files.value.find((file) => file.id === id);
}
function reserveFileName(file: SavedSqlNameScope): () => void {
function reserveFileName(file: SavedSqlNameScope, options: { allowExisting?: boolean } = {}): () => void {
const scopeKey = savedSqlNameScopeKey(file);
const nameKey = savedSqlNameKey(file.name);
const persistedConflict = files.value.some((candidate) => candidate.id !== file.id && savedSqlNameScopeKey(candidate) === scopeKey && savedSqlNameKey(candidate.name) === nameKey);
if (persistedConflict) throw new SavedSqlNameConflictError(file.name);
if (!options.allowExisting) {
const persistedConflict = files.value.some((candidate) => candidate.id !== file.id && savedSqlNameScopeKey(candidate) === scopeKey && savedSqlNameKey(candidate.name) === nameKey);
if (persistedConflict) throw new SavedSqlNameConflictError(file.name);
}
let pendingNames = pendingNamesByScope.get(scopeKey);
if (!pendingNames) {
@@ -230,9 +231,9 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
pendingNamesByScope.set(scopeKey, pendingNames);
}
const pending = pendingNames.get(nameKey);
if (pending && pending.ownerId !== file.id) throw new SavedSqlNameConflictError(file.name);
if (pending) pending.count++;
else pendingNames.set(nameKey, { ownerId: file.id, count: 1 });
if (!options.allowExisting && pending && [...pending.owners.keys()].some((ownerId) => ownerId !== file.id)) throw new SavedSqlNameConflictError(file.name);
if (pending) pending.owners.set(file.id, (pending.owners.get(file.id) ?? 0) + 1);
else pendingNames.set(nameKey, { owners: new Map([[file.id, 1]]) });
let released = false;
return () => {
@@ -240,9 +241,11 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
released = true;
const currentNames = pendingNamesByScope.get(scopeKey);
const current = currentNames?.get(nameKey);
if (!current || current.ownerId !== file.id) return;
current.count--;
if (current.count <= 0) currentNames?.delete(nameKey);
const count = current?.owners.get(file.id);
if (!current || count === undefined) return;
if (count > 1) current.owners.set(file.id, count - 1);
else current.owners.delete(file.id);
if (current.owners.size === 0) currentNames?.delete(nameKey);
if (currentNames?.size === 0) pendingNamesByScope.delete(scopeKey);
};
}
@@ -363,6 +366,19 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
}
}
function setPersistedFileTarget(file: SavedSqlFile) {
const releaseName = reserveFileName(file, { allowExisting: true });
persistedFileTargetNameReleases.get(file.id)?.();
persistedFileTargetNameReleases.set(file.id, releaseName);
persistedFileTargets.set(file.id, {
connectionId: file.connectionId,
catalog: normalizedCatalog(file.catalog),
database: file.database,
schema: file.schema,
updatedAt: file.updatedAt,
});
}
function updateFileExecutionTarget(id: string, target: SavedSqlExecutionTargetInput): Promise<SavedSqlFile | undefined> {
const existing = getFile(id);
if (!existing) return Promise.resolve(undefined);
@@ -372,13 +388,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
}
if (!persistedFileTargets.has(id)) {
persistedFileTargets.set(id, {
connectionId: existing.connectionId,
catalog: normalizedCatalog(existing.catalog),
database: existing.database,
schema: existing.schema,
updatedAt: existing.updatedAt,
});
setPersistedFileTarget(existing);
}
const revision = (fileTargetRevisions.get(id) ?? 0) + 1;
fileTargetRevisions.set(id, revision);
@@ -391,38 +401,32 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
.then(async () => {
if (fileTargetRevisions.get(id) !== revision) return getFile(id);
const loaded = await ensureFileContent(id);
if (!loaded || fileTargetRevisions.get(id) !== revision) return getFile(id);
const candidate: SavedSqlFile = {
...loaded,
...normalizedTarget,
sqlLoaded: true,
updatedAt: nowIso(),
};
files.value = files.value.map((file) => (file.id === id ? candidate : file));
bumpVersion();
const persistedTarget = persistedFileTargets.get(id);
const persistedNameIdentity = persistedTarget
? savedSqlNameIdentity({
...candidate,
connectionId: persistedTarget.connectionId,
catalog: persistedTarget.catalog,
database: persistedTarget.database,
})
: savedSqlNameIdentity(candidate);
let releaseName: (() => void) | undefined;
try {
const loaded = await ensureFileContent(id);
if (!loaded || fileTargetRevisions.get(id) !== revision) return getFile(id);
const candidate: SavedSqlFile = {
...loaded,
...normalizedTarget,
sqlLoaded: true,
updatedAt: nowIso(),
};
files.value = files.value.map((file) => (file.id === id ? candidate : file));
bumpVersion();
const persistedTarget = persistedFileTargets.get(id);
const persistedNameIdentity = persistedTarget
? savedSqlNameIdentity({
...candidate,
connectionId: persistedTarget.connectionId,
catalog: persistedTarget.catalog,
database: persistedTarget.database,
})
: savedSqlNameIdentity(candidate);
if (persistedNameIdentity !== savedSqlNameIdentity(candidate)) releaseName = reserveFileName(candidate);
const saved = normalizeSavedSqlFile(await api.saveSavedSqlFile(candidate));
persistedFileTargets.set(id, {
connectionId: saved.connectionId,
catalog: normalizedCatalog(saved.catalog),
database: saved.database,
schema: saved.schema,
updatedAt: saved.updatedAt,
});
setPersistedFileTarget(saved);
if (fileTargetRevisions.get(id) !== revision) return getFile(id);
const current = getFile(id);
const persisted = { ...saved, sql: current?.sql ?? saved.sql, sqlLoaded: current?.sqlLoaded ?? true };
@@ -447,6 +451,8 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
if (pendingFileTargetSaves.get(id) !== save) return;
pendingFileTargetSaves.delete(id);
persistedFileTargets.delete(id);
persistedFileTargetNameReleases.get(id)?.();
persistedFileTargetNameReleases.delete(id);
};
void save.then(cleanup, cleanup);
return save;
+209
View File
@@ -0,0 +1,209 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createPinia, setActivePinia } from "pinia";
import ts from "typescript";
import { beforeEach, test, vi } from "vitest";
import { schemaAfterConnectionSwitch } from "../../apps/desktop/src/lib/schema/connectionSchemaInitialization.ts";
import { useQueryStore } from "../../apps/desktop/src/stores/queryStore.ts";
import { useSavedSqlStore } from "../../apps/desktop/src/stores/savedSqlStore.ts";
import type { SavedSqlFile } from "../../apps/desktop/src/types/database.ts";
const apiMock = vi.hoisted(() => ({
loadSavedSqlLibrary: vi.fn(),
loadSavedSqlFile: vi.fn(),
saveSavedSqlFile: vi.fn<(file: SavedSqlFile) => Promise<SavedSqlFile>>(),
closeClientConnectionSession: vi.fn(),
listSchemas: vi.fn(),
}));
const toastMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/backend/api", () => apiMock);
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: toastMock }) }));
const appSource = readFileSync(new URL("../../apps/desktop/src/App.vue", import.meta.url), "utf8");
const functionSource = appSource.match(/async function changeActiveConnection\(tabId: string, connectionId: string\) \{[\s\S]*?\n\}\n\nfunction changeActiveDatabase/)?.[0].replace(/\n\nfunction changeActiveDatabase$/, "");
assert.ok(functionSource);
const switchSource = ts.transpileModule(functionSource, { compilerOptions: { target: ts.ScriptTarget.ESNext } }).outputText;
function deferred<Value>() {
let resolve!: (value: Value) => void;
let reject!: (error: Error) => void;
const promise = new Promise<Value>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
beforeEach(() => {
setActivePinia(createPinia());
vi.resetAllMocks();
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [] });
apiMock.saveSavedSqlFile.mockImplementation(async (file) => file);
apiMock.closeClientConnectionSession.mockResolvedValue(undefined);
apiMock.listSchemas.mockResolvedValue(["new_schema"]);
});
async function setupSwitch(databaseType = "mysql") {
const savedSqlStore = useSavedSqlStore();
const original = await savedSqlStore.saveFile({ connectionId: "conn-1", database: "db-1", schema: "old_schema", name: "report.sql", sql: "SELECT 1;" });
const queryStore = useQueryStore();
const tabId = queryStore.openSavedSql(original, { targetMode: "saved" });
const tab = queryStore.tabs.find((candidate) => candidate.id === tabId)!;
const ensureConnected = vi.fn().mockResolvedValue(undefined);
const getDatabaseOptions = vi.fn().mockResolvedValue(["db-2"]);
const rememberExternalSqlFileTarget = vi.fn();
const connectionStore = {
getConfig: (id: string) => ({ id, db_type: databaseType }),
ensureConnected,
activeConnectionId: "conn-1",
};
const changeActiveConnection = new Function(
"resolveToolbarTab",
"connectionStore",
"resolveDefaultDatabase",
"queryStore",
"getDatabaseOptions",
"rememberExternalSqlFileTarget",
"schemaAfterConnectionSwitch",
"api",
"toast",
"t",
"translateBackendError",
`${switchSource}\nreturn changeActiveConnection;`,
)(
(id: string) => queryStore.tabs.find((candidate) => candidate.id === id),
connectionStore,
(_connection: unknown, options: string[]) => options[0] ?? "",
queryStore,
getDatabaseOptions,
rememberExternalSqlFileTarget,
schemaAfterConnectionSwitch,
apiMock,
toastMock,
(key: string) => key,
(_translate: unknown, error: Error) => error.message,
) as (id: string, connectionId: string) => Promise<void>;
return { savedSqlStore, original, queryStore, tab, tabId, ensureConnected, getDatabaseOptions, changeActiveConnection, rememberExternalSqlFileTarget };
}
test.each(["connect", "databases"])("a target-save rollback invalidates the pending %s continuation", async (stage) => {
const context = await setupSwitch();
await context.savedSqlStore.saveFile({ connectionId: "conn-2", database: "", name: "report.sql", sql: "SELECT 2;" });
const pending = deferred<string[]>();
if (stage === "connect") context.ensureConnected.mockReturnValue(pending.promise);
else context.getDatabaseOptions.mockReturnValue(pending.promise);
const change = context.changeActiveConnection(context.tabId, "conn-2");
await vi.waitFor(() => assert.equal(context.tab.connectionId, "conn-1"));
pending.resolve(["db-2"]);
await change;
assert.deepEqual([context.tab.connectionId, context.tab.database, context.tab.schema], ["conn-1", "db-1", "old_schema"]);
assert.deepEqual([context.savedSqlStore.getFile(context.original.id)?.connectionId, context.savedSqlStore.getFile(context.original.id)?.database], ["conn-1", "db-1"]);
assert.equal(toastMock.mock.calls.length, 1);
if (stage === "connect") assert.equal(context.getDatabaseOptions.mock.calls.length, 0);
});
test.each(["database", "catalog", "schema"])("a newer %s selection invalidates a pending connection default", async (selection) => {
const context = await setupSwitch();
const databases = deferred<string[]>();
context.getDatabaseOptions.mockReturnValue(databases.promise);
const change = context.changeActiveConnection(context.tabId, "conn-2");
await vi.waitFor(() => assert.equal(context.getDatabaseOptions.mock.calls.length, 1));
if (selection === "database") context.queryStore.updateDatabase(context.tabId, "chosen");
else if (selection === "catalog") context.queryStore.updateCatalog(context.tabId, "chosen", "catalog_db");
else context.queryStore.updateSchema(context.tabId, "chosen");
const target = [context.tab.connectionId, context.tab.database, context.tab.catalog, context.tab.schema];
databases.resolve(["db-2"]);
await change;
assert.deepEqual([context.tab.connectionId, context.tab.database, context.tab.catalog, context.tab.schema], target);
});
test("changing a connection away and back does not revive its older default lookup", async () => {
const context = await setupSwitch();
const databases = deferred<string[]>();
context.getDatabaseOptions.mockReturnValueOnce(databases.promise);
const change = context.changeActiveConnection(context.tabId, "conn-2");
await vi.waitFor(() => assert.equal(context.getDatabaseOptions.mock.calls.length, 1));
context.queryStore.updateConnection(context.tabId, "conn-3", "");
context.queryStore.updateConnection(context.tabId, "conn-2", "");
databases.resolve(["stale_database"]);
await change;
assert.deepEqual([context.tab.connectionId, context.tab.database], ["conn-2", ""]);
});
test("a newer database selection invalidates a pending schema lookup", async () => {
const context = await setupSwitch("oracle");
const schemas = deferred<string[]>();
apiMock.listSchemas.mockReturnValue(schemas.promise);
const change = context.changeActiveConnection(context.tabId, "conn-2");
await vi.waitFor(() => assert.equal(apiMock.listSchemas.mock.calls.length, 1));
context.queryStore.updateDatabase(context.tabId, "chosen");
schemas.resolve(["stale_schema"]);
await change;
assert.deepEqual([context.tab.database, context.tab.schema], ["chosen", undefined]);
});
test.each(["closed", "disposed"])("a %s tab no longer starts database metadata work", async (lifecycle) => {
const context = await setupSwitch();
const connect = deferred<void>();
context.ensureConnected.mockReturnValue(connect.promise);
const change = context.changeActiveConnection(context.tabId, "conn-2");
if (lifecycle === "closed") context.queryStore.tabs.splice(context.queryStore.tabs.indexOf(context.tab), 1);
else context.queryStore.$dispose();
connect.resolve();
await change;
assert.equal(context.getDatabaseOptions.mock.calls.length, 0);
});
test("a superseded connection error does not notify for the newer target", async () => {
const context = await setupSwitch();
const connect = deferred<void>();
context.ensureConnected.mockReturnValue(connect.promise);
const change = context.changeActiveConnection(context.tabId, "conn-2");
context.queryStore.updateConnection(context.tabId, "conn-3", "chosen");
connect.reject(new Error("stale connection failed"));
await change;
assert.equal(toastMock.mock.calls.length, 0);
});
test("a successful switch still initializes its database and schema", async () => {
const context = await setupSwitch("oracle");
await context.changeActiveConnection(context.tabId, "conn-2");
assert.deepEqual([context.tab.connectionId, context.tab.database, context.tab.schema], ["conn-2", "db-2", "new_schema"]);
assert.equal(toastMock.mock.calls.length, 0);
await vi.waitFor(() => assert.equal(context.savedSqlStore.getFile(context.original.id)?.schema, "new_schema"));
});
test("a current connection failure still notifies", async () => {
const context = await setupSwitch();
context.ensureConnected.mockRejectedValue(new Error("connection failed"));
await context.changeActiveConnection(context.tabId, "conn-2");
assert.equal(toastMock.mock.calls.length, 1);
assert.equal(context.getDatabaseOptions.mock.calls.length, 0);
});
test("a database target rollback invalidates the pending schema result", async () => {
const context = await setupSwitch("oracle");
const schemas = deferred<string[]>();
apiMock.listSchemas.mockReturnValue(schemas.promise);
apiMock.saveSavedSqlFile.mockImplementation(async (file) => {
if (file.database === "db-2") throw new Error("disk full");
return file;
});
const change = context.changeActiveConnection(context.tabId, "conn-2");
await vi.waitFor(() => assert.equal(toastMock.mock.calls.length, 1));
const target = [context.tab.connectionId, context.tab.database, context.tab.schema];
schemas.resolve(["stale_schema"]);
await change;
assert.deepEqual([context.tab.connectionId, context.tab.database, context.tab.schema], target);
});
test("an external SQL switch still remembers the initialized database and schema", async () => {
const context = await setupSwitch("oracle");
context.tab.savedSqlId = undefined;
context.tab.externalSqlPath = "/tmp/report.sql";
await context.changeActiveConnection(context.tabId, "conn-2");
assert.deepEqual(context.rememberExternalSqlFileTarget.mock.calls.at(-1), ["/tmp/report.sql", { connectionId: "conn-2", database: "db-2", catalog: undefined, schema: "new_schema" }]);
assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 1);
});
+355 -12
View File
@@ -2,9 +2,12 @@ import assert from "node:assert/strict";
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, test, vi } from "vitest";
import type { SavedSqlFile, SavedSqlFolder, SavedSqlLibrary } from "../../apps/desktop/src/types/database.ts";
import { useSavedSqlStore } from "../../apps/desktop/src/stores/savedSqlStore.ts";
import { SavedSqlNameConflictError, useSavedSqlStore } from "../../apps/desktop/src/stores/savedSqlStore.ts";
import { useQueryStore } from "../../apps/desktop/src/stores/queryStore.ts";
const toastMock = vi.hoisted(() => vi.fn());
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: toastMock }) }));
const apiMock = vi.hoisted(() => ({
loadSavedSqlLibrary: vi.fn<() => Promise<SavedSqlLibrary>>(),
loadSavedSqlFile: vi.fn<(id: string) => Promise<SavedSqlFile | null>>(),
@@ -631,7 +634,7 @@ test("renaming a saved SQL file allows the same name in another folder of the da
assert.equal(store.getFile("sql-1")?.name, "revenue.sql");
});
test("renaming a saved SQL file rejects the same name in its folder across catalogs", async () => {
test("renaming a saved SQL file allows the same name in its folder across catalogs", async () => {
const files: SavedSqlFile[] = [
{
id: "sql-hive",
@@ -660,10 +663,10 @@ test("renaming a saved SQL file rejects the same name in its folder across catal
const store = useSavedSqlStore();
await store.initFromStorage();
await assert.rejects(store.renameFile("sql-hive", "REPORT"), /already exists/);
await store.renameFile("sql-hive", "REPORT");
assert.equal(store.getFile("sql-hive")?.name, "draft.sql");
assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 0);
assert.equal(store.getFile("sql-hive")?.name, "REPORT.sql");
assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 1);
});
test("failed saved SQL rename releases the requested name for retry", async () => {
@@ -761,7 +764,7 @@ test("hydrates saved SQL before copying it to another database", async () => {
assert.equal(apiMock.loadSavedSqlFile.mock.calls.length, 1);
});
test("concurrent saved SQL pastes reserve different copy names in the same folder across databases", async () => {
test("concurrent saved SQL pastes can reuse copy names in different databases", async () => {
const source: SavedSqlFile = {
id: "sql-1",
connectionId: "conn-1",
@@ -778,7 +781,7 @@ test("concurrent saved SQL pastes reserve different copy names in the same folde
await store.initFromStorage();
const [first, second] = await Promise.all([store.copyFilesToDatabase([source.id], { connectionId: "conn-1", catalog: "hive", database: "analytics" }), store.copyFilesToDatabase([source.id], { connectionId: "conn-1", catalog: "hive", database: "other" })]);
assert.deepEqual([first[0]?.name, second[0]?.name].sort(), ["report_copy1.sql", "report_copy2.sql"]);
assert.deepEqual([first[0]?.name, second[0]?.name].sort(), ["report_copy1.sql", "report_copy1.sql"]);
assert.equal(first[0]?.catalog, "hive");
assert.equal(second[0]?.catalog, "hive");
});
@@ -989,14 +992,16 @@ test.each([
assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 1);
});
test.each(["", "other"])("creating a SQL file rejects the same folder name despite database %j", async (database) => {
test("creating a SQL file scopes names by connection, catalog, database, and folder", async () => {
const store = useSavedSqlStore();
await store.saveFile({ connectionId: "conn-1", folderId: "folder-1", name: "report.sql", database: "analytics", sql: "SELECT 1;" });
await store.saveFile({ connectionId: "conn-1", catalog: "hive", folderId: "folder-1", name: "report.sql", database: "analytics", sql: "SELECT 1;" });
await store.saveFile({ connectionId: "conn-1", catalog: "hive", folderId: "folder-1", name: "REPORT", database: "reporting", sql: "SELECT 2;" });
await store.saveFile({ connectionId: "conn-1", catalog: "iceberg", folderId: "folder-1", name: "report", database: "analytics", sql: "SELECT 3;" });
await assert.rejects(store.saveFile({ connectionId: "conn-1", folderId: "folder-1", name: "REPORT", database, sql: "SELECT 2;" }), /already exists/);
await assert.rejects(store.saveFile({ connectionId: "conn-1", catalog: "hive", folderId: "folder-1", name: "REPORT", database: "analytics", sql: "SELECT 4;" }), /already exists/);
assert.equal(store.files.length, 1);
assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 1);
assert.equal(store.files.length, 3);
assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 3);
});
test("same SQL names remain independent across connections in the root folder", async () => {
@@ -1034,3 +1039,341 @@ test("SQL copy names ignore files in other folders of the same database", async
assert.equal(copy?.name, "report_copy1.sql");
assert.equal(copy?.folderId, "folder-1");
});
test.each(["database", "catalog"])("a conflicting saved SQL %s switch restores the tab and permits saving again", async (change) => {
const savedSqlStore = useSavedSqlStore();
const original = await savedSqlStore.saveFile({ connectionId: "conn-1", catalog: "hive", database: "analytics", name: "report.sql", schema: "public", sql: "SELECT 1;" });
await savedSqlStore.saveFile({ connectionId: "conn-1", catalog: change === "catalog" ? "iceberg" : "hive", database: change === "database" ? "other" : "analytics", name: "report.sql", sql: "SELECT 2;" });
const queryStore = useQueryStore();
const tabId = queryStore.openSavedSql(original, { targetMode: "saved" });
if (change === "database") queryStore.updateDatabase(tabId, "other");
else queryStore.updateCatalog(tabId, "iceberg", "analytics");
await vi.waitFor(() => assert.equal(toastMock.mock.calls.length, 1));
const tab = queryStore.tabs.find((item) => item.id === tabId)!;
assert.deepEqual([tab.database, tab.catalog, tab.schema], ["analytics", "hive", "public"]);
assert.equal(savedSqlStore.getFile(original.id)?.database, tab.database);
assert.equal(savedSqlStore.getFile(original.id)?.catalog, tab.catalog);
assert.ok(toastMock.mock.calls[0]?.[0].includes("report.sql"));
await savedSqlStore.saveFile({ ...original, database: tab.database, catalog: tab.catalog, schema: tab.schema, sql: "SELECT 3;" });
assert.equal(savedSqlStore.getFile(original.id)?.sql, "SELECT 3;");
});
test("an older failed target save does not roll back a newer database selection", async () => {
const savedSqlStore = useSavedSqlStore();
const original = await savedSqlStore.saveFile({ connectionId: "conn-1", database: "first", name: "report.sql", sql: "SELECT 1;" });
const queryStore = useQueryStore();
const tabId = queryStore.openSavedSql(original, { targetMode: "saved" });
let rejectSave: (error: Error) => void = () => { throw new Error("save has not started"); };
apiMock.saveSavedSqlFile.mockImplementationOnce(() => new Promise((_, reject) => { rejectSave = reject; }));
queryStore.updateDatabase(tabId, "second");
await vi.waitFor(() => assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 2));
queryStore.updateDatabase(tabId, "third");
rejectSave(new Error("disk full"));
await vi.waitFor(() => assert.equal(apiMock.saveSavedSqlFile.mock.calls.at(-1)?.[0].database, "third"));
assert.equal(queryStore.tabs.find((item) => item.id === tabId)?.database, "third");
assert.equal(savedSqlStore.getFile(original.id)?.database, "third");
assert.equal(toastMock.mock.calls.length, 0);
});
test("a target save failure after query store disposal does not restore tabs or notify", async () => {
const savedSqlStore = useSavedSqlStore();
const original = await savedSqlStore.saveFile({ connectionId: "conn-1", database: "first", name: "report.sql", sql: "SELECT 1;" });
const queryStore = useQueryStore();
const tabId = queryStore.openSavedSql(original, { targetMode: "saved" });
let rejectSave: (error: Error) => void = () => { throw new Error("save has not started"); };
apiMock.saveSavedSqlFile.mockImplementationOnce(() => new Promise((_, reject) => { rejectSave = reject; }));
queryStore.updateDatabase(tabId, "second");
await vi.waitFor(() => assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 2));
queryStore.$dispose();
rejectSave(new Error("disk full"));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(queryStore.tabs.find((item) => item.id === tabId)?.database, "second");
assert.equal(toastMock.mock.calls.length, 0);
});
function deferredSavedSqlFile() {
let resolve!: (file: SavedSqlFile) => void;
let reject!: (error: Error) => void;
const promise = new Promise<SavedSqlFile>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function targetReservationFile(overrides: Partial<SavedSqlFile> = {}): SavedSqlFile {
return {
id: "source",
connectionId: "conn-1",
catalog: "hive",
database: "first",
folderId: "folder-1",
name: "report.sql",
sql: "SELECT 1;",
sqlLoaded: true,
createdAt: "2026-09-17T00:00:00.000Z",
updatedAt: "2026-09-17T00:00:00.000Z",
...overrides,
};
}
function targetReservationTarget(database: string) {
return { connectionId: "conn-1", catalog: "hive", database };
}
test.each([
{ change: "database", target: { connectionId: "conn-1", catalog: "hive", database: "second" } },
{ change: "catalog", target: { connectionId: "conn-1", catalog: "iceberg", database: "first" } },
{ change: "connection", target: { connectionId: "conn-2", catalog: "hive", database: "first" } },
].flatMap((scenario) => [true, false].map((succeeds) => ({ ...scenario, succeeds }))))(
"target source reservation protects the original $change scope until completion (success=$succeeds)",
async ({ target, succeeds }) => {
const original = targetReservationFile();
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [original] });
const pending = deferredSavedSqlFile();
apiMock.saveSavedSqlFile.mockImplementation((file) => file.id === original.id ? pending.promise : Promise.resolve(file));
const store = useSavedSqlStore();
await store.initFromStorage();
const move = store.updateFileExecutionTarget(original.id, target).catch((error) => error);
const immediateCreate = store.saveFile({ ...original, id: undefined, name: "REPORT" }).catch((error) => error);
await vi.waitFor(() => assert.ok(apiMock.saveSavedSqlFile.mock.calls.some(([file]) => file.id === original.id)));
const pendingCreate = await store.saveFile({ ...original, id: undefined }).catch((error) => error);
await assert.rejects(store.saveFile({ ...original, ...target, id: undefined }), SavedSqlNameConflictError);
await store.saveFile({ ...original, id: undefined, folderId: "other-folder" });
if (succeeds) pending.resolve({ ...original, ...target });
else pending.reject(new Error("disk full"));
const result = await move;
assert.ok(await immediateCreate instanceof SavedSqlNameConflictError);
assert.ok(pendingCreate instanceof SavedSqlNameConflictError);
assert.equal(result instanceof Error, !succeeds);
assert.deepEqual(
[store.getFile(original.id)?.connectionId, store.getFile(original.id)?.catalog, store.getFile(original.id)?.database],
succeeds ? [target.connectionId, target.catalog, target.database] : [original.connectionId, original.catalog, original.database],
);
apiMock.saveSavedSqlFile.mockImplementation(async (file) => file);
if (!succeeds) {
await assert.rejects(store.saveFile({ ...original, id: undefined }), SavedSqlNameConflictError);
await store.saveFile({ ...original, ...target, id: undefined });
await store.updateFileExecutionTarget(original.id, { ...target, database: "final" });
}
await store.saveFile({ ...original, id: undefined });
},
);
test("target source reservation transfers to B when A to B succeeds and queued C fails", async () => {
const original = targetReservationFile();
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [original] });
const middle = deferredSavedSqlFile();
const latest = deferredSavedSqlFile();
apiMock.saveSavedSqlFile.mockImplementation((file) => {
if (file.id !== original.id) return Promise.resolve(file);
return file.database === "second" ? middle.promise : latest.promise;
});
const store = useSavedSqlStore();
await store.initFromStorage();
const firstMove = store.updateFileExecutionTarget(original.id, targetReservationTarget("second"));
await vi.waitFor(() => assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 1));
const lastMove = store.updateFileExecutionTarget(original.id, targetReservationTarget("third")).catch((error) => error);
await assert.rejects(store.saveFile({ ...original, id: undefined, database: "second" }), SavedSqlNameConflictError);
middle.resolve({ ...original, database: "second" });
await firstMove;
await vi.waitFor(() => assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 2));
await store.saveFile({ ...original, id: undefined });
const middleCreate = await store.saveFile({ ...original, id: undefined, database: "second" }).catch((error) => error);
await assert.rejects(store.saveFile({ ...original, id: undefined, database: "third" }), SavedSqlNameConflictError);
latest.reject(new Error("latest save failed"));
assert.match((await lastMove).message, /latest save failed/);
assert.ok(middleCreate instanceof SavedSqlNameConflictError);
assert.equal(store.getFile(original.id)?.database, "second");
await assert.rejects(store.saveFile({ ...original, id: undefined, database: "second" }), SavedSqlNameConflictError);
await store.saveFile({ ...original, id: undefined, database: "third" });
apiMock.saveSavedSqlFile.mockImplementation(async (file) => file);
await store.updateFileExecutionTarget(original.id, targetReservationTarget("final"));
await store.saveFile({ ...original, id: undefined, database: "second" });
});
test.each([true, false])("target source reservation survives a superseded failed save (latest success=%s)", async (succeeds) => {
const original = targetReservationFile();
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [original] });
const middle = deferredSavedSqlFile();
const latest = deferredSavedSqlFile();
apiMock.saveSavedSqlFile.mockImplementation((file) => {
if (file.id !== original.id) return Promise.resolve(file);
return file.database === "second" ? middle.promise : latest.promise;
});
const store = useSavedSqlStore();
await store.initFromStorage();
const firstMove = store.updateFileExecutionTarget(original.id, targetReservationTarget("second")).catch((error) => error);
await vi.waitFor(() => assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 1));
const lastMove = store.updateFileExecutionTarget(original.id, targetReservationTarget("third")).catch((error) => error);
middle.reject(new Error("superseded save failed"));
assert.match((await firstMove).message, /superseded save failed/);
await vi.waitFor(() => assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 2));
assert.equal(store.getFile(original.id)?.database, "third");
await store.saveFile({ ...original, id: undefined, database: "second" });
const sourceCreate = await store.saveFile({ ...original, id: undefined }).catch((error) => error);
if (succeeds) latest.resolve({ ...original, database: "third" });
else latest.reject(new Error("latest save failed"));
assert.equal((await lastMove) instanceof Error, !succeeds);
assert.ok(sourceCreate instanceof SavedSqlNameConflictError);
assert.equal(store.getFile(original.id)?.database, succeeds ? "third" : "first");
apiMock.saveSavedSqlFile.mockImplementation(async (file) => file);
if (!succeeds) {
await assert.rejects(store.saveFile({ ...original, id: undefined }), SavedSqlNameConflictError);
await store.saveFile({ ...original, id: undefined, database: "third" });
await store.updateFileExecutionTarget(original.id, targetReservationTarget("final"));
}
await store.saveFile({ ...original, id: undefined });
});
test.each([true, false])("target source reservation survives skipped coalesced revisions (success=%s)", async (succeeds) => {
const original = targetReservationFile();
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [original] });
const pending = deferredSavedSqlFile();
apiMock.saveSavedSqlFile.mockImplementation((file) => file.id === original.id ? pending.promise : Promise.resolve(file));
const store = useSavedSqlStore();
await store.initFromStorage();
const firstMove = store.updateFileExecutionTarget(original.id, targetReservationTarget("second"));
const skippedMove = store.updateFileExecutionTarget(original.id, targetReservationTarget("third"));
const lastMove = store.updateFileExecutionTarget(original.id, targetReservationTarget("fourth")).catch((error) => error);
const immediateCreate = store.saveFile({ ...original, id: undefined }).catch((error) => error);
await Promise.all([firstMove, skippedMove]);
await vi.waitFor(() => assert.ok(apiMock.saveSavedSqlFile.mock.calls.some(([file]) => file.id === original.id)));
await store.saveFile({ ...original, id: undefined, database: "second" });
await store.saveFile({ ...original, id: undefined, database: "third" });
if (succeeds) pending.resolve({ ...original, database: "fourth" });
else pending.reject(new Error("disk full"));
assert.equal((await lastMove) instanceof Error, !succeeds);
assert.ok(await immediateCreate instanceof SavedSqlNameConflictError);
assert.deepEqual(apiMock.saveSavedSqlFile.mock.calls.filter(([file]) => file.id === original.id).map(([file]) => file.database), ["fourth"]);
apiMock.saveSavedSqlFile.mockImplementation(async (file) => file);
if (!succeeds) {
assert.equal(store.getFile(original.id)?.database, "first");
await assert.rejects(store.saveFile({ ...original, id: undefined }), SavedSqlNameConflictError);
await store.saveFile({ ...original, id: undefined, database: "fourth" });
await store.updateFileExecutionTarget(original.id, targetReservationTarget("final"));
}
await store.saveFile({ ...original, id: undefined });
});
test.each([true, false].flatMap((superseded) => [true, false].map((succeeds) => ({ superseded, succeeds }))))(
"target source reservation covers lazy hydration (superseded=$superseded, success=$succeeds)",
async ({ superseded, succeeds }) => {
const original = targetReservationFile();
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [{ ...original, sql: "", sqlLoaded: false }] });
const hydration = deferredSavedSqlFile();
const pending = deferredSavedSqlFile();
apiMock.loadSavedSqlFile.mockReturnValueOnce(hydration.promise);
apiMock.saveSavedSqlFile.mockImplementation((file) => file.id === original.id ? pending.promise : Promise.resolve(file));
const store = useSavedSqlStore();
await store.initFromStorage();
const firstMove = store.updateFileExecutionTarget(original.id, targetReservationTarget("second")).catch((error) => error);
await vi.waitFor(() => assert.equal(apiMock.loadSavedSqlFile.mock.calls.length, 1));
const sourceDuringLoad = await store.saveFile({ ...original, id: undefined }).catch((error) => error);
const database = superseded ? "third" : "second";
const lastMove = superseded ? store.updateFileExecutionTarget(original.id, targetReservationTarget(database)).catch((error) => error) : firstMove;
hydration.resolve(original);
await vi.waitFor(() => assert.ok(apiMock.saveSavedSqlFile.mock.calls.some(([file]) => file.id === original.id)));
const sourceDuringSave = await store.saveFile({ ...original, id: undefined }).catch((error) => error);
if (succeeds) pending.resolve({ ...original, database });
else pending.reject(new Error("disk full"));
await firstMove;
assert.equal((await lastMove) instanceof Error, !succeeds);
assert.ok(sourceDuringLoad instanceof SavedSqlNameConflictError);
assert.ok(sourceDuringSave instanceof SavedSqlNameConflictError);
assert.deepEqual(apiMock.saveSavedSqlFile.mock.calls.filter(([file]) => file.id === original.id).map(([file]) => [file.database, file.sql]), [[database, original.sql]]);
assert.equal(store.getFile(original.id)?.database, succeeds ? database : "first");
assert.equal(store.getFile(original.id)?.sql, original.sql);
apiMock.saveSavedSqlFile.mockImplementation(async (file) => file);
if (!succeeds) {
await assert.rejects(store.saveFile({ ...original, id: undefined }), SavedSqlNameConflictError);
await store.saveFile({ ...original, id: undefined, database });
await store.updateFileExecutionTarget(original.id, targetReservationTarget("final"));
}
await store.saveFile({ ...original, id: undefined });
},
);
test("target source reservation rolls back and cleans up when lazy hydration fails", async () => {
const original = targetReservationFile();
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [{ ...original, sql: "", sqlLoaded: false }] });
const hydration = deferredSavedSqlFile();
apiMock.loadSavedSqlFile.mockReturnValueOnce(hydration.promise);
const store = useSavedSqlStore();
await store.initFromStorage();
const move = store.updateFileExecutionTarget(original.id, targetReservationTarget("second")).catch((error) => error);
await vi.waitFor(() => assert.equal(apiMock.loadSavedSqlFile.mock.calls.length, 1));
hydration.reject(new Error("content unavailable"));
assert.match((await move).message, /content unavailable/);
assert.equal(store.getFile(original.id)?.database, "first");
assert.equal(store.getFile(original.id)?.sqlLoaded, false);
assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 0);
await assert.rejects(store.saveFile({ ...original, id: undefined }), SavedSqlNameConflictError);
await store.saveFile({ ...original, id: undefined, database: "second" });
apiMock.loadSavedSqlFile.mockResolvedValueOnce(original);
await store.updateFileExecutionTarget(original.id, targetReservationTarget("final"));
await store.saveFile({ ...original, id: undefined });
});
test("target source reservation permits schema and content updates to legacy duplicate names", async () => {
const original = targetReservationFile();
const duplicate = { ...original, id: "legacy-duplicate", name: "REPORT.SQL" };
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [original, duplicate] });
const store = useSavedSqlStore();
await store.initFromStorage();
await Promise.all([
store.updateFileExecutionTarget(original.id, { ...targetReservationTarget("first"), schema: "public" }),
store.updateFileExecutionTarget(duplicate.id, { ...targetReservationTarget("first"), schema: "app" }),
]);
await store.saveFile({ ...original, schema: "public", sql: "SELECT 2;" });
assert.equal(store.getFile(original.id)?.schema, "public");
assert.equal(store.getFile(duplicate.id)?.schema, "app");
assert.equal(store.getFile(original.id)?.sql, "SELECT 2;");
await assert.rejects(store.saveFile({ ...original, id: undefined }), SavedSqlNameConflictError);
});
test("target source reservation retains each legacy duplicate owner until its move completes", async () => {
const original = targetReservationFile();
const duplicate = { ...original, id: "legacy-duplicate", name: "REPORT.SQL" };
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [original, duplicate] });
const firstPending = deferredSavedSqlFile();
const secondPending = deferredSavedSqlFile();
apiMock.saveSavedSqlFile.mockImplementation((file) => {
if (file.id === original.id) return firstPending.promise;
if (file.id === duplicate.id) return secondPending.promise;
return Promise.resolve(file);
});
const store = useSavedSqlStore();
await store.initFromStorage();
const firstMove = store.updateFileExecutionTarget(original.id, targetReservationTarget("second"));
const secondMove = store.updateFileExecutionTarget(duplicate.id, targetReservationTarget("third")).catch((error) => error);
await vi.waitFor(() => assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 2));
firstPending.resolve({ ...original, database: "second" });
await firstMove;
const sourceCreate = await store.saveFile({ ...original, id: undefined }).catch((error) => error);
secondPending.reject(new Error("second owner failed"));
assert.match((await secondMove).message, /second owner failed/);
assert.ok(sourceCreate instanceof SavedSqlNameConflictError);
assert.equal(store.getFile(duplicate.id)?.database, "first");
apiMock.saveSavedSqlFile.mockImplementation(async (file) => file);
await store.updateFileExecutionTarget(duplicate.id, targetReservationTarget("final"));
await store.saveFile({ ...original, id: undefined });
});