fix(mysql): refresh stale snapshots and streamline table reloads

This commit is contained in:
t8y2 authored and GitHub committed 2026-09-17 19:29:09 +08:00
1 parent e00fbabb35
commit fac270fc8a
11 files changed
+911 -54

No files matched your search

@@ -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")>();
+3 -2
View File
@@ -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;
}
}