feat(execution): multi-database SQL execution with scoped targets

This commit is contained in:
miracle authored and GitHub committed 2026-08-09 14:24:10 +08:00
1 parent 328a2aa107
commit 71ccb3d97d
46 files changed
+4421 -182

No files matched your search

@@ -0,0 +1,23 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
describe("productionSafetyStore scope cancellation", () => {
beforeEach(() => setActivePinia(createPinia()));
it("cancels queued requests in the scope before advancing the confirmation queue", async () => {
const store = useProductionSafetyStore();
const first = store.requestConfirmation({ sql: "UPDATE a SET value = 1", scopeId: "batch-1" });
const second = store.requestConfirmation({ sql: "UPDATE b SET value = 1", scopeId: "batch-1" });
const unrelated = store.requestConfirmation({ sql: "UPDATE c SET value = 1", scopeId: "batch-2" });
store.cancelScope("batch-1");
expect(store.pending?.scopeId).toBe("batch-2");
expect(await first).toBe(false);
expect(await second).toBe(false);
store.confirm();
expect(await unrelated).toBe(true);
expect(store.pending).toBeUndefined();
});
});
@@ -622,6 +622,31 @@ describe("queryStore multi-statement errors", () => {
expect(tab.activeResultRunId).toBeUndefined();
});
it("uses the immutable target context instead of the result tab namespace", async () => {
mocks.executeMulti.mockResolvedValue([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "source-db", "Target", "query", "source-schema", "SELECT 1", "source-catalog");
await store.executeTabSql(tabId, "SELECT 1", {
targetContext: { scope: "database", database: "target-db", schema: "target-schema" },
});
expect(mocks.executeMulti).toHaveBeenCalledWith("mysql-1", "target-db", "SELECT 1", "target-schema", expect.any(String), expect.objectContaining({ catalog: undefined }));
});
it("clears the database and schema for a connection-scoped target", async () => {
mocks.getConnectionConfig.mockReturnValue({ id: "etcd-1", name: "etcd", db_type: "etcd", database: "stale" });
mocks.executeMulti.mockResolvedValue([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("etcd-1", "stale", "Target", "query", "stale-schema", "GET /", "stale-catalog");
await store.executeTabSql(tabId, "GET /", { targetContext: { scope: "connection" } });
expect(mocks.executeMulti).toHaveBeenCalledWith("etcd-1", "", "GET /", undefined, expect.any(String), expect.objectContaining({ catalog: undefined }));
});
it("restores the retained result when a new-result execution fails before dispatch", async () => {
mocks.executeMulti.mockResolvedValueOnce([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]);
const { useQueryStore } = await import("@/stores/queryStore");
@@ -0,0 +1,23 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useSqlExecutionDangerStore } from "@/stores/sqlExecutionDangerStore";
describe("sqlExecutionDangerStore", () => {
beforeEach(() => setActivePinia(createPinia()));
it("cancels all requests in a batch without promoting another request from that batch", async () => {
const store = useSqlExecutionDangerStore();
const first = store.requestConfirmation({ sql: "DROP TABLE a", kind: "sql", scopeId: "batch-1" });
const second = store.requestConfirmation({ sql: "DROP TABLE b", kind: "sql", scopeId: "batch-1" });
const unrelated = store.requestConfirmation({ sql: "DROP TABLE c", kind: "sql", scopeId: "batch-2" });
store.cancelScope("batch-1");
expect(store.pending?.scopeId).toBe("batch-2");
expect(await first).toBe(false);
expect(await second).toBe(false);
store.confirm();
expect(await unrelated).toBe(true);
expect(store.pending).toBeUndefined();
});
});
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useSqlExecutionTargetGroupStore } from "@/stores/sqlExecutionTargetGroupStore";
import { SQL_EXECUTION_TARGET_GROUPS_STORAGE_KEY } from "@/stores/sqlExecutionTargetGroupStore";
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => data.set(key, value),
removeItem: (key: string) => data.delete(key),
});
return data;
}
const targets = [
{ connectionId: "conn-1", database: "app", schema: "public" },
{ connectionId: "conn-2", database: "app", schema: "public" },
] as const;
const resolveDatabaseType = () => "postgres" as const;
describe("sqlExecutionTargetGroupStore", () => {
beforeEach(() => {
installLocalStorage();
setActivePinia(createPinia());
});
it("deduplicates targets and persists reusable target references", () => {
const store = useSqlExecutionTargetGroupStore();
const group = store.createGroup({
name: "Production",
databaseType: "postgres",
targets: [...targets, targets[0]],
resolveDatabaseType,
});
expect(group.targets).toHaveLength(2);
expect(group.targets[0]).toEqual(targets[0]);
expect(JSON.parse(globalThis.localStorage.getItem(SQL_EXECUTION_TARGET_GROUPS_STORAGE_KEY) || "{}")).not.toHaveProperty("password");
setActivePinia(createPinia());
const reloaded = useSqlExecutionTargetGroupStore();
expect(reloaded.getGroup(group.id)?.targets).toEqual([...targets]);
});
it("rejects duplicate names case-insensitively and supports update/clone/delete", () => {
const store = useSqlExecutionTargetGroupStore();
const group = store.createGroup({ name: "Shared", databaseType: "mysql", targets: [targets[0]], resolveDatabaseType: () => "mysql" });
expect(() => store.createGroup({ name: " shared ", databaseType: "mysql", targets: [targets[1]], resolveDatabaseType: () => "mysql" })).toThrow("目标组名称已存在");
const updated = store.updateGroup(group.id, { name: "Shared App", targets: [targets[1]], resolveDatabaseType: () => "mysql" });
expect(updated?.name).toBe("Shared App");
expect(updated?.targets).toEqual([targets[1]]);
const clone = store.cloneGroup(group.id, "Shared Copy", undefined, () => "mysql");
expect(clone?.id).not.toBe(group.id);
expect(clone?.targets).toEqual([targets[1]]);
store.deleteGroup(group.id);
expect(store.getGroup(group.id)).toBeUndefined();
expect(store.getGroup(clone!.id)).toBeDefined();
});
it("rejects a mixed effective database type at the store boundary", () => {
const store = useSqlExecutionTargetGroupStore();
expect(() =>
store.createGroup({
name: "Mixed",
databaseType: "postgres",
targets,
resolveDatabaseType: (target) => (target.connectionId === "conn-1" ? "postgres" : "mysql"),
}),
).toThrow("目标组只能包含同一种数据库类型");
});
});
@@ -7,6 +7,8 @@ export interface ProductionConfirmationRequest {
database?: string;
productionDatabases?: string[];
source?: string;
/** Identifies a cancellable execution batch without coupling this store to its orchestrator. */
scopeId?: string;
}
interface QueuedConfirmationRequest {
@@ -59,5 +61,19 @@ export const useProductionSafetyStore = defineStore("productionSafety", () => {
settle(false);
}
return { pending, requestConfirmation, confirm, cancel };
function cancelScope(scopeId: string) {
// Remove queued requests before settling the active one. `settle` promotes
// the next queue entry, so removing afterwards could promote a request from
// this same scope and leave its promise unresolved.
const retained: QueuedConfirmationRequest[] = [];
const cancelled: QueuedConfirmationRequest[] = [];
for (const entry of queue) {
(entry.request.scopeId === scopeId ? cancelled : retained).push(entry);
}
queue.splice(0, queue.length, ...retained);
cancelled.forEach((entry) => entry.resolve(false));
if (pending.value?.scopeId === scopeId) settle(false);
}
return { pending, requestConfirmation, confirm, cancel, cancelScope };
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,78 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import type { DatabaseType } from "@/types/database";
export interface SqlExecutionDangerRequest {
sql: string;
kind: "sql" | "redis";
connectionName?: string;
database?: string;
targetLabel?: string;
databaseType?: DatabaseType;
/** Identifies a cancellable execution batch without coupling this store to its orchestrator. */
scopeId?: string;
}
interface QueuedDangerRequest {
request: SqlExecutionDangerRequest;
resolve: (confirmed: boolean) => void;
}
export const useSqlExecutionDangerStore = defineStore("sqlExecutionDanger", () => {
const pending = ref<SqlExecutionDangerRequest>();
const queue: QueuedDangerRequest[] = [];
let resolvePending: ((confirmed: boolean) => void) | undefined;
function requestConfirmation(request: SqlExecutionDangerRequest): Promise<boolean> {
return new Promise<boolean>((resolve) => {
if (pending.value) {
queue.push({ request, resolve });
return;
}
begin(request, resolve);
});
}
function begin(request: SqlExecutionDangerRequest, resolve: (confirmed: boolean) => void): void {
pending.value = request;
resolvePending = resolve;
}
function settle(confirmed: boolean): void {
const resolve = resolvePending;
resolvePending = undefined;
pending.value = undefined;
resolve?.(confirmed);
const next = queue.shift();
if (next) begin(next.request, next.resolve);
}
function confirm(): void {
settle(true);
}
function cancel(): void {
settle(false);
}
function cancelAll(): void {
settle(false);
while (queue.length > 0) queue.shift()?.resolve(false);
}
function cancelScope(scopeId: string): void {
// Remove queued requests before settling the active one. `settle` promotes
// the next queue entry, so removing afterwards could promote a request from
// this same scope and leave its promise unresolved.
const retained: QueuedDangerRequest[] = [];
const cancelled: QueuedDangerRequest[] = [];
for (const entry of queue) {
(entry.request.scopeId === scopeId ? cancelled : retained).push(entry);
}
queue.splice(0, queue.length, ...retained);
cancelled.forEach((entry) => entry.resolve(false));
if (pending.value?.scopeId === scopeId) settle(false);
}
return { pending, requestConfirmation, confirm, cancel, cancelAll, cancelScope };
});
@@ -0,0 +1,194 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { uuid } from "@/lib/common/utils";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { dedupeMultiDbExecutionTargets, executionTargetGroupNameKey, normalizeMultiDbExecutionTarget, type MultiDbExecutionTarget, type SqlExecutionTargetDatabaseTypeResolver, type SqlExecutionTargetGroup } from "@/types/sqlExecution";
import type { DatabaseType } from "@/types/database";
const STORAGE_KEY = "dbx-sql-execution-target-groups";
const STORAGE_VERSION = 1;
interface PersistedTargetGroupState {
version: number;
groups: SqlExecutionTargetGroup[];
}
function nowIso(): string {
return new Date().toISOString();
}
function normalizeGroup(value: Partial<SqlExecutionTargetGroup>): SqlExecutionTargetGroup | undefined {
const name = typeof value.name === "string" ? value.name.trim() : "";
const databaseType = value.databaseType;
if (!value.id || !name || !databaseType || !Array.isArray(value.targets)) return undefined;
const targets = dedupeMultiDbExecutionTargets(value.targets);
if (targets.length === 0) return undefined;
const createdAt = typeof value.createdAt === "string" && value.createdAt ? value.createdAt : nowIso();
const updatedAt = typeof value.updatedAt === "string" && value.updatedAt ? value.updatedAt : createdAt;
return {
id: value.id,
name,
databaseType,
targets,
createdAt,
updatedAt,
lastUsedAt: typeof value.lastUsedAt === "string" && value.lastUsedAt ? value.lastUsedAt : undefined,
};
}
function sortGroups(groups: readonly SqlExecutionTargetGroup[]): SqlExecutionTargetGroup[] {
return [...groups].sort((a, b) => {
const aTime = a.lastUsedAt ?? a.updatedAt;
const bTime = b.lastUsedAt ?? b.updatedAt;
const timeDiff = bTime.localeCompare(aTime);
if (timeDiff !== 0) return timeDiff;
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
});
}
function readGroups(): SqlExecutionTargetGroup[] {
const raw = safeLocalStorageGet(STORAGE_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as Partial<PersistedTargetGroupState> | SqlExecutionTargetGroup[];
const values = Array.isArray(parsed) ? parsed : Array.isArray(parsed.groups) ? parsed.groups : [];
const groups: SqlExecutionTargetGroup[] = [];
const names = new Set<string>();
for (const value of values) {
const group = normalizeGroup(value);
if (!group) continue;
const nameKey = executionTargetGroupNameKey(group.name);
if (names.has(nameKey)) continue;
names.add(nameKey);
groups.push(group);
}
return sortGroups(groups);
} catch (error) {
console.warn("[DBX][sql-execution-target-groups:load] invalid persisted state", error);
return [];
}
}
function writeGroups(groups: readonly SqlExecutionTargetGroup[]): void {
const state: PersistedTargetGroupState = {
version: STORAGE_VERSION,
groups: sortGroups(groups),
};
safeLocalStorageSet(STORAGE_KEY, JSON.stringify(state));
}
export const useSqlExecutionTargetGroupStore = defineStore("sqlExecutionTargetGroup", () => {
const groups = ref<SqlExecutionTargetGroup[]>(readGroups());
function hasName(name: string, exceptId?: string): boolean {
const key = executionTargetGroupNameKey(name);
return groups.value.some((group) => group.id !== exceptId && executionTargetGroupNameKey(group.name) === key);
}
function assertGroupName(name: string, exceptId?: string): string {
const normalized = name.trim();
if (!normalized) throw new Error("目标组名称不能为空");
if (hasName(normalized, exceptId)) throw new Error("目标组名称已存在");
return normalized;
}
function assertTargets(targets: readonly MultiDbExecutionTarget[], expectedDatabaseType?: DatabaseType, resolveDatabaseType?: SqlExecutionTargetDatabaseTypeResolver): MultiDbExecutionTarget[] {
const normalized = dedupeMultiDbExecutionTargets(targets);
if (normalized.length === 0) throw new Error("目标组至少需要一个执行目标");
if (expectedDatabaseType && resolveDatabaseType) {
const mismatched = normalized.find((target) => resolveDatabaseType(target) !== expectedDatabaseType);
if (mismatched) throw new Error("目标组只能包含同一种数据库类型的执行目标");
}
return normalized;
}
function persist(): void {
groups.value = sortGroups(groups.value);
writeGroups(groups.value);
}
function createGroup(input: { name: string; databaseType: DatabaseType; targets: readonly MultiDbExecutionTarget[]; resolveDatabaseType: SqlExecutionTargetDatabaseTypeResolver }): SqlExecutionTargetGroup {
const timestamp = nowIso();
const group: SqlExecutionTargetGroup = {
id: uuid(),
name: assertGroupName(input.name),
databaseType: input.databaseType,
targets: assertTargets(input.targets, input.databaseType, input.resolveDatabaseType),
createdAt: timestamp,
updatedAt: timestamp,
};
groups.value = [...groups.value, group];
persist();
return group;
}
function updateGroup(id: string, input: { name?: string; targets?: readonly MultiDbExecutionTarget[]; resolveDatabaseType: SqlExecutionTargetDatabaseTypeResolver }): SqlExecutionTargetGroup | undefined {
const existing = groups.value.find((group) => group.id === id);
if (!existing) return undefined;
const updated: SqlExecutionTargetGroup = {
...existing,
...(input.name !== undefined ? { name: assertGroupName(input.name, id) } : {}),
...(input.targets !== undefined ? { targets: assertTargets(input.targets, existing.databaseType, input.resolveDatabaseType) } : {}),
updatedAt: nowIso(),
};
groups.value = groups.value.map((group) => (group.id === id ? updated : group));
persist();
return updated;
}
function deleteGroup(id: string): void {
groups.value = groups.value.filter((group) => group.id !== id);
persist();
}
function markGroupUsed(id: string): SqlExecutionTargetGroup | undefined {
const existing = groups.value.find((group) => group.id === id);
if (!existing) return undefined;
const updated = { ...existing, lastUsedAt: nowIso() };
groups.value = groups.value.map((group) => (group.id === id ? updated : group));
persist();
return updated;
}
function getGroup(id: string): SqlExecutionTargetGroup | undefined {
return groups.value.find((group) => group.id === id);
}
function getGroupsByDatabaseType(databaseType?: DatabaseType): SqlExecutionTargetGroup[] {
return sortGroups(databaseType ? groups.value.filter((group) => group.databaseType === databaseType) : groups.value);
}
function replaceTargets(id: string, targets: readonly MultiDbExecutionTarget[], resolveDatabaseType: SqlExecutionTargetDatabaseTypeResolver): SqlExecutionTargetGroup | undefined {
return updateGroup(id, { targets, resolveDatabaseType });
}
function cloneGroup(id: string, name: string, targets: readonly MultiDbExecutionTarget[] | undefined, resolveDatabaseType: SqlExecutionTargetDatabaseTypeResolver): SqlExecutionTargetGroup | undefined {
const existing = getGroup(id);
if (!existing) return undefined;
return createGroup({
name,
databaseType: existing.databaseType,
targets: targets ?? existing.targets,
resolveDatabaseType,
});
}
function normalizeTarget(target: MultiDbExecutionTarget): MultiDbExecutionTarget | undefined {
return normalizeMultiDbExecutionTarget(target);
}
return {
groups,
createGroup,
updateGroup,
replaceTargets,
cloneGroup,
deleteGroup,
markGroupUsed,
getGroup,
getGroupsByDatabaseType,
normalizeTarget,
};
});
export { STORAGE_KEY as SQL_EXECUTION_TARGET_GROUPS_STORAGE_KEY };