fix(grid): refresh table metadata before reloading data

This commit is contained in:
soddygo authored and GitHub committed 2026-09-15 16:00:03 +08:00
1 parent 2ee04c51ec
commit fd4ca30707
6 files changed
+353 -110

No files matched your search

@@ -584,7 +584,7 @@ const emit = defineEmits<{
const autoRefresh = useDataGridAutoRefresh({
canRefresh: computed(() => !isSaving.value && !props.loading),
refresh: onToolbarRefresh,
refresh: () => reloadTableData("auto-refresh"),
});
const autoRefreshIntervalSeconds = autoRefresh.intervalSeconds;
const autoRefreshEnabled = autoRefresh.enabled;
@@ -4130,6 +4130,10 @@ function prepareFullReload() {
}
async function onToolbarRefresh() {
await reloadTableData("refresh");
}
async function reloadTableData(intent: DataGridReloadIntent) {
if (transactionActive.value) {
discardChanges();
}
@@ -4140,7 +4144,7 @@ async function onToolbarRefresh() {
}
markConditionInputsApplied();
prepareFullReload();
emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, resetToFirstPage ? 0 : (currentPage.value - 1) * pageSize.value, "refresh");
emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, resetToFirstPage ? 0 : (currentPage.value - 1) * pageSize.value, intent);
}
function setAutoRefreshInterval(seconds: number) {
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
setExecuting: vi.fn(),
updateSql: vi.fn(),
getColumns: vi.fn(),
deleteSchemaCachePrefix: vi.fn(),
listIndexes: vi.fn(),
ensureConnected: vi.fn(),
tableOpenPageSize: 100,
@@ -35,6 +36,7 @@ vi.mock("vue-i18n", () => ({
vi.mock("@/lib/backend/api", () => ({
buildSortedQuerySql: mocks.buildSortedQuerySql,
getColumns: mocks.getColumns,
deleteSchemaCachePrefix: mocks.deleteSchemaCachePrefix,
listIndexes: mocks.listIndexes,
}));
@@ -136,6 +138,7 @@ describe("useDataGridActions", () => {
clearTableMetadataCache();
vi.clearAllMocks();
mocks.executeTabSql.mockReset();
mocks.deleteSchemaCachePrefix.mockReset().mockResolvedValue(undefined);
mocks.tabs.length = 0;
mocks.tableOpenPageSize = 100;
mocks.infiniteScroll = true;
@@ -152,10 +155,151 @@ describe("useDataGridActions", () => {
mocks.listIndexes.mockResolvedValue([]);
});
it("manual refresh waits for cache deletion and fresh columns before building SQL", async () => {
const tab = tableDataTab();
mocks.tabs.push(tab);
let release!: () => void;
mocks.deleteSchemaCachePrefix.mockReturnValue(
new Promise<void>((resolve) => {
release = resolve;
}),
);
mocks.getColumns.mockResolvedValue([...tab.tableMeta!.columns, { name: "added", data_type: "text", is_nullable: true }]);
const actions = useDataGridActions(computed(() => tab));
const reload = actions.onReloadData(tab.id, tab.sql, "", "", "", 25, 50, "refresh");
await vi.waitFor(() => expect(mocks.deleteSchemaCachePrefix).toHaveBeenCalledWith("object-meta:v1:postgres-1:app:public:users:"));
expect(mocks.getColumns).not.toHaveBeenCalled();
expect(mocks.buildTableSelectSql).not.toHaveBeenCalled();
release();
await reload;
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: ["id", "added"], limit: 25, offset: 50 }));
});
it("preserves rows when cache deletion fails and allows retry", async () => {
const tab = tableDataTab();
mocks.tabs.push(tab);
const result = tab.result;
const failure = new Error("cache deletion failed");
mocks.deleteSchemaCachePrefix.mockRejectedValueOnce(failure);
const actions = useDataGridActions(computed(() => tab));
await expect(actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh")).rejects.toMatchObject({ name: "ObjectCacheInvalidationError", reason: failure });
expect(tab.result).toBe(result);
expect(mocks.getColumns).not.toHaveBeenCalled();
expect(mocks.executeTabSql).not.toHaveBeenCalled();
expect(mocks.setExecuting).toHaveBeenLastCalledWith(tab.id, false);
await actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh");
expect(mocks.executeTabSql).toHaveBeenCalledTimes(1);
});
it("waits for fresh columns and suppresses a duplicate refresh", async () => {
const tab = tableDataTab();
mocks.tabs.push(tab);
let release!: (columns: unknown[]) => void;
mocks.getColumns.mockReturnValueOnce(
new Promise((resolve) => {
release = resolve;
}),
);
const actions = useDataGridActions(computed(() => tab));
const reload = actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh");
await vi.waitFor(() => expect(mocks.getColumns).toHaveBeenCalledTimes(1));
await actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh");
expect(mocks.buildTableSelectSql).not.toHaveBeenCalled();
release([{ ...tab.tableMeta!.columns[0], name: "renamed", data_type: "bigint" }]);
await reload;
expect(mocks.executeTabSql).toHaveBeenCalledTimes(1);
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: ["renamed"] }));
expect(tab.tableMeta!.columns[0].data_type).toBe("bigint");
});
it("does not execute after the tab is replaced during metadata loading", async () => {
const tab = tableDataTab();
mocks.tabs.push(tab);
let release!: (columns: unknown[]) => void;
mocks.getColumns.mockReturnValueOnce(
new Promise((resolve) => {
release = resolve;
}),
);
const actions = useDataGridActions(computed(() => tab));
const reload = actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh");
await vi.waitFor(() => expect(mocks.getColumns).toHaveBeenCalledTimes(1));
mocks.tabs.splice(0, 1, tableDataTab());
release(tab.tableMeta!.columns);
await reload;
expect(mocks.setTableMeta).not.toHaveBeenCalled();
expect(mocks.executeTabSql).not.toHaveBeenCalled();
});
it("does not execute old SQL or clear a replacement tab's loading state after SQL generation", async () => {
const tab = tableDataTab();
mocks.tabs.push(tab);
let release!: (sql: string) => void;
mocks.buildTableSelectSql.mockReturnValueOnce(
new Promise<string>((resolve) => {
release = resolve;
}),
);
const actions = useDataGridActions(computed(() => tab));
const reload = actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh");
await vi.waitFor(() => expect(mocks.buildTableSelectSql).toHaveBeenCalledTimes(1));
mocks.tabs.splice(0, 1, tableDataTab({ isExecuting: true }));
mocks.setExecuting.mockClear();
release("SELECT old_target");
await reload;
expect(mocks.updateSql).not.toHaveBeenCalled();
expect(mocks.executeTabSql).not.toHaveBeenCalled();
expect(mocks.setExecuting).not.toHaveBeenCalled();
});
it.each(["closed", "retargeted", "reconnected"] as const)("abandons SQL prepared for a %s tab", async (change) => {
const tab = tableDataTab();
mocks.tabs.push(tab);
let release!: (sql: string) => void;
mocks.buildTableSelectSql.mockReturnValueOnce(
new Promise<string>((resolve) => {
release = resolve;
}),
);
const actions = useDataGridActions(computed(() => tab));
const reload = actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh");
await vi.waitFor(() => expect(mocks.buildTableSelectSql).toHaveBeenCalledTimes(1));
if (change === "closed") mocks.tabs.length = 0;
if (change === "retargeted") tab.tableMeta!.tableName = "other_table";
if (change === "reconnected") mocks.metadataGeneration++;
release("SELECT stale_target");
await reload;
expect(mocks.updateSql).not.toHaveBeenCalled();
expect(mocks.executeTabSql).not.toHaveBeenCalled();
});
it("does not let an older background metadata read overwrite manual refresh", async () => {
const tab = tableDataTab({ tableMetaUpdatedAt: 1 });
mocks.tabs.push(tab);
const oldColumns = tab.tableMeta!.columns;
let release!: (columns: unknown[]) => void;
mocks.getColumns.mockReturnValueOnce(
new Promise((resolve) => {
release = resolve;
}),
);
const actions = useDataGridActions(computed(() => tab));
await actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "auto-refresh");
await vi.waitFor(() => expect(mocks.getColumns).toHaveBeenCalledTimes(1));
mocks.getColumns.mockResolvedValueOnce([...oldColumns, { ...oldColumns[0], name: "added" }]);
await actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh");
expect(tab.tableMeta!.columns.map((column) => column.name)).toEqual(["id", "added"]);
release(oldColumns);
// Drain the old metadata promise and its consumers.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(tab.tableMeta!.columns.map((column) => column.name)).toEqual(["id", "added"]);
});
it("uses the configured table-data default when toolbar reload has no saved pagination", async () => {
mocks.tableOpenPageSize = 250;
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT * FROM public.users LIMIT 250 OFFSET 0");
const tab = tableDataTab();
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
@@ -297,6 +441,7 @@ describe("useDataGridActions", () => {
resultPageOffset: 50,
});
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT * FROM public.users LIMIT 25 OFFSET 50");
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
await actions.onReloadData(tab.id, tab.sql, "", "", "", 25, 50, "refresh");
@@ -316,6 +461,7 @@ describe("useDataGridActions", () => {
},
});
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT * FROM public.users LIMIT 100 OFFSET 100");
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
await actions.onPaginate(tab.id, 100, 100);
@@ -448,8 +594,8 @@ describe("useDataGridActions", () => {
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
// 合成的 ["Error"] 结果列不得进入 SQL 投影:真实列缺失时省略 columns(SELECT *)
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: undefined }));
// Explicit refresh waits for actual metadata rather than projecting fallback error columns.
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: ["id"] }));
await vi.waitFor(() => {
expect(mocks.getColumns).toHaveBeenCalled();
expect(mocks.setTableMeta).toHaveBeenCalledWith("tab-1", expect.objectContaining({ primaryKeys: ["id"] }));
@@ -467,7 +613,7 @@ describe("useDataGridActions", () => {
// 第一轮:stale-tab 早退(tabs 里找不到匹配项时 refreshDataTabTableMeta 直接返回)
mocks.tabs.length = 0;
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "auto-refresh");
await vi.waitFor(() => expect(mocks.getColumns).toHaveBeenCalledTimes(1));
expect(mocks.setTableMeta).not.toHaveBeenCalled();
expect(tab.tableMetaPending).toBe(true);
@@ -475,7 +621,7 @@ describe("useDataGridActions", () => {
// 第二轮:真实 columns 仍为空,新的消费者加入同一在途请求;共享缓存应
// 去重后端调用,但本轮仍要在目标恢复后落地结果
mocks.tabs.push(tab);
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "auto-refresh");
await vi.waitFor(() => {
expect(mocks.getColumns).toHaveBeenCalledTimes(1);
expect(mocks.setTableMeta).toHaveBeenCalledWith("tab-1", expect.objectContaining({ primaryKeys: ["id"] }));
@@ -502,7 +648,7 @@ describe("useDataGridActions", () => {
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "auto-refresh");
// Dameng 元数据必须排在数据查询之后(串行约束,同 useSidebarDataOpenRuntime);
// 真实元数据落地后结束行标识等待
@@ -525,7 +671,7 @@ describe("useDataGridActions", () => {
const actions = useDataGridActions(computed(() => tab));
// 查询 reject 仍会重新抛出,但元数据刷新必须已启动,标签页可恢复
await expect(actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh")).rejects.toThrow("query failed");
await expect(actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "auto-refresh")).rejects.toThrow("query failed");
await vi.waitFor(() => {
expect(mocks.getColumns).toHaveBeenCalled();
expect(tab.tableMetaPending).toBe(false);
@@ -589,12 +735,12 @@ describe("useDataGridActions", () => {
expect(mocks.executeTabSql).toHaveBeenCalledTimes(1);
});
it("does not refetch metadata on a warm toolbar reload in the same generation", async () => {
it("does not refetch metadata on a warm automatic reload in the same generation", async () => {
const tab = tableDataTab();
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "auto-refresh");
expect(mocks.getColumns).not.toHaveBeenCalled();
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: ["id"] }));
@@ -1,3 +1,4 @@
import { invalidateObjectMetadataCache } from "@/lib/metadata/objectMetadataCache";
import { type ComputedRef } from "vue";
import { useI18n } from "vue-i18n";
import { useConnectionStore } from "@/stores/connectionStore";
@@ -13,7 +14,7 @@ import * as api from "@/lib/backend/api";
import type { QueryTab } from "@/types/database";
import { useToast } from "@/composables/useToast";
import { effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/database/jdbcDialect";
import { loadTableMetadata, TABLE_METADATA_CACHE_TTL_MS } from "@/lib/metadata/tableMetadataCache";
import { invalidateTableMetadataCache, loadTableMetadata, TABLE_METADATA_CACHE_TTL_MS } from "@/lib/metadata/tableMetadataCache";
import { isDataTabMetadataLifecycleStale } from "@/lib/sidebar/dataTabOpenPolicy";
import { applyMongoFindSort } from "@/lib/mongo/mongoShellCommand";
import { uuid } from "@/lib/common/utils";
@@ -44,6 +45,8 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const pendingDataReloads = new Set<QueryTab>();
const metadataRequests = new WeakMap<QueryTab, object>();
// A data-grid action names the tab that emitted the event, so a queued
// or late-routed event cannot fall back to whichever tab is active now.
@@ -146,13 +149,17 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
tableName: tableMeta.tableName,
tableType: tableMeta.tableType,
};
// Cache invalidation prevents stale cache writes, but an older caller can
// still receive its result. Only the latest request may update this tab.
const requestToken = {};
metadataRequests.set(tab, requestToken);
const metadataGenerationAtStart = connectionStore.metadataGenerationFor(target.connectionId, target.database);
const trace = options.trace;
console.info("[DBX][reloadData:metadata:ensure-connected:start]", { traceId: trace?.traceId, elapsed: trace?.elapsed() });
await connectionStore.ensureConnected(target.connectionId);
console.info("[DBX][reloadData:metadata:ensure-connected:done]", { traceId: trace?.traceId, elapsed: trace?.elapsed() });
if (connectionStore.metadataGenerationFor(target.connectionId, target.database) !== metadataGenerationAtStart) {
if (metadataRequests.get(tab) !== requestToken || connectionStore.metadataGenerationFor(target.connectionId, target.database) !== metadataGenerationAtStart) {
console.info("[DBX][reloadData:metadata:superseded-by-connection-generation]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), table: target.tableName });
return false;
}
@@ -175,14 +182,23 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
});
const columns = metadata.columns;
console.info("[DBX][reloadData:metadata:get-columns:done]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), columnCount: columns.length });
if (connectionStore.metadataGenerationFor(target.connectionId, target.database) !== metadataGenerationAtStart) {
if (metadataRequests.get(tab) !== requestToken || connectionStore.metadataGenerationFor(target.connectionId, target.database) !== metadataGenerationAtStart) {
console.info("[DBX][reloadData:metadata:superseded-by-connection-generation]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), table: target.tableName });
return false;
}
const current = queryStore.tabs.find((item) => item.id === target.tabId);
const currentMeta = current ? tableMetaForDataTab(current) : undefined;
const currentSourceDatabase = currentMeta?.database ?? current?.database;
if (!current || current.mode !== "data" || current.connectionId !== target.connectionId || currentSourceDatabase !== target.database || currentMeta?.tableName !== target.tableName || (currentMeta.schema ?? "") !== (target.schema ?? "") || (currentMeta.catalog ?? "") !== (target.catalog ?? "")) {
if (
!current ||
current !== tab ||
current.mode !== "data" ||
current.connectionId !== target.connectionId ||
currentSourceDatabase !== target.database ||
currentMeta?.tableName !== target.tableName ||
(currentMeta.schema ?? "") !== (target.schema ?? "") ||
(currentMeta.catalog ?? "") !== (target.catalog ?? "")
) {
console.info("[DBX][reloadData:metadata:stale-tab]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), table: target.tableName });
return false;
}
@@ -213,108 +229,159 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
const startedAt = performance.now();
const elapsed = () => `${Math.round(performance.now() - startedAt)}ms`;
if (tab.mode === "data" && tableMetaForDataTab(tab)) {
reconcileOracleTableType(tab);
tab.whereInput = whereInput ?? "";
queryStore.clearInvalidDataTabSort(tab.id);
const realColumnNames = tab.tableMeta?.columns.map((column) => column.name) ?? [];
let incomingSortMissing = realColumnNames.length > 0 && simpleDataGridOrderByReferencesMissingColumn(orderBy, realColumnNames);
if (incomingSortMissing) tab.orderByInput = undefined;
const pageLimit = limit ?? tab.resultPageLimit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize);
const pageOffset = offset ?? 0;
console.info("[DBX][reloadData:start]", {
traceId,
tabId: tab.id,
connectionId: tab.connectionId,
database: tab.database,
table: tableMetaForDataTab(tab)?.tableName,
elapsed: elapsed(),
});
queryStore.setExecuting(tab.id, true);
const metadataAgeMs = tab.tableMetaUpdatedAt ? Date.now() - tab.tableMetaUpdatedAt : Number.POSITIVE_INFINITY;
// 判断元数据是否真实存在必须用原始 tab.tableMeta:tableMetaForDataTab 会在
// 真实列缺失时用查询结果列合成 columns(包括失败结果的 ["Error"] 列),
// 不能据此跳过刷新,否则恢复/失败后的重试会被 TTL 卡住
const hasRealTableMetaColumns = !!tab.tableMeta?.columns.length;
if (hasRealTableMetaColumns) {
try {
console.info("[DBX][reloadData:ensure-connected:start]", { traceId, elapsed: elapsed() });
await connectionStore.ensureConnected(tab.connectionId);
console.info("[DBX][reloadData:ensure-connected:done]", { traceId, elapsed: elapsed() });
} catch (e: any) {
console.warn("[DBX][reloadData:ensure-connected:error]", { traceId, elapsed: elapsed(), error: e });
queryStore.setExecuting(tab.id, false);
toast(e?.message || String(e), 5000);
throw e;
}
}
const connectionGeneration = connectionStore.metadataGenerationFor(tab.connectionId, tab.database);
const lifecycleStale = isDataTabMetadataLifecycleStale(tab, connectionGeneration);
const shouldRefreshMetadata = lifecycleStale || !hasRealTableMetaColumns || metadataAgeMs > DATA_TAB_METADATA_TTL_MS;
// Dameng 元数据必须与数据查询串行(同 useSidebarDataOpenRuntime),
// 延后到查询完成后再启动。跨生命周期的强制重建除外:必须先拿到新列再
// 构建 SQL,否则第一次 toolbar reload 仍会沿用断链前的显式列列表。
const deferMetadataRefresh = !lifecycleStale && effectiveDatabaseTypeForConnection(connectionStore.getConfig(tab.connectionId)) === "dameng";
const startMetadataRefresh = () => {
console.info("[DBX][reloadData:metadata:background:start]", { traceId, elapsed: elapsed(), reason: hasRealTableMetaColumns ? "stale" : "missing", metadataAgeMs });
void refreshDataTabTableMeta(tab, { force: lifecycleStale, trace: { traceId, elapsed } })
.then(() => {
console.info("[DBX][reloadData:metadata:background:done]", { traceId, elapsed: elapsed() });
})
.catch((e: any) => {
console.warn("[DBX][reloadData:metadata:background:error]", { traceId, elapsed: elapsed(), error: e });
toast(e?.message || String(e), 5000);
});
if (pendingDataReloads.has(tab)) return;
pendingDataReloads.add(tab);
const registered = queryStore.tabs.includes(tab);
const currentTab = () => (registered ? queryStore.tabs.find((item) => item.id === tab.id) : resolveActionTab(tab.id));
const source = tableMetaForDataTab(tab)!;
const target = { connectionId: tab.connectionId, database: tab.database, sourceDatabase: source.database ?? tab.database, tableName: source.tableName, schema: source.schema, catalog: source.catalog };
const stillCurrent = () => {
const meta = tableMetaForDataTab(tab);
return (
currentTab() === tab &&
tab.mode === "data" &&
tab.connectionId === target.connectionId &&
tab.database === target.database &&
(meta?.database ?? tab.database) === target.sourceDatabase &&
meta?.tableName === target.tableName &&
meta?.schema === target.schema &&
meta?.catalog === target.catalog
);
};
if (lifecycleStale) {
if (!hasRealTableMetaColumns) tab.tableMetaPending = true;
console.info("[DBX][reloadData:metadata:await:start]", { traceId, elapsed: elapsed(), reason: hasRealTableMetaColumns ? "lifecycle-stale" : "missing", metadataAgeMs });
const stopPreparing = () => {
// A late request must not reset a replacement tab's execution state.
if (currentTab() === tab) queryStore.setExecuting(tab.id, false);
};
try {
reconcileOracleTableType(tab);
tab.whereInput = whereInput ?? "";
queryStore.clearInvalidDataTabSort(tab.id);
const realColumnNames = tab.tableMeta?.columns.map((column) => column.name) ?? [];
let incomingSortMissing = realColumnNames.length > 0 && simpleDataGridOrderByReferencesMissingColumn(orderBy, realColumnNames);
if (incomingSortMissing) tab.orderByInput = undefined;
const pageLimit = limit ?? tab.resultPageLimit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize);
const pageOffset = offset ?? 0;
console.info("[DBX][reloadData:start]", {
traceId,
tabId: tab.id,
connectionId: tab.connectionId,
database: tab.database,
table: tableMetaForDataTab(tab)?.tableName,
elapsed: elapsed(),
});
queryStore.setExecuting(tab.id, true);
const metadataAgeMs = tab.tableMetaUpdatedAt ? Date.now() - tab.tableMetaUpdatedAt : Number.POSITIVE_INFINITY;
// 判断元数据是否真实存在必须用原始 tab.tableMeta:tableMetaForDataTab 会在
// 真实列缺失时用查询结果列合成 columns(包括失败结果的 ["Error"] 列),
// 不能据此跳过刷新,否则恢复/失败后的重试会被 TTL 卡住
const hasRealTableMetaColumns = !!tab.tableMeta?.columns.length;
if (hasRealTableMetaColumns) {
try {
console.info("[DBX][reloadData:ensure-connected:start]", { traceId, elapsed: elapsed() });
await connectionStore.ensureConnected(tab.connectionId);
console.info("[DBX][reloadData:ensure-connected:done]", { traceId, elapsed: elapsed() });
} catch (e: any) {
console.warn("[DBX][reloadData:ensure-connected:error]", { traceId, elapsed: elapsed(), error: e });
stopPreparing();
toast(e?.message || String(e), 5000);
throw e;
}
}
if (!stillCurrent()) {
stopPreparing();
return;
}
const connectionGeneration = connectionStore.metadataGenerationFor(tab.connectionId, tab.database);
const lifecycleStale = isDataTabMetadataLifecycleStale(tab, connectionGeneration);
const shouldRefreshMetadata = lifecycleStale || !hasRealTableMetaColumns || metadataAgeMs > DATA_TAB_METADATA_TTL_MS;
// Dameng 元数据必须与数据查询串行(同 useSidebarDataOpenRuntime),
// 延后到查询完成后再启动。主动刷新和跨生命周期重建除外:必须先拿到新列再
// 构建 SQL,否则第一次 toolbar reload 仍会沿用断链前的显式列列表。
const deferMetadataRefresh = intent !== "refresh" && !lifecycleStale && effectiveDatabaseTypeForConnection(connectionStore.getConfig(tab.connectionId)) === "dameng";
const startMetadataRefresh = () => {
console.info("[DBX][reloadData:metadata:background:start]", { traceId, elapsed: elapsed(), reason: hasRealTableMetaColumns ? "stale" : "missing", metadataAgeMs });
void refreshDataTabTableMeta(tab, { force: lifecycleStale, trace: { traceId, elapsed } })
.then(() => {
console.info("[DBX][reloadData:metadata:background:done]", { traceId, elapsed: elapsed() });
})
.catch((e: any) => {
console.warn("[DBX][reloadData:metadata:background:error]", { traceId, elapsed: elapsed(), error: e });
toast(e?.message || String(e), 5000);
});
};
if (lifecycleStale || intent === "refresh") {
if (!hasRealTableMetaColumns) tab.tableMetaPending = true;
console.info("[DBX][reloadData:metadata:await:start]", { traceId, elapsed: elapsed(), reason: intent === "refresh" ? "manual-refresh" : "lifecycle-stale", metadataAgeMs });
try {
if (intent === "refresh") {
const meta = tableMetaForDataTab(tab)!;
const config = connectionStore.getConfig(tab.connectionId);
const match = {
connectionId: tab.connectionId,
database: meta.database ?? tab.database,
schema: metadataSchemaForConnection(config, meta.database ?? tab.database, meta.schema),
tableName: meta.tableName,
};
invalidateTableMetadataCache(match);
await invalidateObjectMetadataCache(match, { strict: true });
if (!stillCurrent() || connectionStore.metadataGenerationFor(tab.connectionId, tab.database) !== connectionGeneration) {
stopPreparing();
return;
}
}
const rebuilt = await refreshDataTabTableMeta(tab, { force: true, trace: { traceId, elapsed } });
console.info("[DBX][reloadData:metadata:await:done]", { traceId, elapsed: elapsed(), rebuilt });
if (!rebuilt) {
stopPreparing();
return;
}
} catch (e: any) {
console.warn("[DBX][reloadData:metadata:await:error]", { traceId, elapsed: elapsed(), error: e });
stopPreparing();
toast(e?.message || String(e), 5000);
throw e;
}
queryStore.clearInvalidDataTabSort(tab.id);
const rebuiltColumnNames = tab.tableMeta?.columns.map((column) => column.name) ?? [];
incomingSortMissing = rebuiltColumnNames.length > 0 && simpleDataGridOrderByReferencesMissingColumn(orderBy, rebuiltColumnNames);
if (incomingSortMissing) tab.orderByInput = undefined;
} else if (shouldRefreshMetadata) {
// 元数据缺失(如重启恢复的标签页只持久化了占位身份)时行标识未知:
// 挂起等待,防止数据查询先返回后编辑/保存以空 primaryKeys 短暂可用,
// 走整行 WHERE 保存路径(#3727)。真实元数据经 setTableMeta 落地后解除
if (!hasRealTableMetaColumns) tab.tableMetaPending = true;
if (!deferMetadataRefresh) startMetadataRefresh();
} else {
console.info("[DBX][reloadData:metadata:skip]", { traceId, elapsed: elapsed(), columnCount: tab.tableMeta!.columns.length, metadataAgeMs });
}
try {
const rebuilt = await refreshDataTabTableMeta(tab, { force: true, trace: { traceId, elapsed } });
console.info("[DBX][reloadData:metadata:await:done]", { traceId, elapsed: elapsed(), rebuilt });
if (!rebuilt) {
queryStore.setExecuting(tab.id, false);
console.info("[DBX][reloadData:build-sql:start]", { traceId, elapsed: elapsed() });
const nextSql = await buildTableSql(tab, { whereInput, orderBy: incomingSortMissing ? undefined : orderBy, limit: pageLimit, offset: pageOffset });
console.info("[DBX][reloadData:build-sql:done]", { traceId, elapsed: elapsed() });
if (!stillCurrent() || connectionStore.metadataGenerationFor(tab.connectionId, tab.database) !== connectionGeneration) {
stopPreparing();
return;
}
} catch (e: any) {
console.warn("[DBX][reloadData:metadata:await:error]", { traceId, elapsed: elapsed(), error: e });
queryStore.setExecuting(tab.id, false);
toast(e?.message || String(e), 5000);
queryStore.updateSql(tab.id, nextSql);
console.info("[DBX][reloadData:execute:start]", { traceId, elapsed: elapsed() });
await queryStore.executeTabSql(tab.id, nextSql, {
pagination: { limit: pageLimit, offset: pageOffset },
preserveResultDuringExecution: true,
});
console.info("[DBX][reloadData:execute:done]", { traceId, elapsed: elapsed() });
} catch (e) {
console.error("[DBX][reloadData:error]", { traceId, elapsed: elapsed(), error: e });
stopPreparing();
if (shouldRefreshMetadata && deferMetadataRefresh) startMetadataRefresh();
throw e;
}
queryStore.clearInvalidDataTabSort(tab.id);
const rebuiltColumnNames = tab.tableMeta?.columns.map((column) => column.name) ?? [];
incomingSortMissing = rebuiltColumnNames.length > 0 && simpleDataGridOrderByReferencesMissingColumn(orderBy, rebuiltColumnNames);
if (incomingSortMissing) tab.orderByInput = undefined;
} else if (shouldRefreshMetadata) {
// 元数据缺失(如重启恢复的标签页只持久化了占位身份)时行标识未知:
// 挂起等待,防止数据查询先返回后编辑/保存以空 primaryKeys 短暂可用,
// 走整行 WHERE 保存路径(#3727)。真实元数据经 setTableMeta 落地后解除
if (!hasRealTableMetaColumns) tab.tableMetaPending = true;
if (!deferMetadataRefresh) startMetadataRefresh();
} else {
console.info("[DBX][reloadData:metadata:skip]", { traceId, elapsed: elapsed(), columnCount: tab.tableMeta!.columns.length, metadataAgeMs });
}
try {
console.info("[DBX][reloadData:build-sql:start]", { traceId, elapsed: elapsed() });
const nextSql = await buildTableSql(tab, { whereInput, orderBy: incomingSortMissing ? undefined : orderBy, limit: pageLimit, offset: pageOffset });
console.info("[DBX][reloadData:build-sql:done]", { traceId, elapsed: elapsed() });
queryStore.updateSql(tab.id, nextSql);
console.info("[DBX][reloadData:execute:start]", { traceId, elapsed: elapsed() });
await queryStore.executeTabSql(tab.id, nextSql, {
pagination: { limit: pageLimit, offset: pageOffset },
preserveResultDuringExecution: true,
});
console.info("[DBX][reloadData:execute:done]", { traceId, elapsed: elapsed() });
} catch (e) {
console.error("[DBX][reloadData:error]", { traceId, elapsed: elapsed(), error: e });
queryStore.setExecuting(tab.id, false);
if (shouldRefreshMetadata && deferMetadataRefresh) startMetadataRefresh();
throw e;
return;
} finally {
pendingDataReloads.delete(tab);
}
if (shouldRefreshMetadata && deferMetadataRefresh) startMetadataRefresh();
return;
}
if (intent === "refresh" && tab.mode === "query" && (tab.results?.length ?? 0) > 1) {
if ((intent === "refresh" || intent === "auto-refresh") && tab.mode === "query" && (tab.results?.length ?? 0) > 1) {
const resultGroupSql = tab.resultBaseSql || tab.lastExecutedSql || tab.sql;
if (!resultGroupSql.trim()) return;
tab.resultSortColumn = undefined;
@@ -32,7 +32,7 @@ export function isDataGridToolbarActionCompact(action: DataGridToolbarActionKey,
return actionIndex < Math.max(0, Math.floor(compactActionCount));
}
export type DataGridReloadIntent = "refresh";
export type DataGridReloadIntent = "refresh" | "auto-refresh";
export interface DataGridToolbarActionCapability {
label: string;
@@ -31,6 +31,30 @@ describe("objectMetadataCache", () => {
});
});
it("reports deletion failures only to strict callers sharing the same invalidation", async () => {
let reject!: (error: Error) => void;
mocks.deleteSchemaCachePrefix.mockReturnValueOnce(
new Promise<void>((_, fail) => {
reject = fail;
}),
);
const bestEffort = invalidateObjectMetadataCache(request);
const strict = invalidateObjectMetadataCache(request, { strict: true });
const failure = new Error("storage unavailable");
const rejected = expect(strict).rejects.toMatchObject({ name: "ObjectCacheInvalidationError", reason: failure });
await vi.waitFor(() => expect(mocks.deleteSchemaCachePrefix).toHaveBeenCalledTimes(1));
reject(failure);
await rejected;
await expect(bestEffort).resolves.toBeUndefined();
await expect(invalidateObjectMetadataCache(request, { strict: true })).resolves.toBeUndefined();
expect(mocks.deleteSchemaCachePrefix).toHaveBeenCalledTimes(2);
});
it("keeps forced facet reads best effort if deletion fails", async () => {
mocks.deleteSchemaCachePrefix.mockRejectedValueOnce(new Error("storage unavailable"));
await expect(loadObjectMetadataFacet(request, "columns", async () => ["fresh"], { force: true })).resolves.toEqual({ value: ["fresh"], cacheStatus: "remote" });
});
it("reads a facet from disk without invoking the loader", async () => {
mocks.loadSchemaCache.mockResolvedValue({ version: 1, cachedAt: new Date().toISOString(), value: [{ name: "id" }] });
const loader = vi.fn().mockResolvedValue([{ name: "remote" }]);