fix(saved-sql): auto-number duplicate saved SQL file names

This commit is contained in:
zipg
2026-10-03 13:43:39 +08:00
committed by GitHub
parent 1bd38f1d6e
commit 8dc1297f6a
18 changed files with 266 additions and 50 deletions
+75 -24
View File
@@ -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<string | null>(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<string | null>(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_<n>`), 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();
}
@@ -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<string>) {
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<SavedSqlFile, "connectionId" | "catalog" | "database">, folderId?: string) {
+1
View File
@@ -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",
+1
View File
@@ -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}”",
+1
View File
@@ -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}”",
+1
View File
@@ -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}”",
+1
View File
@@ -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}”",
+1
View File
@@ -745,6 +745,7 @@ export default withEnglishFallback({
renameFile: "SQLの名前を変更",
renameFailed: "SQLの名前変更に失敗しました: {message}",
nameConflict: "この場所には「{name}」という名前の SQL が既にあります",
nameAdjusted: "この場所には同名の SQL が既にあるため、「{name}」として保存しました",
deleteFile: "SQLを削除",
deleteFileConfirm: "「{name}」を削除しますか?",
createFolderOption: "フォルダ「{name}」を作成",
+1
View File
@@ -747,6 +747,7 @@ export default withEnglishFallback({
renameFile: "SQL 이름 변경",
renameFailed: "SQL 이름 변경 실패: {message}",
nameConflict: '이 위치에 "{name}"이라는 SQL이 이미 있습니다',
nameAdjusted: '이 위치에 같은 이름의 SQL이 있어 "{name}"(으)로 저장했습니다',
deleteFile: "SQL 삭제",
deleteFileConfirm: "“{name}”을(를) 삭제하시겠습니까?",
createFolderOption: "폴더 “{name}” 만들기",
+1
View File
@@ -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}”",
+1
View File
@@ -704,6 +704,7 @@ export default withEnglishFallback({
renameFile: "Переименовать SQL",
renameFailed: "Не удалось переименовать SQL: {message}",
nameConflict: "SQL с именем «{name}» уже существует в этом месте",
nameAdjusted: "SQL с таким именем уже был в этом месте, сохранено как «{name}»",
deleteFile: "Удалить SQL",
deleteFileConfirm: "Удалить «{name}»?",
createFolderOption: "Создать папку «{name}»",
+1
View File
@@ -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",
+1
View File
@@ -675,6 +675,7 @@ export default withEnglishFallback({
renameFile: "重命名 SQL",
renameFailed: "重命名 SQL 失败:{message}",
nameConflict: "当前位置已存在同名 SQL:“{name}”",
nameAdjusted: "当前位置已存在同名 SQL,已自动保存为“{name}”",
deleteFile: "删除 SQL",
deleteFileConfirm: "确定删除“{name}”吗?",
createFolderOption: "创建文件夹“{name}”",
+1
View File
@@ -748,6 +748,7 @@ export default withEnglishFallback({
renameFile: "重新命名 SQL",
renameFailed: "重新命名 SQL 失敗:{message}",
nameConflict: "目前位置已存在同名 SQL:「{name}」",
nameAdjusted: "目前位置已存在同名 SQL,已自動儲存為「{name}」",
deleteFile: "刪除 SQL",
deleteFileConfirm: "確定刪除「{name}」嗎?",
createFolderOption: "建立資料夾「{name}」",
@@ -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");
});
});
@@ -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>): 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);
}
+39 -7
View File
@@ -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<string, number>;
}
@@ -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<string> {
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,
+58
View File
@@ -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> = {}): 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;
});