diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 901d324ef6..b2101cf4fd 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -1181,6 +1181,7 @@ function isManticoreJsonColumn(column: EditableStructureColumn): boolean { let sqlPreviewRequestId = 0; let structureLoadRequestId = 0; +let structureMetadataRevalidationId = 0; let tableCommentLoadRequestId = 0; let tableCommentLoadPromise: Promise | null = null; let tableOwnerLoadRequestId = 0; @@ -2085,6 +2086,9 @@ async function loadStructure( secondaryMetadataErrors.value = {}; let secondaryMetadataScheduled = false; let loadedSuccessfully = false; + let columnsServedFromCache = false; + let commentServedFromCache = false; + let appliedColumnsSignature: string | undefined; try { await store.ensureConnected(connectionId); @@ -2102,7 +2106,13 @@ async function loadStructure( .then((status) => ({ known: true, status })) .catch(() => ({ known: false, status: { isPartitionedParent: false, isPartition: false } })) : Promise.resolve({ known: true, status: { isPartitionedParent: false, isPartition: false } }); - const columnsPromise = effectiveScope.columns ? loadObjectMetadataFacet(metadataRequest, "columns", () => api.getColumns(connectionId, database, schema, tableName, catalog), { force: forceMetadata }).then((result) => result.value) : Promise.resolve(undefined); + const columnsLoad = effectiveScope.columns ? loadObjectMetadataFacet(metadataRequest, "columns", () => api.getColumns(connectionId, database, schema, tableName, catalog), { force: forceMetadata }) : undefined; + const columnsPromise = columnsLoad + ? columnsLoad.then((result) => { + columnsServedFromCache = result.cacheStatus !== "remote"; + return result.value; + }) + : Promise.resolve(undefined); const indexesPromise = effectiveScope.indexes ? tableMetadataCapabilities.value.indexes ? loadObjectMetadataFacet(metadataRequest, "indexes", () => api.listIndexes(connectionId, database, schema, tableName, catalog), { force: forceMetadata }).then((result) => result.value) @@ -2123,7 +2133,13 @@ async function loadStructure( ? loadObjectMetadataFacet(metadataRequest, "triggers", () => api.listTriggers(connectionId, database, schema, tableName, catalog), { force: forceMetadata }).then((result) => result.value) : Promise.resolve([]) : Promise.resolve(undefined); - const tableCommentPromise = effectiveScope.tableComment && structureCapabilities.value.comment ? loadCachedTableComment(metadataRequest, forceMetadata).then((result) => result.value) : Promise.resolve(undefined); + const tableCommentLoad = effectiveScope.tableComment && structureCapabilities.value.comment ? loadCachedTableComment(metadataRequest, forceMetadata) : undefined; + const tableCommentPromise = tableCommentLoad + ? tableCommentLoad.then((result) => { + commentServedFromCache = result.cacheStatus !== "remote"; + return result.value; + }) + : Promise.resolve(undefined); let nextColumns = await columnsPromise; if (nextColumns) { @@ -2144,6 +2160,7 @@ async function loadStructure( const nextColumnDrafts = createColumnDrafts(nextColumns, databaseType.value); const hydratedColumnDrafts = supportsCharacterLengthUnits.value && options.characterLengthUnitsAfterSave ? restoreCharacterLengthUnitsAfterSave(databaseType.value, nextColumnDrafts, options.characterLengthUnitsAfterSave) : nextColumnDrafts; columns.value = applyStoredLocalColumnOrder(hydratedColumnDrafts); + appliedColumnsSignature = JSON.stringify(nextColumns); loadedMetadataFacets.add("columns"); if (!options.preserveDraft) clearColumnSelection(); } @@ -2227,6 +2244,14 @@ async function loadStructure( await secondaryMetadataPromise; } loadedSuccessfully = true; + // Cache-served structure metadata can be stale when another session rebuilt + // the table (#8816): the cache is only invalidated by in-app mutations. + // Revalidate in the background. Manticore columns are re-derived from the + // DDL locally, so leave those to the explicit refresh. + const manticoreDerivesColumnsFromDdl = databaseType.value === "manticoresearch" && tableMetadataCapabilities.value.ddl; + if (!forceMetadata && !manticoreDerivesColumnsFromDdl && (columnsServedFromCache || commentServedFromCache)) { + void revalidateCachedStructureMetadata(requestId, { columns: columnsServedFromCache, tableComment: commentServedFromCache }, appliedColumnsSignature); + } } catch (e: any) { if (showErrors) { errorMessage.value = e?.message || String(e); @@ -2244,6 +2269,54 @@ async function loadStructure( } } +/** Re-fetch structure facets that were served from the metadata cache and apply + * them while the drafts they feed are still clean: reopening the editor after + * another session rebuilt the table must not keep showing the old columns + * (#8816). User edits stay untouched and remain refreshable from the toolbar. */ +async function revalidateCachedStructureMetadata(loadRequestId: number, scope: { columns: boolean; tableComment: boolean }, appliedColumnsSignature: string | undefined) { + const connectionId = props.connectionId; + const database = props.database; + const catalog = props.catalog; + const schema = metadataSchema.value; + const tableName = props.tableName; + if (!connectionId || !database || !tableName) return; + const revalidationId = ++structureMetadataRevalidationId; + const metadataRequest = { connectionId, database, schema, tableName, catalog }; + try { + // Force alone only clears this facet's own key; the web backend keeps its + // own backend-columns/backend-comment entries under the same table prefix + // and would serve them to the forced re-fetch. Drop the whole table scope + // first, the same thing an explicit "Refresh Structure" does. + await invalidateObjectMetadataCache({ connectionId, database, schema, tableName }); + const columnsPromise = scope.columns ? loadObjectMetadataFacet(metadataRequest, "columns", () => api.getColumns(connectionId, database, schema, tableName, catalog), { force: true }) : Promise.resolve(undefined); + const commentPromise = scope.tableComment ? loadCachedTableComment(metadataRequest, true) : Promise.resolve(undefined); + // allSettled so a failing comment re-fetch cannot swallow a valid columns + // fix (and vice versa); each failure is logged on its own. + const [columnsResult, commentResult] = await Promise.allSettled([columnsPromise, commentPromise] as const); + // A newer load or revalidation supersedes this one. + if (revalidationId !== structureMetadataRevalidationId || loadRequestId !== structureLoadRequestId) return; + if (columnsResult.status === "rejected") console.warn("[DBX][structure-editor:columns-metadata-revalidation-failed]", columnsResult.reason); + if (commentResult.status === "rejected") console.warn("[DBX][structure-editor:comment-metadata-revalidation-failed]", commentResult.reason); + + let applied = false; + const nextColumns = columnsResult.status === "fulfilled" ? columnsResult.value?.value : undefined; + if (nextColumns && !captureStructureRefreshScope().columns && JSON.stringify(nextColumns) !== appliedColumnsSignature) { + columns.value = applyStoredLocalColumnOrder(createColumnDrafts(nextColumns, databaseType.value)); + scheduleSqlPreviewRefresh(); + applied = true; + } + const nextComment = commentResult.status === "fulfilled" ? commentResult.value?.value : undefined; + if (nextComment !== undefined && tableComment.value === originalTableComment.value && nextComment !== originalTableComment.value) { + originalTableComment.value = nextComment; + tableComment.value = nextComment; + applied = true; + } + if (applied) syncDraftToParent(); + } catch (e) { + console.warn("[DBX][structure-editor:metadata-revalidation-failed]", e); + } +} + async function refreshStructureAfterSave(scope: TableStructureRefreshScope, characterLengthUnitsAfterSave: ReadonlyMap) { try { await Promise.all([loadStructure(true, scope, false, { blockSecondaryMetadata: true, characterLengthUnitsAfterSave }), loadTableOwner(true), loadMysqlTableEngine(false)]); diff --git a/apps/desktop/src/components/structure/__tests__/TableStructureEditor.metadataRevalidation.spec.ts b/apps/desktop/src/components/structure/__tests__/TableStructureEditor.metadataRevalidation.spec.ts new file mode 100644 index 0000000000..bfe0761650 --- /dev/null +++ b/apps/desktop/src/components/structure/__tests__/TableStructureEditor.metadataRevalidation.spec.ts @@ -0,0 +1,406 @@ +// @vitest-environment happy-dom + +import { createApp, nextTick, type App } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Regression for #8816: the editor serves its initial load from the frontend +// metadata cache, which in-app mutations only invalidate. A table rebuilt by +// another session therefore reopened with stale columns; cache-served loads +// must revalidate the affected facets in the background without clobbering +// pending user edits. + +const mocks = vi.hoisted(() => ({ + connection: { + id: "structure-revalidation", + name: "MySQL", + db_type: "mysql", + driver_label: "MySQL", + }, + ensureConnected: vi.fn(), + executeQuery: vi.fn(), + executeBatch: vi.fn(), + listDataTypes: vi.fn(), + buildTableStructureChangeSql: vi.fn(), + buildMysqlAutoIncrementSql: vi.fn(), + buildTableOwnerChangeSql: vi.fn(), + getTablePartitionStatus: vi.fn(), + getTableOwner: vi.fn(), + updateEditorSettings: vi.fn(), + loadObjectDdl: vi.fn(), + invalidateObjectDdl: vi.fn(), + loadObjectMetadataFacet: vi.fn(), + invalidateObjectMetadataCache: vi.fn(), + invalidateTableMetadataCache: vi.fn(), + toast: vi.fn(), +})); + +vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); + +vi.mock("@lucide/vue", async () => { + const { defineComponent, h } = await import("vue"); + const Icon = defineComponent({ name: "Icon", setup: () => () => h("span") }); + return { + AlertTriangle: Icon, + Check: Icon, + ChevronDown: Icon, + ChevronLeft: Icon, + ChevronRight: Icon, + ChevronUp: Icon, + Copy: Icon, + Database: Icon, + Info: Icon, + Keyboard: Icon, + KeyRound: Icon, + ListChevronsUpDown: Icon, + Loader2: Icon, + Maximize2: Icon, + Pencil: Icon, + Plus: Icon, + RefreshCw: Icon, + RotateCcw: Icon, + Save: Icon, + Search: Icon, + Settings: Icon, + SlidersHorizontal: Icon, + Trash2: Icon, + UserRound: Icon, + X: Icon, + }; +}); + +vi.mock("@/components/ui/button", async () => { + const { defineComponent, h } = await import("vue"); + return { + Button: defineComponent({ + name: "Button", + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("button", attrs, slots.default?.()), + }), + }; +}); +vi.mock("@/components/ui/input", async () => { + const { defineComponent, h } = await import("vue"); + return { + Input: defineComponent({ + name: "Input", + inheritAttrs: false, + props: { modelValue: { type: [String, Number], default: "" } }, + emits: ["update:modelValue"], + setup: + (props, { attrs, emit }) => + () => + h("input", { + ...attrs, + value: props.modelValue, + onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value), + }), + }), + }; +}); +vi.mock("@/components/ui/badge", async () => { + const { defineComponent, h } = await import("vue"); + return { + Badge: defineComponent({ + name: "Badge", + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("span", attrs, slots.default?.()), + }), + }; +}); +vi.mock("@/components/ui/tabs", async () => { + const { defineComponent, h } = await import("vue"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + const TabsContent = defineComponent({ + name: "MockTabsContent", + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + const TabsTrigger = defineComponent({ + name: "MockTabsTrigger", + inheritAttrs: false, + props: { value: { type: String, required: true } }, + setup: + (props, { attrs, slots }) => + () => + h("button", { ...attrs, type: "button", "data-tab-trigger": props.value }, slots.default?.()), + }); + return { Tabs: Div, TabsContent, TabsList: Div, TabsTrigger }; +}); +vi.mock("@/components/ui/dropdown-menu", async () => { + const { defineComponent, h } = await import("vue"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { DropdownMenu: Div, DropdownMenuCheckboxItem: Div, DropdownMenuContent: Div, DropdownMenuItem: Div, DropdownMenuTrigger: Div }; +}); +vi.mock("@/components/ui/popover", async () => { + const { defineComponent, h } = await import("vue"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { Popover: Div, PopoverContent: Div, PopoverTrigger: Div }; +}); +vi.mock("@/components/ui/tooltip", async () => { + const { defineComponent, h } = await import("vue"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { Tooltip: Div, TooltipContent: Div, TooltipTrigger: Div }; +}); +vi.mock("@/components/ui/searchable-select", async () => { + const { defineComponent, h } = await import("vue"); + return { + SearchableSelect: defineComponent({ + name: "SearchableSelect", + inheritAttrs: false, + props: { modelValue: { type: String, default: "" } }, + emits: ["update:modelValue"], + setup: + (props, { attrs }) => + () => + h("button", { ...attrs, type: "button", "data-model-value": props.modelValue }), + }), + }; +}); +vi.mock("@/components/ui/select", async () => { + const { defineComponent, h } = await import("vue"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { Select: Div, SelectContent: Div, SelectItem: Div, SelectTrigger: Div, SelectValue: Div }; +}); +vi.mock("@/components/editor/EditorSearchPanel.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { + default: defineComponent({ + name: "MockEditorSearchPanel", + setup: () => ({ openSearch: () => false, closeSearch: () => false }), + render: () => h("div", { "data-editor-search-panel": "true" }), + }), + }; +}); + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ + ensureConnected: mocks.ensureConnected, + getConfig: (connectionId: string) => (connectionId === mocks.connection.id ? mocks.connection : undefined), + }), +})); +vi.mock("@/stores/productionSafetyStore", () => ({ useProductionSafetyStore: () => ({ requestConfirmation: vi.fn() }) })); +vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ tableStructureRefreshVersion: () => 0 }) })); +vi.mock("@/stores/historyStore", () => ({ useHistoryStore: () => ({ add: vi.fn() }) })); +vi.mock("@/stores/settingsStore", () => ({ + useSettingsStore: () => ({ + editorSettings: { structureEditorDensity: "compact", sqlFormatter: {}, tableColumnTemplateFields: [], fontSize: 13, fontFamily: "monospace", theme: "default", generateSqlQuoteIdentifiers: true }, + updateEditorSettings: mocks.updateEditorSettings, + }), +})); +vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false }, themePalette: { value: "pearl" } }) })); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) })); +vi.mock("@/lib/sql/sqlHighlighter", () => ({ createShikiSqlHighlighter: vi.fn(async () => (sql: string) => sql) })); +vi.mock("@/lib/sql/sqlFormatter", () => ({ + formatSqlForDisplay: vi.fn(async (sql: string) => sql), + sqlFormatDialectForDbType: vi.fn(() => "mysql"), +})); +vi.mock("@/lib/editor/editorThemes", () => ({ loadEditorTheme: vi.fn(async () => []), editorFontTheme: vi.fn(() => []) })); +vi.mock("@/lib/metadata/objectDdlCache", () => ({ + loadObjectDdl: mocks.loadObjectDdl, + invalidateObjectDdl: mocks.invalidateObjectDdl, +})); +vi.mock("@/lib/metadata/objectMetadataCache", () => ({ loadObjectMetadataFacet: mocks.loadObjectMetadataFacet, invalidateObjectMetadataCache: mocks.invalidateObjectMetadataCache })); +vi.mock("@/lib/metadata/tableMetadataCache", () => ({ invalidateTableMetadataCache: mocks.invalidateTableMetadataCache })); +vi.mock("@/lib/backend/api", () => ({ + executeQuery: mocks.executeQuery, + executeBatch: mocks.executeBatch, + listDataTypes: mocks.listDataTypes, + buildTableStructureChangeSql: mocks.buildTableStructureChangeSql, + buildMysqlAutoIncrementSql: mocks.buildMysqlAutoIncrementSql, + buildTableOwnerChangeSql: mocks.buildTableOwnerChangeSql, + getTablePartitionStatus: mocks.getTablePartitionStatus, + getTableOwner: mocks.getTableOwner, +})); + +import TableStructureEditor from "@/components/structure/TableStructureEditor.vue"; + +const mountedApps: App[] = []; + +const initialColumns = [{ name: "id", data_type: "bigint", nullable: false, default_value: null, comment: "" }]; +const rebuiltColumns = [ + { name: "id", data_type: "integer", nullable: false, default_value: null, comment: "" }, + { name: "email", data_type: "varchar(255)", nullable: true, default_value: null, comment: "" }, +]; + +function columnsFacetCalls() { + return mocks.loadObjectMetadataFacet.mock.calls.filter((call) => call[1] === "columns"); +} + +async function settle() { + for (let i = 0; i < 30; i++) { + await nextTick(); + await Promise.resolve(); + } +} + +async function mountStructureEditor() { + const root = document.createElement("div"); + document.body.append(root); + const app = createApp(TableStructureEditor, { + connectionId: mocks.connection.id, + database: "test", + tableName: "users", + }); + mountedApps.push(app); + app.mount(root); + await vi.waitFor( + () => { + expect(root.textContent).toContain("structureEditor.noChanges"); + }, + { timeout: 3000 }, + ); + await settle(); + return root; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.ensureConnected.mockResolvedValue(undefined); + mocks.executeQuery.mockResolvedValue({ columns: [], rows: [] }); + mocks.executeBatch.mockResolvedValue({ rowsAffected: 0 }); + mocks.listDataTypes.mockResolvedValue([]); + mocks.getTablePartitionStatus.mockResolvedValue({ isPartitionedParent: false, isPartition: false }); + mocks.getTableOwner.mockResolvedValue(""); + mocks.buildTableOwnerChangeSql.mockResolvedValue({ statements: [], warnings: [] }); + mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [] }); + mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE users (id bigint)", cacheStatus: "remote" }); +}); + +afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.innerHTML = ""; +}); + +describe("TableStructureEditor metadata revalidation (#8816)", () => { + it("re-fetches cache-served columns in the background and applies changes on a clean draft", async () => { + let columnsCalls = 0; + mocks.loadObjectMetadataFacet.mockImplementation(async (_request: unknown, facet: string) => { + if (facet === "columns") { + columnsCalls += 1; + return columnsCalls === 1 ? { value: initialColumns, cacheStatus: "memory" } : { value: rebuiltColumns, cacheStatus: "remote" }; + } + if (facet === "comment") return { value: "", cacheStatus: "remote" }; + return { value: [], cacheStatus: "remote" }; + }); + + const root = await mountStructureEditor(); + + await vi.waitFor(() => expect(columnsFacetCalls().length).toBe(2), { timeout: 3000 }); + expect(columnsFacetCalls()[1][3]).toEqual({ force: true }); + expect(root.textContent).toContain("email"); + // The revalidation must not loop: applying happens outside loadStructure. + await settle(); + expect(columnsFacetCalls().length).toBe(2); + }); + + it("does not re-fetch when the initial load came from the remote source", async () => { + mocks.loadObjectMetadataFacet.mockImplementation(async (_request: unknown, facet: string) => { + if (facet === "columns") return { value: initialColumns, cacheStatus: "remote" }; + if (facet === "comment") return { value: "", cacheStatus: "remote" }; + return { value: [], cacheStatus: "remote" }; + }); + + const root = await mountStructureEditor(); + await settle(); + + expect(columnsFacetCalls().length).toBe(1); + expect(root.textContent).not.toContain("email"); + }); + + it("keeps pending user edits when the background revalidation brings different columns", async () => { + let columnsCalls = 0; + let resolveRevalidation: ((result: { value: unknown; cacheStatus: string }) => void) | undefined; + mocks.loadObjectMetadataFacet.mockImplementation((_request: unknown, facet: string) => { + if (facet === "columns") { + columnsCalls += 1; + if (columnsCalls === 1) return Promise.resolve({ value: initialColumns, cacheStatus: "memory" }); + return new Promise((resolve) => { + resolveRevalidation = resolve; + }); + } + if (facet === "comment") return Promise.resolve({ value: "", cacheStatus: "remote" }); + return Promise.resolve({ value: [], cacheStatus: "remote" }); + }); + + const root = await mountStructureEditor(); + + // The background revalidation started and is held pending. + await vi.waitFor(() => expect(columnsFacetCalls().length).toBe(2), { timeout: 3000 }); + expect(resolveRevalidation).toBeDefined(); + + // The user starts editing while the revalidation is in flight. + const nameInput = root.querySelector("[data-column-name-input]"); + expect(nameInput).not.toBeNull(); + nameInput!.value = "renamed_id"; + nameInput!.dispatchEvent(new Event("input")); + await settle(); + + resolveRevalidation!({ value: rebuiltColumns, cacheStatus: "remote" }); + await settle(); + + const renamedInput = root.querySelector("[data-column-name-input]"); + expect(renamedInput?.value).toBe("renamed_id"); + expect(root.textContent).not.toContain("email"); + }); + + it("applies fresh columns even when the comment revalidation fails", async () => { + let columnsCalls = 0; + mocks.loadObjectMetadataFacet.mockImplementation((_request: unknown, facet: string, _loader?: unknown, options?: { force?: boolean }) => { + if (facet === "columns") { + columnsCalls += 1; + return Promise.resolve(columnsCalls === 1 ? { value: initialColumns, cacheStatus: "memory" } : { value: rebuiltColumns, cacheStatus: "remote" }); + } + if (facet === "comment") { + // Cache-served first so the file comment is part of the revalidation. + if (options?.force) return Promise.reject(new Error("comment unavailable")); + return Promise.resolve({ value: "", cacheStatus: "memory" }); + } + return Promise.resolve({ value: [], cacheStatus: "remote" }); + }); + + const root = await mountStructureEditor(); + + await vi.waitFor(() => expect(columnsFacetCalls().length).toBe(2), { timeout: 3000 }); + await settle(); + expect(root.textContent).toContain("email"); + }); +}); diff --git a/issue-assets/issue-8816-auto-revalidate-fixed.png b/issue-assets/issue-8816-auto-revalidate-fixed.png new file mode 100644 index 0000000000..3a304d7d32 Binary files /dev/null and b/issue-assets/issue-8816-auto-revalidate-fixed.png differ diff --git a/issue-assets/issue-8816-stale-after-list-refresh.png b/issue-assets/issue-8816-stale-after-list-refresh.png new file mode 100644 index 0000000000..0d4ad6d330 Binary files /dev/null and b/issue-assets/issue-8816-stale-after-list-refresh.png differ