mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-29 16:18:55 +00:00
feat(schema): refresh stale metadata cache
This commit is contained in:
@@ -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,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user