mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(saved-sql): scope SQL names and guard target persistence
This commit is contained in:
@@ -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()) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user