feat(execution): multi-database SQL execution with scoped targets
This commit is contained in:
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 };
|
||||
Reference in new issue
Block a user