diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 179b6a2dea..2c03116ab0 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -483,6 +483,10 @@ const showMultiDbExecuteDialog = ref(false); const multiExecuteSql = ref(""); const multiExecuteSourceTabId = ref(""); const saveSqlName = ref(""); +// The name we suggested from the tab title. Only a name that is still +// untouched can be auto-adjusted on a conflict — a name the user typed is +// never quietly saved under a different one. +const autoGeneratedSaveSqlName = ref(null); const ROOT_SAVED_SQL_FOLDER = "__root__"; const { selection: saveSqlFolderId, pending: saveSqlFolderCreationPending, reset: resetSaveSqlFolderSelection, invalidate: invalidateSaveSqlFolderSelection, select: selectSaveSqlFolder } = useSaveSqlFolderSelection(ROOT_SAVED_SQL_FOLDER); const pendingSaveAndCloseTabId = ref(null); @@ -1943,6 +1947,20 @@ function defaultSavedSqlName(title: string) { return normalized.endsWith(".sql") ? normalized : `${normalized}.sql`; } +function selectedSaveSqlFolderId(): string | undefined { + return saveSqlFolderId.value === ROOT_SAVED_SQL_FOLDER ? undefined : saveSqlFolderId.value; +} + +function setAutoSaveSqlName(tab: QueryTab) { + const name = defaultSavedSqlName(tab.title); + saveSqlName.value = name; + autoGeneratedSaveSqlName.value = name; +} + +function saveSqlDialogTab(): QueryTab | undefined { + return saveSqlDialogTabId.value ? queryStore.tabs.find((candidate) => candidate.id === saveSqlDialogTabId.value) : activeTab.value; +} + function notifySqlLibrarySaved() { sqlLibrarySaveFeedbackId.value += 1; toast(t("savedSql.saved"), 2000); @@ -2314,8 +2332,8 @@ async function handleSaveTab(tabId: string) { const prevActive = queryStore.activeTabId; queryStore.activateTab(tabId); saveSqlDialogTabId.value = tabId; - saveSqlName.value = defaultSavedSqlName(tab.title); resetSaveSqlFolderSelection(ROOT_SAVED_SQL_FOLDER); + setAutoSaveSqlName(tab); pendingSaveAndCloseTabId.value = tabId; pendingPrevActiveTabId.value = prevActive; showSaveSqlDialog.value = true; @@ -2353,8 +2371,8 @@ async function openSaveSqlDialog(tabId?: string) { return; } - saveSqlName.value = defaultSavedSqlName(tab.title); resetSaveSqlFolderSelection(ROOT_SAVED_SQL_FOLDER); + setAutoSaveSqlName(tab); showSaveSqlDialog.value = true; } @@ -2416,7 +2434,7 @@ async function handleSaveSqlFolderSelect(value: string) { await selectSaveSqlFolder(value); return; } - const tab = saveSqlDialogTabId.value ? queryStore.tabs.find((candidate) => candidate.id === saveSqlDialogTabId.value) : activeTab.value; + const tab = saveSqlDialogTab(); if (!tab) return; await selectSaveSqlFolder( value, @@ -2425,32 +2443,64 @@ async function handleSaveSqlFolderSelect(value: string) { ); } +// Tab titles are a per-session counter (`query_`), so a generated name can +// already be taken in the target folder. Following the store's suggestion is +// bounded: a concurrent save can claim the suggested name too. +const SAVE_SQL_NAME_ATTEMPTS = 3; + +/** The free name a name conflict points at, or undefined when the caller picked the name. */ +function autoAdjustedSaveName(error: unknown, attemptedName: string): string | undefined { + if (autoGeneratedSaveSqlName.value === null || saveSqlName.value !== autoGeneratedSaveSqlName.value) return undefined; + const conflict = error as { code?: unknown; suggestedName?: unknown } | null; + if (conflict?.code !== "SAVED_SQL_NAME_CONFLICT" || typeof conflict.suggestedName !== "string") return undefined; + if (conflict.suggestedName === attemptedName) return undefined; + return conflict.suggestedName; +} + async function confirmSaveSqlToLibrary() { if (saveSqlFolderCreationPending.value) return; - const tab = saveSqlDialogTabId.value ? queryStore.tabs.find((candidate) => candidate.id === saveSqlDialogTabId.value) : activeTab.value; + const tab = saveSqlDialogTab(); const name = saveSqlName.value.trim(); if (!tab || !tab.sql.trim() || !name) return; const flyOrigin = elementCenter(saveSqlConfirmButtonElement()); - try { - const target = savedSqlTargetForSave(tab); - const saved = await savedSqlStore.saveFile({ - id: tab.savedSqlId, - connectionId: target.connectionId, - folderId: saveSqlFolderId.value === ROOT_SAVED_SQL_FOLDER ? undefined : saveSqlFolderId.value, - name: defaultSavedSqlName(name), - database: target.database, - catalog: target.catalog, - schema: target.schema, - sql: await formattedSqlForSave(tab), - }); - queryStore.linkSavedSql(tab.id, saved.id, saved.name); - queryStore.markTabClean(tab); - showSaveSqlDialog.value = false; - saveSqlDialogTabId.value = null; - closePendingSavedTab(); - void notifyNewSqlLibrarySaved(flyOrigin); - } catch (e: any) { - toast(t("savedSql.saveFailed", { message: savedSqlErrorMessage(e, t) }), 5000); + const target = savedSqlTargetForSave(tab); + const sql = await formattedSqlForSave(tab); + let saveName = defaultSavedSqlName(name); + let adjusted = false; + for (let attempt = 0; ; attempt++) { + try { + const saved = await savedSqlStore.saveFile({ + id: tab.savedSqlId, + connectionId: target.connectionId, + folderId: selectedSaveSqlFolderId(), + name: saveName, + database: target.database, + catalog: target.catalog, + schema: target.schema, + sql, + }); + queryStore.linkSavedSql(tab.id, saved.id, saved.name); + queryStore.markTabClean(tab); + showSaveSqlDialog.value = false; + saveSqlDialogTabId.value = null; + closePendingSavedTab(); + if (!adjusted) void notifyNewSqlLibrarySaved(flyOrigin); + else { + toast(t("savedSql.nameAdjusted", { name: saved.name }), 3000); + sqlLibrarySaveFeedbackId.value += 1; + } + return; + } catch (e: any) { + const retryName = attempt < SAVE_SQL_NAME_ATTEMPTS - 1 ? autoAdjustedSaveName(e, saveName) : undefined; + if (!retryName) { + toast(t("savedSql.saveFailed", { message: savedSqlErrorMessage(e, t) }), 5000); + return; + } + adjusted = true; + saveName = retryName; + saveSqlName.value = retryName; + autoGeneratedSaveSqlName.value = retryName; + } } } @@ -4815,6 +4865,7 @@ onUnmounted(() => { showSaveSqlDialog = open; if (!open) { invalidateSaveSqlFolderSelection(); + autoGeneratedSaveSqlName = null; saveSqlDialogTabId = null; if (pendingSaveAndCloseTabId) cancelPendingSaveAndClose(); } diff --git a/apps/desktop/src/components/layout/SqlLibraryPanel.vue b/apps/desktop/src/components/layout/SqlLibraryPanel.vue index 41982b7e72..320454af62 100644 --- a/apps/desktop/src/components/layout/SqlLibraryPanel.vue +++ b/apps/desktop/src/components/layout/SqlLibraryPanel.vue @@ -26,7 +26,7 @@ import { savedSqlFolderBranchFileCount } from "@/lib/savedSql/savedSqlFolderCoun import { collectSavedSqlDirectoryImportFiles } from "@/lib/savedSql/savedSqlDirectoryImport"; import { savedSqlBatchErrorMessage, savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors"; import { savedSqlDatabaseScopeKey } from "@/lib/savedSql/savedSqlDatabaseTree"; -import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName"; +import { ensureSqlExtension, nextAvailableSqlName } from "@/lib/savedSql/savedSqlFileName"; import { savedSqlImportTarget } from "@/lib/savedSql/savedSqlImportTarget"; import { savedSqlExecutionTargetFromTab, type SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget"; import { uniqueSavedSqlExportFileName, exportSavedSqlFileContent } from "@/lib/savedSql/savedSqlExport"; @@ -92,23 +92,11 @@ function sanitizeFileSystemSegment(name: string) { } function uniqueImportedName(name: string, takenNames: Set) { - const normalized = ensureSqlExtension(name); - const normalizedTakenNames = new Set([...takenNames].map((takenName) => ensureSqlExtension(takenName).toLocaleLowerCase())); - if (!normalizedTakenNames.has(normalized.toLocaleLowerCase())) { - takenNames.add(normalized); - return normalized; - } - - const base = stripSqlExtension(normalized); - let counter = 2; - while (true) { - const candidate = `${base} (${counter}).sql`; - if (!normalizedTakenNames.has(candidate.toLocaleLowerCase())) { - takenNames.add(candidate); - return candidate; - } - counter++; - } + const resolved = nextAvailableSqlName(name, takenNames); + // Batch importers reuse one set across files, so the picked name has to stay + // taken for the rest of their loop. + takenNames.add(resolved); + return resolved; } function savedSqlImportNameScopeKey(target: Pick, folderId?: string) { diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 441788c960..6648f3d8c4 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -523,6 +523,7 @@ export default withEnglishFallback({ renameFile: "SQL-in adını dəyiş", renameFailed: "SQL-in adını dəyişmək mümkün olmadı: {message}", nameConflict: 'Bu yerdə "{name}" adlı SQL artıq mövcuddur', + nameAdjusted: 'Bu yerdə eyni adlı SQL var idi, "{name}" kimi saxlanıldı', deleteFile: "SQL-i sil", deleteFileConfirm: "“{name}” silinsin?", createFolderOption: "“{name}” qovluğunu yarat", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 69161d45a9..0b315fb77d 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -759,6 +759,7 @@ export default { renameFile: "Rename SQL", renameFailed: "Failed to rename SQL: {message}", nameConflict: 'An SQL named "{name}" already exists in this location', + nameAdjusted: 'The name was already taken here, so it was saved as "{name}"', deleteFile: "Delete SQL", deleteFileConfirm: "Delete “{name}”?", createFolderOption: "Create folder “{name}”", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 990acad3e6..aee55a7244 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -753,6 +753,7 @@ export default withEnglishFallback({ renameFile: "Renombrar SQL", renameFailed: "Error al renombrar SQL: {message}", nameConflict: 'Ya existe un SQL llamado "{name}" en esta ubicación', + nameAdjusted: 'Ya existía un SQL con ese nombre aquí, así que se guardó como "{name}"', deleteFile: "Eliminar SQL", deleteFileConfirm: "¿Eliminar “{name}”?", createFolderOption: "Crear carpeta “{name}”", diff --git a/apps/desktop/src/i18n/locales/id.ts b/apps/desktop/src/i18n/locales/id.ts index 44ae61df2a..6fb55dc335 100644 --- a/apps/desktop/src/i18n/locales/id.ts +++ b/apps/desktop/src/i18n/locales/id.ts @@ -678,6 +678,7 @@ export default withEnglishFallback({ renameFile: "Ganti Nama SQL", renameFailed: "Gagal mengganti nama SQL: {message}", nameConflict: 'SQL bernama "{name}" sudah ada di lokasi ini', + nameAdjusted: 'SQL dengan nama ini sudah ada di sini, disimpan sebagai "{name}"', deleteFile: "Hapus SQL", deleteFileConfirm: "Hapus “{name}”?", createFolderOption: "Buat folder “{name}”", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 7e146ff521..df99737343 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -744,6 +744,7 @@ export default withEnglishFallback({ renameFile: "Rinomina SQL", renameFailed: "Impossibile rinominare SQL: {message}", nameConflict: 'Esiste già un SQL denominato "{name}" in questa posizione', + nameAdjusted: 'Esisteva già un SQL con questo nome qui, quindi è stato salvato come "{name}"', deleteFile: "Elimina SQL", deleteFileConfirm: "Eliminare “{name}”?", createFolderOption: "Crea cartella “{name}”", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 5cd8329653..1618c0438a 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -745,6 +745,7 @@ export default withEnglishFallback({ renameFile: "SQLの名前を変更", renameFailed: "SQLの名前変更に失敗しました: {message}", nameConflict: "この場所には「{name}」という名前の SQL が既にあります", + nameAdjusted: "この場所には同名の SQL が既にあるため、「{name}」として保存しました", deleteFile: "SQLを削除", deleteFileConfirm: "「{name}」を削除しますか?", createFolderOption: "フォルダ「{name}」を作成", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 34d280dcda..4b8847fdef 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -747,6 +747,7 @@ export default withEnglishFallback({ renameFile: "SQL 이름 변경", renameFailed: "SQL 이름 변경 실패: {message}", nameConflict: '이 위치에 "{name}"이라는 SQL이 이미 있습니다', + nameAdjusted: '이 위치에 같은 이름의 SQL이 있어 "{name}"(으)로 저장했습니다', deleteFile: "SQL 삭제", deleteFileConfirm: "“{name}”을(를) 삭제하시겠습니까?", createFolderOption: "폴더 “{name}” 만들기", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index a2575505b0..f1ec640bc9 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -745,6 +745,7 @@ export default withEnglishFallback({ renameFile: "Renomear SQL", renameFailed: "Falha ao renomear SQL: {message}", nameConflict: 'Já existe um SQL chamado "{name}" neste local', + nameAdjusted: 'Já existia um SQL com esse nome aqui, então foi salvo como "{name}"', deleteFile: "Excluir SQL", deleteFileConfirm: "Excluir “{name}”?", createFolderOption: "Criar pasta “{name}”", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index 116103e0d1..caf1e7a5ba 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -704,6 +704,7 @@ export default withEnglishFallback({ renameFile: "Переименовать SQL", renameFailed: "Не удалось переименовать SQL: {message}", nameConflict: "SQL с именем «{name}» уже существует в этом месте", + nameAdjusted: "SQL с таким именем уже был в этом месте, сохранено как «{name}»", deleteFile: "Удалить SQL", deleteFileConfirm: "Удалить «{name}»?", createFolderOption: "Создать папку «{name}»", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 05c6e70b3a..a3c5141c82 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -525,6 +525,7 @@ export default withEnglishFallback({ renameFile: "SQL'i Yeniden Adlandır", renameFailed: "SQL yeniden adlandırılamadı: {message}", nameConflict: 'Bu konumda "{name}" adlı bir SQL zaten var', + nameAdjusted: 'Bu konumda aynı ada sahip bir SQL vardı, "{name}" olarak kaydedildi', deleteFile: "SQL Sil", deleteFileConfirm: "“{name}” silinsin mi?", createFolderOption: "“{name}” klasörünü oluştur", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index b92da73fe3..61f16e7e69 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -675,6 +675,7 @@ export default withEnglishFallback({ renameFile: "重命名 SQL", renameFailed: "重命名 SQL 失败:{message}", nameConflict: "当前位置已存在同名 SQL:“{name}”", + nameAdjusted: "当前位置已存在同名 SQL,已自动保存为“{name}”", deleteFile: "删除 SQL", deleteFileConfirm: "确定删除“{name}”吗?", createFolderOption: "创建文件夹“{name}”", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index d88a010ff3..c6cbdb3353 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -748,6 +748,7 @@ export default withEnglishFallback({ renameFile: "重新命名 SQL", renameFailed: "重新命名 SQL 失敗:{message}", nameConflict: "目前位置已存在同名 SQL:「{name}」", + nameAdjusted: "目前位置已存在同名 SQL,已自動儲存為「{name}」", deleteFile: "刪除 SQL", deleteFileConfirm: "確定刪除「{name}」嗎?", createFolderOption: "建立資料夾「{name}」", diff --git a/apps/desktop/src/lib/__tests__/savedSql/savedSqlFileName.spec.ts b/apps/desktop/src/lib/__tests__/savedSql/savedSqlFileName.spec.ts index b23cbd5d67..dafe5fc1c9 100644 --- a/apps/desktop/src/lib/__tests__/savedSql/savedSqlFileName.spec.ts +++ b/apps/desktop/src/lib/__tests__/savedSql/savedSqlFileName.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName"; +import { ensureSqlExtension, nextAvailableSqlName, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName"; describe("savedSqlFileName", () => { it("appends .sql when missing", () => { @@ -18,3 +18,34 @@ describe("savedSqlFileName", () => { expect(stripSqlExtension("report.SQL")).toBe("report"); }); }); + +describe("nextAvailableSqlName", () => { + it("keeps a free name unchanged", () => { + expect(nextAvailableSqlName("query_4", new Set())).toBe("query_4.sql"); + expect(nextAvailableSqlName("query_4.sql", new Set(["other.sql"]))).toBe("query_4.sql"); + }); + + it("continues an underscored counter series instead of nesting a new suffix", () => { + expect(nextAvailableSqlName("query_3.sql", new Set(["query_3.sql"]))).toBe("query_4.sql"); + expect(nextAvailableSqlName("query_4.sql", new Set(["query_3.sql", "query_4.sql", "query_5.sql"]))).toBe("query_6.sql"); + }); + + it("continues a parenthesized counter series", () => { + expect(nextAvailableSqlName("report (2).sql", new Set(["report (2).sql"]))).toBe("report (3).sql"); + expect(nextAvailableSqlName("report(2).sql", new Set(["report(2).sql"]))).toBe("report(3).sql"); + }); + + it("starts a parenthesized series for names without a counter", () => { + expect(nextAvailableSqlName("report.sql", new Set(["report.sql"]))).toBe("report (2).sql"); + expect(nextAvailableSqlName("report.sql", new Set(["report.sql", "REPORT (2).SQL"]))).toBe("report (3).sql"); + }); + + it("compares names case-insensitively and implies the .sql extension", () => { + expect(nextAvailableSqlName("Query_7", new Set(["query_7.SQL"]))).toBe("Query_8.sql"); + expect(nextAvailableSqlName("query_7.sql", new Set(["query_7"]))).toBe("query_8.sql"); + }); + + it("skips over taken names inside the series", () => { + expect(nextAvailableSqlName("query_1.sql", new Set(["query_1.sql", "query_2.sql", "query_4.sql"]))).toBe("query_3.sql"); + }); +}); diff --git a/apps/desktop/src/lib/savedSql/savedSqlFileName.ts b/apps/desktop/src/lib/savedSql/savedSqlFileName.ts index 9cca7f96d8..30b78adde4 100644 --- a/apps/desktop/src/lib/savedSql/savedSqlFileName.ts +++ b/apps/desktop/src/lib/savedSql/savedSqlFileName.ts @@ -7,3 +7,47 @@ export function ensureSqlExtension(name: string): string { export function stripSqlExtension(name: string): string { return name.replace(/\.sql$/i, ""); } + +interface SqlNameSeries { + index: number; + format: (index: number) => string; +} + +/** + * The de-duplication suffix family a name already belongs to. + * + * A name that carries its own trailing counter keeps that formatting so the + * next free name stays in the same series — `query_3.sql` continues as + * `query_4.sql` and `report (2).sql` as `report (3).sql`, never the nested + * `query_4 (2).sql` / `report (2) (2).sql` that stacking a fresh suffix would + * produce. Names without a counter start a parenthesized series at 2. + */ +function sqlNameSeries(base: string): SqlNameSeries | undefined { + const parenthesized = /^(.*?)([ _]?)\((\d+)\)$/.exec(base); + if (parenthesized?.[1]) return { index: Number(parenthesized[3]), format: (index) => `${parenthesized[1]}${parenthesized[2]}(${index}).sql` }; + const underscored = /^(.*?)_(\d+)$/.exec(base); + if (underscored?.[1]) return { index: Number(underscored[2]), format: (index) => `${underscored[1]}_${index}.sql` }; + return undefined; +} + +/** + * Resolves `baseName` to the first name that is free in `takenNames`. + * + * `takenNames` must come from the same scope the caller will persist into — + * the saved-SQL library keys names by connection, catalog, database and folder, + * so a caller that counts a narrower set can still hand back a colliding name. + * Matching is case-insensitive and `.sql` is implied, mirroring the library's + * own uniqueness rule. + */ +export function nextAvailableSqlName(baseName: string, takenNames: ReadonlySet): string { + const normalized = ensureSqlExtension(baseName); + const normalizedTakenNames = new Set([...takenNames].map((name) => ensureSqlExtension(name).toLocaleLowerCase())); + if (!normalizedTakenNames.has(normalized.toLocaleLowerCase())) return normalized; + + const base = stripSqlExtension(normalized); + const series = sqlNameSeries(base); + const format = series?.format ?? ((index: number) => `${base} (${index}).sql`); + let index = (series?.index ?? 1) + 1; + while (normalizedTakenNames.has(format(index).toLocaleLowerCase())) index++; + return format(index); +} diff --git a/apps/desktop/src/stores/savedSqlStore.ts b/apps/desktop/src/stores/savedSqlStore.ts index d761487cab..11cafd5c11 100644 --- a/apps/desktop/src/stores/savedSqlStore.ts +++ b/apps/desktop/src/stores/savedSqlStore.ts @@ -3,7 +3,7 @@ import { computed, ref } from "vue"; import { uuid } from "@/lib/common/utils"; import * as api from "@/lib/backend/api"; import { forgetSavedSqlEditorPosition } from "@/lib/app/savedSqlEditorPosition"; -import { ensureSqlExtension } from "@/lib/savedSql/savedSqlFileName"; +import { ensureSqlExtension, nextAvailableSqlName } from "@/lib/savedSql/savedSqlFileName"; import { nextSavedSqlCopyName } from "@/lib/savedSql/savedSqlClipboard"; import { savedSqlBatchReassignment, type SavedSqlBatchTargetSelection } from "@/lib/savedSql/savedSqlBatchTarget"; import { savedSqlDatabaseScopeKey } from "@/lib/savedSql/savedSqlDatabaseTree"; @@ -72,6 +72,14 @@ interface SavedSqlNameScope { name: string; } +/** Where a saved SQL name has to be unique: one folder of one database scope. */ +interface SavedSqlNameScopeInput { + connectionId: string; + catalog?: string; + database: string; + folderId?: string; +} + interface PendingSavedSqlName { owners: Map; } @@ -79,7 +87,16 @@ interface PendingSavedSqlName { export class SavedSqlNameConflictError extends Error { readonly code = "SAVED_SQL_NAME_CONFLICT"; - constructor(readonly fileName: string) { + /** + * The next free name in the same scope, so a caller that generated the + * colliding name can retry automatically instead of dead-ending the user on + * an error. Absent when the caller supplied the name itself and silently + * saving under a different one would be wrong. + */ + constructor( + readonly fileName: string, + readonly suggestedName?: string, + ) { super(`SQL "${fileName}" already exists in this location.`); this.name = "SavedSqlNameConflictError"; } @@ -258,7 +275,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => { const nameKey = savedSqlNameKey(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); + if (persistedConflict) throw savedSqlNameConflict(file); } let pendingNames = pendingNamesByScope.get(scopeKey); @@ -267,7 +284,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => { pendingNamesByScope.set(scopeKey, pendingNames); } const pending = pendingNames.get(nameKey); - if (!options.allowExisting && pending && [...pending.owners.keys()].some((ownerId) => ownerId !== file.id)) throw new SavedSqlNameConflictError(file.name); + if (!options.allowExisting && pending && [...pending.owners.keys()].some((ownerId) => ownerId !== file.id)) throw savedSqlNameConflict(file); if (pending) pending.owners.set(file.id, (pending.owners.get(file.id) ?? 0) + 1); else pendingNames.set(nameKey, { owners: new Map([[file.id, 1]]) }); @@ -290,6 +307,23 @@ export const useSavedSqlStore = defineStore("savedSql", () => { return [...(pendingNamesByScope.get(scopeKey)?.keys() ?? [])]; } + /** + * A conflict that tells the caller where to go next. The suggestion is + * resolved against the same scope `reserveFileName` just rejected, including + * names reserved by saves that are still in flight, so it is free at the + * moment of the conflict. A concurrent writer can still take it in between, + * which is why callers retry rather than assume the name is reserved. + */ + function savedSqlNameConflict(file: SavedSqlNameScope): SavedSqlNameConflictError { + return new SavedSqlNameConflictError(file.name, nextAvailableSqlName(file.name, takenFileNames(file))); + } + + /** Names already occupied in one library scope, including in-flight saves. */ + function takenFileNames(scope: SavedSqlNameScopeInput): Set { + const scopeKey = savedSqlNameScopeKey(scope); + return new Set([...files.value.filter((file) => savedSqlNameScopeKey(file) === scopeKey).map((file) => file.name), ...pendingFileNames(scopeKey)]); + } + async function ensureFileContent(id: string) { const existing = getFile(id); if (!existing) return undefined; @@ -608,9 +642,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => { const sourceFolder = source.folderId ? folders.value.find((folder) => folder.id === source.folderId) : undefined; const folderId = sourceFolder?.connectionId === normalizedTarget.connectionId ? source.folderId : undefined; const copyScope = { ...normalizedTarget, folderId }; - const scopeKey = savedSqlNameScopeKey(copyScope); - const takenNames = new Set([...files.value.filter((file) => savedSqlNameScopeKey(file) === scopeKey).map((file) => file.name), ...pendingFileNames(scopeKey)]); - const name = nextSavedSqlCopyName(source.name, takenNames); + const name = nextSavedSqlCopyName(source.name, takenFileNames(copyScope)); const keepSourceScope = savedSqlDatabaseScopeKey(source) === savedSqlDatabaseScopeKey(normalizedTarget); const saved = await saveFile({ connectionId: normalizedTarget.connectionId, diff --git a/packages/app-tests/savedSqlStore.test.ts b/packages/app-tests/savedSqlStore.test.ts index 83957c6e73..999826e4f5 100644 --- a/packages/app-tests/savedSqlStore.test.ts +++ b/packages/app-tests/savedSqlStore.test.ts @@ -1396,3 +1396,61 @@ test("target source reservation retains each legacy duplicate owner until its mo await store.updateFileExecutionTarget(duplicate.id, targetReservationTarget("final")); await store.saveFile({ ...original, id: undefined }); }); + +function scopeSeedFile(id: string, name: string, overrides: Partial = {}): SavedSqlFile { + return { + id, + connectionId: "conn-1", + database: "db-1", + name, + sql: "SELECT 1;", + sqlLoaded: true, + createdAt: "2026-07-19T00:00:00.000Z", + updatedAt: "2026-07-19T00:00:00.000Z", + ...overrides, + }; +} + +test("a name conflict suggests the next free name in the same scope", async () => { + apiMock.loadSavedSqlLibrary.mockResolvedValue({ + folders: [], + files: [ + scopeSeedFile("sql-1", "query_3.sql"), + // Same name in another database and in another folder must not shift the + // suggestion for the root of db-1. + scopeSeedFile("sql-2", "query_4.sql", { database: "db-2" }), + scopeSeedFile("sql-3", "query_4.sql", { folderId: "folder-a" }), + ], + }); + + const store = useSavedSqlStore(); + await store.initFromStorage(); + + // A third tab is titled `query_3`, which the library already holds, so saving + // it has to fall through to the next free name instead of dead-ending. + const conflict = await store.saveFile({ connectionId: "conn-1", database: "db-1", name: "query_3.sql", sql: "SELECT 1;" }).catch((error) => error); + assert.ok(conflict instanceof SavedSqlNameConflictError); + assert.equal(conflict.fileName, "query_3.sql"); + assert.equal(conflict.suggestedName, "query_4.sql"); + + const saved = await store.saveFile({ connectionId: "conn-1", database: "db-1", name: conflict.suggestedName!, sql: "SELECT 1;" }); + assert.equal(saved.name, "query_4.sql"); +}); + +test("a conflict suggestion also skips names reserved by in-flight saves", async () => { + const pending = deferredSavedSqlFile(); + apiMock.saveSavedSqlFile.mockImplementation((file) => (file.name === "query_4.sql" ? pending.promise : Promise.resolve(file))); + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [scopeSeedFile("sql-1", "query_3.sql")] }); + + const store = useSavedSqlStore(); + await store.initFromStorage(); + + const inFlight = store.saveFile({ connectionId: "conn-1", database: "db-1", name: "query_4.sql", sql: "SELECT 1;" }); + const conflict = await store.saveFile({ connectionId: "conn-1", database: "db-1", name: "query_3.sql", sql: "SELECT 2;" }).catch((error) => error); + + assert.ok(conflict instanceof SavedSqlNameConflictError); + assert.equal(conflict.suggestedName, "query_5.sql"); + + pending.resolve({ id: "sql-pending", connectionId: "conn-1", database: "db-1", name: "query_4.sql", sql: "SELECT 1;", sqlLoaded: true, createdAt: "2026-07-19T00:00:00.000Z", updatedAt: "2026-07-19T00:00:00.000Z" }); + await inFlight; +});