feat(schema): refresh stale metadata cache

This commit is contained in:
t8y2
2026-05-17 16:43:56 +08:00
parent ce0037d70a
commit 92a6ed1a6e
7 changed files with 262 additions and 28 deletions
+5 -5
View File
@@ -19,7 +19,7 @@ import {
generateSyncSql,
type TableDiff,
} from "@/lib/schemaDiff";
import { sqlMetadataRefreshScope } from "@/lib/sqlMetadataRefresh";
import { sqlMetadataRefreshTarget } from "@/lib/sqlMetadataRefresh";
import { useToast } from "@/composables/useToast";
import { Loader2, Copy, Play, GitCompareArrows, ArrowLeftRight } from "lucide-vue-next";
@@ -286,11 +286,11 @@ async function executeSql() {
}
const failed = syncErrors.value.length;
if (failed === 0) {
const scope = sqlMetadataRefreshScope(sql);
if (scope === "connection") {
const refreshTarget = sqlMetadataRefreshTarget(sql, targetSchema.value);
if (refreshTarget.scope === "connection") {
await store.loadDatabases(targetConnectionId.value, { force: true });
} else if (scope === "database") {
await store.refreshObjectListTreeNode(targetConnectionId.value, targetDatabase.value, targetSchema.value);
} else if (refreshTarget.scope === "database") {
await store.refreshObjectListTreeNode(targetConnectionId.value, targetDatabase.value, refreshTarget.schema);
}
toast(t("diff.syncSuccess"), 2000);
open.value = false;
+5 -5
View File
@@ -5,7 +5,7 @@ import { useHistoryStore } from "@/stores/historyStore";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import { classifySqlActivityKind } from "@/lib/historyActivityKind";
import { sqlMetadataRefreshScope } from "@/lib/sqlMetadataRefresh";
import { sqlMetadataRefreshTarget } from "@/lib/sqlMetadataRefresh";
import type { ConnectionConfig, QueryTab } from "@/types/database";
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
@@ -82,11 +82,11 @@ export function useSqlExecution(deps: {
affected_rows: success ? tab.result?.affected_rows : undefined,
});
if (success) {
const scope = sqlMetadataRefreshScope(sql);
if (scope === "connection") {
const refreshTarget = sqlMetadataRefreshTarget(sql, tab.schema);
if (refreshTarget.scope === "connection") {
await connectionStore.loadDatabases(tab.connectionId, { force: true });
} else if (scope === "database") {
await connectionStore.refreshObjectListTreeNode(tab.connectionId, tab.database, tab.schema);
} else if (refreshTarget.scope === "database") {
await connectionStore.refreshObjectListTreeNode(tab.connectionId, tab.database, refreshTarget.schema);
}
}
}
+45
View File
@@ -0,0 +1,45 @@
export const SCHEMA_TREE_CACHE_TTL_MS = 15 * 60 * 1000;
export interface SchemaTreeCacheEnvelope<T> {
version: 1;
cachedAt: string;
children: T;
}
export interface DecodedSchemaTreeCache<T> {
children: T;
isStale: boolean;
}
export function encodeSchemaTreeCache<T>(children: T, nowMs = Date.now()): SchemaTreeCacheEnvelope<T> {
return {
version: 1,
cachedAt: new Date(nowMs).toISOString(),
children,
};
}
export function decodeSchemaTreeCache<T>(
payload: unknown,
nowMs = Date.now(),
ttlMs = SCHEMA_TREE_CACHE_TTL_MS,
): DecodedSchemaTreeCache<T> | null {
if (Array.isArray(payload)) {
return { children: payload as T, isStale: true };
}
if (!payload || typeof payload !== "object") return null;
const envelope = payload as Partial<SchemaTreeCacheEnvelope<unknown>>;
if (envelope.version !== 1 || !Array.isArray(envelope.children) || typeof envelope.cachedAt !== "string") {
return null;
}
const cachedAtMs = Date.parse(envelope.cachedAt);
if (!Number.isFinite(cachedAtMs)) return null;
return {
children: envelope.children as T,
isStale: nowMs - cachedAtMs >= ttlMs,
};
}
+63 -5
View File
@@ -1,8 +1,17 @@
export type SqlMetadataRefreshScope = "none" | "connection" | "database";
export type SqlMetadataRefreshTarget =
| { scope: "none" }
| { scope: "connection" }
| { scope: "database"; schema?: string };
const DATABASE_DDL_RE = /\b(CREATE|DROP)\s+(DATABASE|SCHEMA)\b/i;
const DATABASE_DDL_RE = /\b(CREATE|DROP)\s+DATABASE\b/i;
const SCHEMA_DDL_RE = /\b(CREATE|DROP)\s+SCHEMA\b/i;
const OBJECT_DDL_RE =
/\b(CREATE|ALTER|DROP|RENAME)\s+(OR\s+REPLACE\s+)?(((GLOBAL|LOCAL)\s+)?TEMP(ORARY)?\s+)?(MATERIALIZED\s+)?(TABLE|VIEW|INDEX|SEQUENCE|PROCEDURE|FUNCTION|TRIGGER|TYPE)\b/i;
const OBJECT_NAME_DDL_RE =
/\b(?:CREATE|ALTER|DROP|RENAME)\s+(?:OR\s+REPLACE\s+)?(?:(?:(?:GLOBAL|LOCAL)\s+)?TEMP(?:ORARY)?\s+)?(?:MATERIALIZED\s+)?(?:TABLE|VIEW|SEQUENCE|PROCEDURE|FUNCTION|TRIGGER|TYPE)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?((?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*)\s*\.\s*(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*))/i;
const INDEX_TABLE_DDL_RE =
/\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*)\s+ON\s+((?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*)\s*\.\s*(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*))/i;
function stripSqlMetadataComments(sql: string): string {
return sql
@@ -12,8 +21,57 @@ function stripSqlMetadataComments(sql: string): string {
}
export function sqlMetadataRefreshScope(sql: string): SqlMetadataRefreshScope {
const cleaned = stripSqlMetadataComments(sql);
if (DATABASE_DDL_RE.test(cleaned)) return "connection";
if (OBJECT_DDL_RE.test(cleaned)) return "database";
return "none";
return sqlMetadataRefreshTarget(sql).scope;
}
function splitSqlMetadataStatements(sql: string): string[] {
return stripSqlMetadataComments(sql)
.split(";")
.map((stmt) => stmt.trim())
.filter(Boolean);
}
function unquoteIdentifier(identifier: string): string {
const trimmed = identifier.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("`") && trimmed.endsWith("`")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function schemaFromQualifiedName(qualifiedName: string): string | undefined {
const schema = qualifiedName.split(".")[0]?.trim();
return schema ? unquoteIdentifier(schema) : undefined;
}
function schemaFromObjectDdl(statement: string): string | undefined {
const match = statement.match(INDEX_TABLE_DDL_RE) || statement.match(OBJECT_NAME_DDL_RE);
return match?.[1] ? schemaFromQualifiedName(match[1]) : undefined;
}
export function sqlMetadataRefreshTarget(sql: string, activeSchema?: string): SqlMetadataRefreshTarget {
const statements = splitSqlMetadataStatements(sql);
if (statements.some((stmt) => DATABASE_DDL_RE.test(stmt))) return { scope: "connection" };
const schemaTargets = new Set<string>();
let hasDatabaseRefresh = false;
for (const statement of statements) {
if (SCHEMA_DDL_RE.test(statement)) {
hasDatabaseRefresh = true;
continue;
}
if (!OBJECT_DDL_RE.test(statement)) continue;
hasDatabaseRefresh = true;
const schema = schemaFromObjectDdl(statement) || activeSchema;
if (schema) schemaTargets.add(schema);
}
if (!hasDatabaseRefresh) return { scope: "none" };
if (schemaTargets.size === 1) return { scope: "database", schema: [...schemaTargets][0] };
return { scope: "database" };
}
+55 -11
View File
@@ -32,6 +32,7 @@ import {
expandCachedObjectBrowserNodes,
objectGroupRefreshParentId,
} from "@/lib/tableTree";
import { decodeSchemaTreeCache, encodeSchemaTreeCache } from "@/lib/schemaTreeCache";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes";
@@ -41,6 +42,11 @@ interface LoadTreeOptions {
force?: boolean;
}
interface PersistedTreeChildrenLoadResult {
hit: boolean;
isStale: boolean;
}
function redisDbLabel(db: number, loadedKeyCount?: number, totalKeyCount?: number): string {
if (totalKeyCount == null) return `db${db}`;
return `db${db} (${loadedKeyCount ?? 0}/${totalKeyCount})`;
@@ -113,6 +119,7 @@ export const useConnectionStore = defineStore("connection", () => {
} | null>(null);
const sidebarLayout = ref<SidebarLayout>(emptyLayout());
let layoutPersistTimer: ReturnType<typeof setTimeout> | null = null;
const staleTreeRefreshIds = new Set<string>();
function startEditing(id: string) {
editingConnectionId.value = id;
@@ -331,10 +338,17 @@ export const useConnectionStore = defineStore("connection", () => {
return parts.map((part) => encodeURIComponent(part)).join(":");
}
async function loadPersistedTreeChildren(node: TreeNode, cacheKey: string): Promise<boolean> {
const children = await api.loadSchemaCache<TreeNode[]>(cacheKey).catch(() => null);
if (!children) return false;
const normalizedChildren = expandCachedObjectBrowserNodes(children);
function refreshStaleTreeNode(node: TreeNode) {
if (staleTreeRefreshIds.has(node.id)) return;
staleTreeRefreshIds.add(node.id);
void loadTreeNodeChildren(node, { force: true }).finally(() => staleTreeRefreshIds.delete(node.id));
}
async function loadPersistedTreeChildren(node: TreeNode, cacheKey: string): Promise<PersistedTreeChildrenLoadResult> {
const payload = await api.loadSchemaCache<unknown>(cacheKey).catch(() => null);
const decoded = decodeSchemaTreeCache<TreeNode[]>(payload);
if (!decoded) return { hit: false, isStale: false };
const normalizedChildren = expandCachedObjectBrowserNodes(decoded.children);
setChildren(
node,
node.type === "connection" && node.connectionId
@@ -342,11 +356,11 @@ export const useConnectionStore = defineStore("connection", () => {
: normalizedChildren,
);
node.isExpanded = true;
return true;
return { hit: true, isStale: decoded.isStale };
}
async function savePersistedTreeChildren(cacheKey: string, children: TreeNode[]) {
await api.saveSchemaCache(cacheKey, children).catch(() => undefined);
await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache(children)).catch(() => undefined);
}
function useCachedChildren(node: TreeNode, options?: LoadTreeOptions): boolean {
@@ -628,7 +642,13 @@ export const useConnectionStore = defineStore("connection", () => {
if (config?.db_type === "dameng" || config?.db_type === "oracle") {
const effectiveDb = config.database || "";
const cacheKey = schemaCacheKey(connectionId, effectiveDb, "schemas");
if (!options?.force && (await loadPersistedTreeChildren(node, cacheKey))) return;
if (!options?.force) {
const cached = await loadPersistedTreeChildren(node, cacheKey);
if (cached.hit) {
if (cached.isStale) refreshStaleTreeNode(node);
return;
}
}
const schemas = await api.listSchemas(connectionId, effectiveDb);
const visibleSchemas = filterVisibleDatabaseNames(schemas, config?.visible_databases);
const schemaNodes: TreeNode[] = visibleSchemas.map((s) => ({
@@ -645,7 +665,13 @@ export const useConnectionStore = defineStore("connection", () => {
await savePersistedTreeChildren(cacheKey, schemaNodes);
} else {
const cacheKey = schemaCacheKey(connectionId, "databases");
if (!options?.force && (await loadPersistedTreeChildren(node, cacheKey))) return;
if (!options?.force) {
const cached = await loadPersistedTreeChildren(node, cacheKey);
if (cached.hit) {
if (cached.isStale) refreshStaleTreeNode(node);
return;
}
}
const databases = await api.listDatabases(connectionId);
const visibleNames = filterVisibleDatabaseNames(
databases.map((database) => database.name),
@@ -796,7 +822,13 @@ export const useConnectionStore = defineStore("connection", () => {
await ensureConnected(connectionId);
if (useCachedChildren(node, options)) return;
const cacheKey = schemaCacheKey(connectionId, database, "schemas");
if (!options?.force && (await loadPersistedTreeChildren(node, cacheKey))) return;
if (!options?.force) {
const cached = await loadPersistedTreeChildren(node, cacheKey);
if (cached.hit) {
if (cached.isStale) refreshStaleTreeNode(node);
return;
}
}
const schemas = await api.listSchemas(connectionId, database);
const children = schemas.map((s) => ({
@@ -829,7 +861,13 @@ export const useConnectionStore = defineStore("connection", () => {
await ensureConnected(connectionId);
if (useCachedChildren(node, options)) return;
const cacheKey = schemaCacheKey(connectionId, database, "sqlserver-objects");
if (!options?.force && (await loadPersistedTreeChildren(node, cacheKey))) return;
if (!options?.force) {
const cached = await loadPersistedTreeChildren(node, cacheKey);
if (cached.hit) {
if (cached.isStale) refreshStaleTreeNode(node);
return;
}
}
const [schemas, defaultSchemaObjects] = await Promise.all([
api.listSchemas(connectionId, database),
@@ -856,7 +894,13 @@ export const useConnectionStore = defineStore("connection", () => {
await ensureConnected(connectionId);
if (useCachedChildren(node, options)) return;
const cacheKey = schemaCacheKey(connectionId, database, schema || "", "objects");
if (!options?.force && (await loadPersistedTreeChildren(node, cacheKey))) return;
if (!options?.force) {
const cached = await loadPersistedTreeChildren(node, cacheKey);
if (cached.hit) {
if (cached.isStale) refreshStaleTreeNode(node);
return;
}
}
const querySchema = schema || database;
const config = getConfig(connectionId);