fix(mysql): refresh stale snapshots and streamline table reloads
This commit is contained in:
11 files changed
+911
-54
No files matched your search
+134
-14
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDataGridActions } from "@/composables/useDataGridActions";
|
||||
import { clearTableMetadataCache } from "@/lib/metadata/tableMetadataCache";
|
||||
import { restoredDataTabReloadFilters } from "@/lib/table/tableDataRefresh";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
import type { IndexInfo, QueryTab } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
buildTableSelectSql: vi.fn(),
|
||||
@@ -79,14 +79,15 @@ vi.mock("@/stores/queryStore", () => ({
|
||||
if (staleOrder) tab.orderByInput = undefined;
|
||||
return true;
|
||||
}),
|
||||
setTableMeta: mocks.setTableMeta.mockImplementation((id: string, meta: NonNullable<QueryTab["tableMeta"]>) => {
|
||||
setTableMeta: mocks.setTableMeta.mockImplementation((id: string, meta: NonNullable<QueryTab["tableMeta"]>, options: { rowIdentityPending?: boolean } = {}) => {
|
||||
const tab = mocks.tabs.find((item) => item.id === id);
|
||||
if (tab) {
|
||||
tab.tableMeta = meta;
|
||||
tab.tableMetaGeneration = mocks.metadataGeneration;
|
||||
tab.tableMetaUpdatedAt = Date.now();
|
||||
// 与真实 store 一致:仅真实元数据(columns 非空)落地才结束行标识等待
|
||||
if (meta.columns.length > 0) tab.tableMetaPending = false;
|
||||
if (options.rowIdentityPending) tab.tableMetaPending = true;
|
||||
else if (meta.columns.length > 0) tab.tableMetaPending = false;
|
||||
}
|
||||
}),
|
||||
}),
|
||||
@@ -155,7 +156,7 @@ describe("useDataGridActions", () => {
|
||||
mocks.listIndexes.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("manual refresh waits for cache deletion and fresh columns before building SQL", async () => {
|
||||
it("manual refresh starts cache deletion without waiting and builds SQL from fresh columns", async () => {
|
||||
const tab = tableDataTab();
|
||||
mocks.tabs.push(tab);
|
||||
let release!: () => void;
|
||||
@@ -168,26 +169,145 @@ describe("useDataGridActions", () => {
|
||||
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();
|
||||
// 持久缓存删除不再挡在元数据/查询前:列加载可以立即开始
|
||||
await vi.waitFor(() => expect(mocks.getColumns).toHaveBeenCalledTimes(1));
|
||||
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 () => {
|
||||
it("manual refresh keeps index discovery off the awaited path", async () => {
|
||||
const tab = tableDataTab();
|
||||
mocks.tabs.push(tab);
|
||||
let releaseIndexes!: (indexes: unknown[]) => void;
|
||||
mocks.listIndexes.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
releaseIndexes = resolve;
|
||||
}),
|
||||
);
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
const reload = actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh");
|
||||
await reload;
|
||||
// 等待段只拉列:SQL 已构建执行,listIndexes 仍未结算
|
||||
expect(mocks.executeTabSql).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.listIndexes).toHaveBeenCalledTimes(1);
|
||||
releaseIndexes([]);
|
||||
});
|
||||
|
||||
it("manual refresh derives primary keys from fresh columns", async () => {
|
||||
const tab = tableDataTab();
|
||||
mocks.tabs.push(tab);
|
||||
// 表结构变化:旧主键 id 被删,新主键 id2
|
||||
mocks.getColumns.mockResolvedValue([{ name: "id2", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }]);
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
await actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh");
|
||||
// 旧 PK 不在新列中:等待段不得把失效 PK 写回 tab 元数据
|
||||
expect(tab.tableMeta?.primaryKeys).toEqual(["id2"]);
|
||||
});
|
||||
|
||||
it.each([true, false])("keeps a surviving old key read-only until index discovery settles (success=%s)", async (success) => {
|
||||
const tab = reactive(tableDataTab());
|
||||
mocks.tabs.push(tab);
|
||||
mocks.getColumns.mockResolvedValue([{ ...tab.tableMeta!.columns[0], is_primary_key: false }]);
|
||||
let resolveIndexes!: (indexes: IndexInfo[]) => void;
|
||||
let rejectIndexes!: (error: Error) => void;
|
||||
mocks.listIndexes.mockReturnValueOnce(
|
||||
new Promise<IndexInfo[]>((resolve, reject) => {
|
||||
resolveIndexes = resolve;
|
||||
rejectIndexes = reject;
|
||||
}),
|
||||
);
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh");
|
||||
|
||||
expect(mocks.executeTabSql).toHaveBeenCalledOnce();
|
||||
expect(tab.tableMetaPending).toBe(true);
|
||||
expect(tab.tableMeta?.primaryKeys).toEqual([]);
|
||||
if (success) {
|
||||
resolveIndexes([]);
|
||||
await vi.waitFor(() => expect(tab.tableMetaPending).toBe(false));
|
||||
} else {
|
||||
rejectIndexes(new Error("index discovery failed"));
|
||||
await vi.waitFor(() => expect(mocks.setTableMeta).toHaveBeenCalledTimes(2));
|
||||
expect(tab.tableMetaPending).toBe(true);
|
||||
await actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "auto-refresh");
|
||||
await vi.waitFor(() => expect(tab.tableMetaPending).toBe(false));
|
||||
expect(mocks.listIndexes).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
expect(tab.tableMeta?.primaryKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses a freshly discovered unique index only after discovery finishes", async () => {
|
||||
const tab = tableDataTab();
|
||||
mocks.tabs.push(tab);
|
||||
mocks.getColumns.mockResolvedValue([{ ...tab.tableMeta!.columns[0], is_primary_key: false }]);
|
||||
let resolveIndexes!: (indexes: IndexInfo[]) => void;
|
||||
mocks.listIndexes.mockReturnValueOnce(
|
||||
new Promise<IndexInfo[]>((resolve) => {
|
||||
resolveIndexes = resolve;
|
||||
}),
|
||||
);
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh");
|
||||
expect(tab.tableMetaPending).toBe(true);
|
||||
expect(tab.tableMeta?.primaryKeys).toEqual([]);
|
||||
resolveIndexes([{ name: "users_id_unique", columns: ["id"], is_unique: true, is_primary: false }]);
|
||||
await vi.waitFor(() => expect(tab.tableMetaPending).toBe(false));
|
||||
expect(tab.tableMeta?.primaryKeys).toEqual(["id"]);
|
||||
});
|
||||
|
||||
it.each(["TABLE", "VIEW", "MATERIALIZED_VIEW"])("preserves Oracle synthetic projection only for eligible tables (%s)", async (tableType) => {
|
||||
mocks.getConfig.mockReturnValue({ id: "oracle-1", db_type: "oracle" });
|
||||
const tab = tableDataTab({ connectionId: "oracle-1" });
|
||||
tab.tableMeta!.tableType = tableType;
|
||||
tab.tableMeta!.columns[0].is_primary_key = false;
|
||||
tab.tableMeta!.primaryKeys = tableType === "TABLE" ? ["__DBX_ROWID"] : [];
|
||||
mocks.tabs.push(tab);
|
||||
mocks.getColumns.mockResolvedValue(tab.tableMeta!.columns);
|
||||
let resolveIndexes!: (indexes: IndexInfo[]) => void;
|
||||
mocks.listIndexes.mockReturnValueOnce(
|
||||
new Promise<IndexInfo[]>((resolve) => {
|
||||
resolveIndexes = resolve;
|
||||
}),
|
||||
);
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh");
|
||||
|
||||
expect(mocks.executeTabSql).toHaveBeenCalledOnce();
|
||||
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
includeRowId: tableType === "TABLE",
|
||||
primaryKeys: tableType === "TABLE" ? ["__DBX_ROWID"] : [],
|
||||
}),
|
||||
);
|
||||
expect(tab.tableMetaPending).toBe(true);
|
||||
resolveIndexes([]);
|
||||
await vi.waitFor(() => expect(tab.tableMetaPending).toBe(false));
|
||||
expect(tab.tableMeta?.primaryKeys).toEqual(tableType === "TABLE" ? ["__DBX_ROWID"] : []);
|
||||
});
|
||||
|
||||
it("keeps old metadata read-only when fresh columns fail", async () => {
|
||||
const tab = tableDataTab();
|
||||
mocks.tabs.push(tab);
|
||||
mocks.getColumns.mockRejectedValueOnce(new Error("columns failed"));
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await expect(actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh")).rejects.toThrow("columns failed");
|
||||
expect(tab.tableMetaPending).toBe(true);
|
||||
expect(mocks.executeTabSql).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves rows when cache deletion fails and still executes", async () => {
|
||||
const tab = tableDataTab();
|
||||
mocks.tabs.push(tab);
|
||||
const result = tab.result;
|
||||
const failure = new Error("cache deletion failed");
|
||||
mocks.deleteSchemaCachePrefix.mockRejectedValueOnce(failure);
|
||||
mocks.deleteSchemaCachePrefix.mockRejectedValueOnce(new Error("cache deletion failed"));
|
||||
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(tab.result).toBe(result);
|
||||
expect(mocks.executeTabSql).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ function createEditorWithResult(
|
||||
tableColumns?: Array<{ name: string; data_type: string; extra?: string; column_default?: string }>,
|
||||
mongoCollectionGrid = false,
|
||||
quickEntry = false,
|
||||
editable = ref(true),
|
||||
) {
|
||||
let editor: ReturnType<typeof useDataGridEditor>;
|
||||
const result = ref<{ columns: string[]; rows: CellValue[][] }>({
|
||||
@@ -59,7 +60,7 @@ function createEditorWithResult(
|
||||
|
||||
editor = useDataGridEditor({
|
||||
result: computed(() => result.value),
|
||||
editable: computed(() => true),
|
||||
editable: computed(() => editable.value),
|
||||
databaseType: computed(() => (mongoCollectionGrid ? "mongodb" : "postgres")),
|
||||
normalizeEditorInput: mongoCollectionGrid ? mongoDocumentGridInputValue : undefined,
|
||||
connectionId: computed(() => "connection-1"),
|
||||
@@ -145,6 +146,46 @@ describe("useDataGridEditor searched replacements", () => {
|
||||
mocks.getConfig.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it.each([true, false])("blocks queued saves while row identity is pending (autoSave=%s)", async (autoSave) => {
|
||||
const editable = ref(true);
|
||||
const editor = createEditor(undefined, true, undefined, undefined, [["old", "keep", "last"]], undefined, undefined, false, false, editable);
|
||||
editor.newRows.value = [];
|
||||
editor.applyCellValue(0, 0, "new");
|
||||
editable.value = false;
|
||||
|
||||
await editor.saveChanges({ autoSave });
|
||||
|
||||
expect(mocks.prepareDataGridSave).not.toHaveBeenCalled();
|
||||
expect(mocks.executeBatch).not.toHaveBeenCalled();
|
||||
expect(editor.hasPendingChanges.value).toBe(true);
|
||||
editable.value = true;
|
||||
mocks.prepareDataGridSave.mockResolvedValue({ statements: ["UPDATE people SET first='new'"], rollbackStatements: [] });
|
||||
mocks.executeBatch.mockResolvedValue([]);
|
||||
await editor.saveChanges();
|
||||
expect(mocks.executeBatch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rechecks row identity readiness after asynchronous save preparation", async () => {
|
||||
const editable = ref(true);
|
||||
const editor = createEditor(undefined, true, undefined, undefined, [["old", "keep", "last"]], undefined, undefined, false, false, editable);
|
||||
editor.newRows.value = [];
|
||||
editor.applyCellValue(0, 0, "new");
|
||||
let finishPreparation!: (value: { statements: string[]; rollbackStatements: string[] }) => void;
|
||||
mocks.prepareDataGridSave.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finishPreparation = resolve;
|
||||
}),
|
||||
);
|
||||
const save = editor.saveChanges();
|
||||
expect(mocks.prepareDataGridSave).toHaveBeenCalledOnce();
|
||||
editable.value = false;
|
||||
finishPreparation({ statements: ["UPDATE people SET first='new'"], rollbackStatements: [] });
|
||||
await save;
|
||||
expect(mocks.executeBatch).not.toHaveBeenCalled();
|
||||
expect(editor.hasPendingChanges.value).toBe(true);
|
||||
expect(editor.isSaving.value).toBe(false);
|
||||
});
|
||||
|
||||
it("stages one undoable batch, keeps empty strings and preserves prior pending edits", () => {
|
||||
const editor = createEditor(undefined, true, undefined, undefined, [["hit", "keep", "hit"]]);
|
||||
editor.newRows.value = [];
|
||||
|
||||
@@ -8,13 +8,13 @@ import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/table
|
||||
import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit";
|
||||
import { tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues";
|
||||
import { elasticsearchCursorPageJumpRequestCount } from "@/lib/dataGrid/dataGridPagination";
|
||||
import { shouldIncludeSyntheticRowId } from "@/lib/table/tableEditing";
|
||||
import { editablePrimaryKeys, shouldIncludeSyntheticRowId } from "@/lib/table/tableEditing";
|
||||
import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
import type { ColumnInfo, QueryTab } from "@/types/database";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { invalidateTableMetadataCache, loadTableMetadata, TABLE_METADATA_CACHE_TTL_MS } from "@/lib/metadata/tableMetadataCache";
|
||||
import { invalidateTableMetadataCache, loadTableColumns, 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";
|
||||
@@ -26,6 +26,8 @@ import { sqlExecutionTargetCapabilities } from "@/lib/database/sqlExecutionTarge
|
||||
|
||||
const DATA_TAB_METADATA_TTL_MS = TABLE_METADATA_CACHE_TTL_MS;
|
||||
|
||||
type TableMetadataColumns = ColumnInfo[];
|
||||
|
||||
function visibleQuerySortColumns(columns: string[], hiddenColumnIndexes: number[] | undefined, columnIndex: number): { resultColumns: string[]; columnIndex: number } | undefined {
|
||||
const hiddenIndexes = new Set(hiddenColumnIndexes ?? []);
|
||||
const resultColumns: string[] = [];
|
||||
@@ -136,7 +138,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshDataTabTableMeta(tab: QueryTab, options: { force?: boolean; trace?: { traceId: string; elapsed: () => string } } = {}): Promise<boolean> {
|
||||
async function refreshDataTabTableMeta(tab: QueryTab, options: { force?: boolean; columnsOnly?: boolean; trace?: { traceId: string; elapsed: () => string } } = {}): Promise<boolean> {
|
||||
if (tab.mode !== "data" || !tab.connectionId || !tab.database) return false;
|
||||
const tableMeta = tableMetaForDataTab(tab);
|
||||
if (!tableMeta?.tableName) return false;
|
||||
@@ -153,6 +155,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
// still receive its result. Only the latest request may update this tab.
|
||||
const requestToken = {};
|
||||
metadataRequests.set(tab, requestToken);
|
||||
tab.tableMetaPending = true;
|
||||
const metadataGenerationAtStart = connectionStore.metadataGenerationFor(target.connectionId, target.database);
|
||||
const trace = options.trace;
|
||||
|
||||
@@ -165,22 +168,41 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
}
|
||||
const config = connectionStore.getConfig(target.connectionId);
|
||||
const querySchema = metadataSchemaForConnection(config, target.database, target.schema);
|
||||
console.info("[DBX][reloadData:metadata:get-columns:start]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), schema: querySchema, table: target.tableName });
|
||||
console.info("[DBX][reloadData:metadata:get-columns:start]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), schema: querySchema, table: target.tableName, columnsOnly: options.columnsOnly === true });
|
||||
// 复用共享表元数据缓存(30s TTL + in-flight 去重),多个入口对同一张表
|
||||
// 不再各自往返 getColumns/listIndexes。跨连接生命周期的强制重建走 force,
|
||||
// 避免同一共享缓存把断链前的旧列再次交回本次 reload。
|
||||
const { metadata } = await loadTableMetadata({
|
||||
connectionId: target.connectionId,
|
||||
database: target.database,
|
||||
schema: querySchema,
|
||||
tableName: target.tableName,
|
||||
tableType: target.tableType,
|
||||
databaseType: effectiveDatabaseTypeForConnection(config) ?? config?.db_type ?? "",
|
||||
driverProfile: config?.driver_profile || config?.db_type,
|
||||
catalog: target.catalog,
|
||||
force: options.force === true,
|
||||
});
|
||||
const columns = metadata.columns;
|
||||
const loaded: { columns: TableMetadataColumns; primaryKeys: string[]; rowIdentityResolved: boolean } = options.columnsOnly
|
||||
? await (async () => {
|
||||
const { columns } = await loadTableColumns({
|
||||
connectionId: target.connectionId,
|
||||
database: target.database,
|
||||
schema: querySchema,
|
||||
tableName: target.tableName,
|
||||
tableType: target.tableType,
|
||||
databaseType: effectiveDatabaseTypeForConnection(config) ?? config?.db_type ?? "",
|
||||
driverProfile: config?.driver_profile || config?.db_type,
|
||||
catalog: target.catalog,
|
||||
force: options.force === true,
|
||||
});
|
||||
const primaryKeys = editablePrimaryKeys(effectiveDatabaseTypeForConnection(config), columns, target.tableType);
|
||||
return { columns, primaryKeys, rowIdentityResolved: false };
|
||||
})()
|
||||
: await (async () => {
|
||||
const { metadata } = await loadTableMetadata({
|
||||
connectionId: target.connectionId,
|
||||
database: target.database,
|
||||
schema: querySchema,
|
||||
tableName: target.tableName,
|
||||
tableType: target.tableType,
|
||||
databaseType: effectiveDatabaseTypeForConnection(config) ?? config?.db_type ?? "",
|
||||
driverProfile: config?.driver_profile || config?.db_type,
|
||||
catalog: target.catalog,
|
||||
force: options.force === true,
|
||||
});
|
||||
return { columns: metadata.columns, primaryKeys: metadata.primaryKeys, rowIdentityResolved: metadata.rowIdentityResolved !== false };
|
||||
})();
|
||||
const columns = loaded.columns;
|
||||
console.info("[DBX][reloadData:metadata:get-columns:done]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), columnCount: columns.length });
|
||||
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 });
|
||||
@@ -202,8 +224,8 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
console.info("[DBX][reloadData:metadata:stale-tab]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), table: target.tableName });
|
||||
return false;
|
||||
}
|
||||
const primaryKeys = metadata.primaryKeys;
|
||||
queryStore.setTableMeta(target.tabId, {
|
||||
const primaryKeys = loaded.primaryKeys;
|
||||
const refreshedMeta = {
|
||||
catalog: target.catalog,
|
||||
database: target.database,
|
||||
schema: target.schema,
|
||||
@@ -211,7 +233,12 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
tableType: target.tableType,
|
||||
columns,
|
||||
primaryKeys,
|
||||
});
|
||||
};
|
||||
if (loaded.rowIdentityResolved) {
|
||||
queryStore.setTableMeta(target.tabId, refreshedMeta);
|
||||
} else {
|
||||
queryStore.setTableMeta(target.tabId, refreshedMeta, { rowIdentityPending: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -293,7 +320,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
}
|
||||
const connectionGeneration = connectionStore.metadataGenerationFor(tab.connectionId, tab.database);
|
||||
const lifecycleStale = isDataTabMetadataLifecycleStale(tab, connectionGeneration);
|
||||
const shouldRefreshMetadata = lifecycleStale || !hasRealTableMetaColumns || metadataAgeMs > DATA_TAB_METADATA_TTL_MS;
|
||||
const shouldRefreshMetadata = lifecycleStale || tab.tableMetaPending || !hasRealTableMetaColumns || metadataAgeMs > DATA_TAB_METADATA_TTL_MS;
|
||||
// Dameng 元数据必须与数据查询串行(同 useSidebarDataOpenRuntime),
|
||||
// 延后到查询完成后再启动。主动刷新和跨生命周期重建除外:必须先拿到新列再
|
||||
// 构建 SQL,否则第一次 toolbar reload 仍会沿用断链前的显式列列表。
|
||||
@@ -310,7 +337,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
});
|
||||
};
|
||||
if (lifecycleStale || intent === "refresh") {
|
||||
if (!hasRealTableMetaColumns) tab.tableMetaPending = true;
|
||||
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") {
|
||||
@@ -323,13 +350,22 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
tableName: meta.tableName,
|
||||
};
|
||||
invalidateTableMetadataCache(match);
|
||||
await invalidateObjectMetadataCache(match, { strict: true });
|
||||
// 持久缓存(object-meta L2)删除只服务后续结构面板/DDL 读取的
|
||||
// 新鲜度,不参与本次查询构建:移出关键路径 fire-and-forget,
|
||||
// 失败不再中断刷新(网格侧已由上面的内存失效保证列新鲜)。
|
||||
console.info("[DBX][reloadData:object-cache-invalidate:start]", { traceId, elapsed: elapsed() });
|
||||
invalidateObjectMetadataCache(match).catch((e: unknown) => {
|
||||
console.warn("[DBX][reloadData:object-cache-invalidate:error]", { traceId, elapsed: elapsed(), error: e });
|
||||
});
|
||||
if (!stillCurrent() || connectionStore.metadataGenerationFor(tab.connectionId, tab.database) !== connectionGeneration) {
|
||||
stopPreparing();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const rebuilt = await refreshDataTabTableMeta(tab, { force: true, trace: { traceId, elapsed } });
|
||||
// 手动刷新等待段只拉列(getColumns),不等 listIndexes:列投影决定
|
||||
// 本次 SELECT 的正确性;主键/索引用旧值与新列求交,PK 名不进 SQL
|
||||
// 文本(仅作大值预览保护集合),查询本身不受索引元数据延迟影响。
|
||||
const rebuilt = await refreshDataTabTableMeta(tab, { force: true, columnsOnly: intent === "refresh" && !lifecycleStale && hasRealTableMetaColumns, trace: { traceId, elapsed } });
|
||||
console.info("[DBX][reloadData:metadata:await:done]", { traceId, elapsed: elapsed(), rebuilt });
|
||||
if (!rebuilt) {
|
||||
stopPreparing();
|
||||
@@ -345,6 +381,15 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
const rebuiltColumnNames = tab.tableMeta?.columns.map((column) => column.name) ?? [];
|
||||
incomingSortMissing = rebuiltColumnNames.length > 0 && simpleDataGridOrderByReferencesMissingColumn(orderBy, rebuiltColumnNames);
|
||||
if (incomingSortMissing) tab.orderByInput = undefined;
|
||||
if (intent === "refresh" && !lifecycleStale) {
|
||||
void refreshDataTabTableMeta(tab, { force: false, trace: { traceId, elapsed } })
|
||||
.then(() => {
|
||||
console.info("[DBX][reloadData:metadata:background-indexes:done]", { traceId, elapsed: elapsed() });
|
||||
})
|
||||
.catch((e: any) => {
|
||||
console.warn("[DBX][reloadData:metadata:background-indexes:error]", { traceId, elapsed: elapsed(), error: e });
|
||||
});
|
||||
}
|
||||
} else if (shouldRefreshMetadata) {
|
||||
// 元数据缺失(如重启恢复的标签页只持久化了占位身份)时行标识未知:
|
||||
// 挂起等待,防止数据查询先返回后编辑/保存以空 primaryKeys 短暂可用,
|
||||
|
||||
@@ -1849,6 +1849,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
}
|
||||
|
||||
async function saveChanges(saveOptions: SaveChangesOptions = {}) {
|
||||
if (!editable.value) return;
|
||||
if (saveOptions.autoSave && manualSaveRequired.value) return;
|
||||
if (isSaving.value) {
|
||||
if (saveOptions.autoSave) pendingAutoSaveRequested = true;
|
||||
@@ -1881,6 +1882,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
if (!editable.value) return;
|
||||
if (customHandler && snapshot.newRows.length > 0 && customHandler.supportsInsert !== true && customHandler.canInsert !== true) {
|
||||
saveError.value = i18n.global.t("grid.insertRowsNotSupported");
|
||||
return;
|
||||
@@ -1977,6 +1979,10 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!editable.value || stmtOptions?.tableMeta !== tableMeta.value) {
|
||||
await finishInterruptedSaveChanges(snapshot);
|
||||
return;
|
||||
}
|
||||
const start = Date.now();
|
||||
let apiResult: { affected_rows?: number } | undefined;
|
||||
console.info("[DBX][dataGrid:save-statements]", {
|
||||
|
||||
@@ -55,6 +55,20 @@ describe("tableMetadataCache columns facet request counts", () => {
|
||||
mocks.listIndexes.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("does not treat failed index discovery as a complete cacheable row identity", async () => {
|
||||
mocks.getColumns.mockResolvedValue([{ ...column("id"), is_primary_key: false }]);
|
||||
mocks.listIndexes.mockRejectedValueOnce(new Error("indexes unavailable"));
|
||||
const failed = await loadTableMetadata(usersRequest);
|
||||
expect(failed.metadata.rowIdentityResolved).toBe(false);
|
||||
expect(failed.metadata.primaryKeys).toEqual([]);
|
||||
expect(getCachedTableMetadata(usersRequest)).toBeUndefined();
|
||||
|
||||
const retried = await loadTableMetadata(usersRequest);
|
||||
expect(retried.metadata.rowIdentityResolved).toBe(true);
|
||||
expect(mocks.listIndexes).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.getColumns).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("R1 — cold single-source columns load: 1 getColumns, 0 listIndexes", async () => {
|
||||
const result = await loadTableColumns({ ...usersRequest });
|
||||
expect(result.columns[0]?.name).toBe("users");
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface TableMetadata {
|
||||
columns: ColumnInfo[];
|
||||
indexes: IndexInfo[];
|
||||
primaryKeys: string[];
|
||||
rowIdentityResolved?: boolean;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
@@ -331,11 +332,16 @@ export async function loadTableMetadata(request: TableMetadataRequest): Promise<
|
||||
// discovery independently unless an agent-backed PostgreSQL-family
|
||||
// relation must first report its visible schema for the index lookup.
|
||||
const resolveReportedSchema = (request.databaseType === "vastbase" || request.databaseType === "kingbase") && !request.schema;
|
||||
const indexesPromise = resolveReportedSchema ? undefined : loadTableIndexes(request).catch((): IndexInfo[] => []);
|
||||
let rowIdentityResolved = true;
|
||||
const indexFailure = (): IndexInfo[] => {
|
||||
rowIdentityResolved = false;
|
||||
return [];
|
||||
};
|
||||
const indexesPromise = resolveReportedSchema ? undefined : loadTableIndexes(request).catch(indexFailure);
|
||||
const columnsResult = await columnsPromise;
|
||||
const columns = columnsResult.columns;
|
||||
const resolvedSchema = resolveReportedSchema ? columns.find((column) => column.resolved_schema)?.resolved_schema : request.schema;
|
||||
const indexes = columns.length > 0 ? await (indexesPromise ?? loadTableIndexes({ ...request, schema: resolvedSchema }).catch((): IndexInfo[] => [])) : [];
|
||||
const indexes = columns.length > 0 ? await (indexesPromise ?? loadTableIndexes({ ...request, schema: resolvedSchema }).catch(indexFailure)) : [];
|
||||
const primaryKeys = editableRowIdentifierColumns(request.databaseType as DatabaseType, columns, indexes, request.tableType);
|
||||
return {
|
||||
schema: resolvedSchema || undefined,
|
||||
@@ -346,6 +352,7 @@ export async function loadTableMetadata(request: TableMetadataRequest): Promise<
|
||||
columns,
|
||||
indexes,
|
||||
primaryKeys,
|
||||
rowIdentityResolved: rowIdentityResolved && columns.length > 0,
|
||||
cachedAt: columnsResult.cachedAt,
|
||||
};
|
||||
},
|
||||
@@ -353,7 +360,7 @@ export async function loadTableMetadata(request: TableMetadataRequest): Promise<
|
||||
);
|
||||
|
||||
// 必须在 unregister 前比较:最后一个在途加载注销时会顺带清掉代数记录
|
||||
if (invalidationStampAtStart === (tableMetadataInvalidationStamps.get(scopeKey) ?? 0)) {
|
||||
if (metadata.rowIdentityResolved && invalidationStampAtStart === (tableMetadataInvalidationStamps.get(scopeKey) ?? 0)) {
|
||||
tableMetadataCache.set(scope, metadata, { cachedAt: metadata.cachedAt });
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -17,6 +17,25 @@ describe("data tab snapshot restore vs tableMetaPending", () => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("keeps projection-only metadata pending until complete identity metadata arrives", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("pg-1", "app", "users", "data", "public");
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
const metadata = { tableName: "users", schema: "public", columns: [column("id")], primaryKeys: ["id"] };
|
||||
store.setTableMeta(tabId, metadata);
|
||||
expect(tab.tableMetaPending).toBe(false);
|
||||
|
||||
store.setTableMeta(tabId, { ...metadata, columns: [{ ...column("id"), is_primary_key: false }], primaryKeys: [] }, { rowIdentityPending: true });
|
||||
expect(tab.tableMetaPending).toBe(true);
|
||||
expect(tab.tableMeta?.primaryKeys).toEqual([]);
|
||||
store.setTableMeta(tabId, { ...metadata, columns: [], primaryKeys: [] });
|
||||
expect(tab.tableMetaPending).toBe(true);
|
||||
|
||||
store.setTableMeta(tabId, metadata);
|
||||
expect(tab.tableMetaPending).toBe(false);
|
||||
});
|
||||
|
||||
it("does not roll back real metadata to a placeholder snapshot and re-pends placeholder restores", async () => {
|
||||
vi.doMock("@/lib/tabs/tabResultCache", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/tabs/tabResultCache")>();
|
||||
|
||||
@@ -5064,9 +5064,10 @@ export const useQueryStore = defineStore("query", () => {
|
||||
return clearInvalidDataTabSortState(tab, tab.tableMeta.columns);
|
||||
}
|
||||
|
||||
function setTableMeta(id: string, meta: NonNullable<QueryTab["tableMeta"]>) {
|
||||
function setTableMeta(id: string, meta: NonNullable<QueryTab["tableMeta"]>, options: { rowIdentityPending?: boolean } = {}) {
|
||||
const tab = tabs.value.find((t) => t.id === id);
|
||||
if (tab) {
|
||||
if (options.rowIdentityPending) tab.tableMetaPending = true;
|
||||
tab.tableMeta = meta;
|
||||
// 记录写入时的连接元数据代次:disconnect/关库/死池重连会使该代次递增,
|
||||
// 代次失配视同冷缓存,即使位于 30s TTL 窗口内也会重建结构(issue #6623 /
|
||||
@@ -5078,7 +5079,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
// columns/primaryKeys 为空的占位身份(如 useNavigationTargets),不得
|
||||
// 借此提前解除编辑门控。失败/中止路径不清除——标签页保持只读是安全
|
||||
// 兜底,刷新或重开表会重新加载元数据恢复
|
||||
if (meta.columns.length > 0) tab.tableMetaPending = false;
|
||||
if (meta.columns.length > 0 && !options.rowIdentityPending) tab.tableMetaPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user