From de5fde242f7cfa48b27a676b4c363a0e23f88b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=88=E7=A7=8B?= <49514152+yiqiui@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:55:47 +0800 Subject: [PATCH] fix(settings): keep persisted query timeout across upgrades --- .../__tests__/connectionStore.timeout.spec.ts | 82 +++++++++++++++++++ apps/desktop/src/stores/connectionStore.ts | 11 ++- apps/desktop/src/stores/settingsStore.ts | 18 ++++ 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts index 11a4164b70..f3b02dc811 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts @@ -312,6 +312,88 @@ describe("connectionStore timeout recovery", () => { ); }); + it("keeps a persisted global query timeout over a stale backup when upgrading", async () => { + // Reproduces #10024: the user saved a 60s global query timeout, but the + // timeout-inheritance migration re-sourced the global value from a stale + // localStorage backup (30s) on the first launch of the new build, resetting + // the saved setting. The persisted blob already carries the value, so it must + // win over the backup. + localStorage.setItem( + "dbx-timeout-inheritance-backup-v1", + JSON.stringify({ + version: 1, + globalConnectTimeoutSecs: 10, + globalQueryTimeoutSecs: 30, + connectSnapshots: {}, + querySnapshots: {}, + }), + ); + const saveEditorSettings = vi.fn().mockResolvedValue(undefined); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + loadEditorSettings: vi.fn().mockResolvedValue({ + globalConnectTimeoutSecs: 20, + globalQueryTimeoutSecs: 60, + timeoutInheritanceMigrationVersion: 1, + }), + loadConnections: vi.fn().mockResolvedValue([postgresConnection({ id: "custom", connect_timeout_secs: 45, query_timeout_secs: 300 })]), + loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]), + loadSidebarLayout: vi.fn().mockResolvedValue(null), + loadTableVGroups: vi.fn().mockResolvedValue({}), + loadTunnelProfiles: vi.fn().mockResolvedValue([]), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveEditorSettings, + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useSettingsStore } = await import("@/stores/settingsStore"); + const settingsStore = useSettingsStore(); + const { useConnectionStore } = await import("@/stores/connectionStore"); + await useConnectionStore().initFromDisk(); + + expect(settingsStore.editorSettings.globalQueryTimeoutSecs).toBe(60); + expect(settingsStore.editorSettings.globalConnectTimeoutSecs).toBe(20); + expect(saveEditorSettings).toHaveBeenCalledWith(expect.objectContaining({ globalQueryTimeoutSecs: 60, globalConnectTimeoutSecs: 20 })); + }); + + it("still recovers an absent global query timeout from the backup after a downgrade", async () => { + // The migration must keep recovering the global value from the backup when + // the persisted settings predate the setting entirely (no global keys on + // disk) — the case the backup precedence was originally written for. + localStorage.setItem( + "dbx-timeout-inheritance-backup-v1", + JSON.stringify({ + version: 1, + globalConnectTimeoutSecs: 7, + globalQueryTimeoutSecs: 12, + connectSnapshots: { inherited: 7 }, + querySnapshots: { inherited: 12 }, + }), + ); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + loadEditorSettings: vi.fn().mockResolvedValue({ timeoutInheritanceMigrationVersion: 1 }), + loadConnections: vi.fn().mockResolvedValue([postgresConnection({ id: "inherited", connect_timeout_secs: 7, query_timeout_secs: 12 })]), + loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]), + loadSidebarLayout: vi.fn().mockResolvedValue(null), + loadTableVGroups: vi.fn().mockResolvedValue({}), + loadTunnelProfiles: vi.fn().mockResolvedValue([]), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveEditorSettings: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useSettingsStore } = await import("@/stores/settingsStore"); + const settingsStore = useSettingsStore(); + const { useConnectionStore } = await import("@/stores/connectionStore"); + await useConnectionStore().initFromDisk(); + + expect(settingsStore.editorSettings.globalQueryTimeoutSecs).toBe(12); + expect(settingsStore.editorSettings.queryTimeoutInheritConnectionIds).toContain("inherited"); + }); + it("does not reclassify local default-valued overrides after migration", async () => { vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); vi.doMock("@/lib/backend/api", () => ({ diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 11fdcd6a47..2dd8c2d867 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -9002,8 +9002,15 @@ export const useConnectionStore = defineStore("connection", () => { const backup = loadTimeoutInheritanceBackup(); const connectIdsBefore = new Set(settingsStore.editorSettings.connectTimeoutInheritConnectionIds); const queryIdsBefore = new Set(settingsStore.editorSettings.queryTimeoutInheritConnectionIds); - const globalConnectTimeoutSecs = migrationVersion < 2 && backup ? backup.globalConnectTimeoutSecs : settingsStore.editorSettings.globalConnectTimeoutSecs; - const globalQueryTimeoutSecs = migrationVersion < 2 && backup ? backup.globalQueryTimeoutSecs : settingsStore.editorSettings.globalQueryTimeoutSecs; + // Recover the global timeout from the localStorage backup only when the + // settings blob on disk never carried one — the downgrade case, where an + // older build predated the setting. When the user's persisted value exists it + // is authoritative and must win over a backup that can lag behind it (the + // upgrade case, where a stale backup otherwise reset a saved timeout). + const recoverGlobalConnectFromBackup = migrationVersion < 2 && !!backup && !settingsStore.hasPersistedGlobalTimeout("connect"); + const recoverGlobalQueryFromBackup = migrationVersion < 2 && !!backup && !settingsStore.hasPersistedGlobalTimeout("query"); + const globalConnectTimeoutSecs = recoverGlobalConnectFromBackup ? backup!.globalConnectTimeoutSecs : settingsStore.editorSettings.globalConnectTimeoutSecs; + const globalQueryTimeoutSecs = recoverGlobalQueryFromBackup ? backup!.globalQueryTimeoutSecs : settingsStore.editorSettings.globalQueryTimeoutSecs; const resolveInheritance = (connection: ConnectionConfig, scope: "connect" | "query") => { const explicit = scope === "connect" ? connection.connect_timeout_inherit : connection.query_timeout_inherit; diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 0debd0a03c..81505a54f3 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -1918,6 +1918,19 @@ export const useSettingsStore = defineStore("settings", () => { persistedAlwaysOnTopToolbarVisibility = visible; } + // Whether the editor-settings blob loaded from disk actually carried a global + // timeout value. A normalized number is not enough to tell a user's saved + // choice apart from the built-in default filled in by normalizeEditorSettings, + // and the timeout-inheritance migration relies on that distinction: a persisted + // value must win over the localStorage backup, while a value that was never on + // disk (a downgrade, where the older build predated the setting) is recovered + // from the backup. Tracked here at load time and read by the migration. + const persistedGlobalTimeoutScopes = ref({ connect: false, query: false }); + + function hasPersistedGlobalTimeout(scope: "connect" | "query"): boolean { + return persistedGlobalTimeoutScopes.value[scope]; + } + function enqueueEditorSettingsOperation(operation: () => Promise): Promise { const queuedOperation = editorSettingsOperationQueue ? editorSettingsOperationQueue.then(operation) : operation(); const trackedOperation = queuedOperation.then( @@ -1996,6 +2009,10 @@ export const useSettingsStore = defineStore("settings", () => { } editorSettings.value = normalized; persistedAlwaysOnTopToolbarVisibility = normalized.toolbarItems.alwaysOnTop; + persistedGlobalTimeoutScopes.value = { + connect: typeof savedSettings.globalConnectTimeoutSecs === "number", + query: typeof savedSettings.globalQueryTimeoutSecs === "number" || typeof (savedSettings as { queryTimeoutSecs?: unknown }).queryTimeoutSecs === "number", + }; const needsExecuteModeDefaultMigration = typeof savedSettings.executeModeDefaultVersion !== "number" || savedSettings.executeModeDefaultVersion < EXECUTE_MODE_CURRENT_DEFAULT_VERSION; const needsTabNavigationShortcutMigration = needsTabNavigationHistoryShortcutMigration(savedSettings.shortcuts); const savedNullText = (savedSettings.dataGridExtractorOptions as Partial | undefined)?.dsv?.nullText; @@ -2805,6 +2822,7 @@ export const useSettingsStore = defineStore("settings", () => { isConfigured, isEditorSettingsLoaded, editorSettings, + hasPersistedGlobalTimeout, desktopSettings, mcpGlobalPolicy, initEditorSettings,