fix(duckdb): restore parquet preview and reclaim connections
This commit is contained in:
1 parent
a407c77a16
commit
84f1498a4b
16 files changed
+470
-19
No files matched your search
@@ -60,8 +60,15 @@ export function useFileDrop() {
|
||||
port: 0,
|
||||
username: "",
|
||||
password: "",
|
||||
one_time: true,
|
||||
};
|
||||
const connectionId = await api.connectDb(config);
|
||||
let connectionId: string;
|
||||
try {
|
||||
connectionId = await api.connectDb(config);
|
||||
} catch (e: any) {
|
||||
toast(t("welcome.fileOpenFailed", { name, message: e?.message || String(e) }), 5000);
|
||||
continue;
|
||||
}
|
||||
connectionStore.addEphemeralConnection({ ...config, id: connectionId });
|
||||
const tabId = queryStore.createTab(connectionId, "", name, "query");
|
||||
queryStore.updateSql(tabId, dataQuery);
|
||||
|
||||
@@ -2033,6 +2033,7 @@ export default {
|
||||
tipSidebar: "Click a table on the left to view data",
|
||||
tipExecute: "to execute query",
|
||||
fileOpened: "Opened {name}",
|
||||
fileOpenFailed: "Failed to open {name}: {message}",
|
||||
mcpTitle: "AI Agent Integration",
|
||||
mcpDescription: "Beyond the built-in AI assistant, you can also use Claude Code, Cursor, and other coding agents to query your databases via MCP.",
|
||||
mcpLearnMore: "Learn more",
|
||||
|
||||
@@ -1967,6 +1967,7 @@ export default withEnglishFallback({
|
||||
tipSidebar: "Haz clic en una tabla a la izquierda para ver los datos",
|
||||
tipExecute: "para ejecutar la consulta",
|
||||
fileOpened: "Abierto {name}",
|
||||
fileOpenFailed: "No se pudo abrir {name}: {message}",
|
||||
mcpTitle: "Integración con agentes de IA",
|
||||
mcpDescription: "Además del asistente de IA integrado, puedes usar Claude Code, Cursor y otros agentes de programación para consultar tus bases de datos mediante MCP.",
|
||||
mcpLearnMore: "Más información",
|
||||
|
||||
@@ -1965,6 +1965,7 @@ export default withEnglishFallback({
|
||||
tipSidebar: "Fai clic su una tabella a sinistra per visualizzarne i dati",
|
||||
tipExecute: "per eseguire la query",
|
||||
fileOpened: "Aperto {name}",
|
||||
fileOpenFailed: "Impossibile aprire {name}: {message}",
|
||||
mcpTitle: "Integrazione Agente AI",
|
||||
mcpDescription: "Oltre all'assistente AI integrato, puoi utilizzare Claude Code, Cursor e altri agenti di sviluppo per interrogare i tuoi database tramite MCP.",
|
||||
mcpLearnMore: "Scopri di più",
|
||||
|
||||
@@ -1992,6 +1992,7 @@ export default withEnglishFallback({
|
||||
tipSidebar: "左側のテーブルをクリックしてデータを表示",
|
||||
tipExecute: "でクエリを実行",
|
||||
fileOpened: "{name} を開きました",
|
||||
fileOpenFailed: "{name} を開けませんでした:{message}",
|
||||
mcpTitle: "AIエージェント連携",
|
||||
mcpDescription: "内蔵AIアシスタントに加えて、Claude Code、Cursor、その他のコーディングエージェントをMCP経由でデータベースクエリに使用できます。",
|
||||
mcpLearnMore: "詳細を見る",
|
||||
|
||||
@@ -1894,6 +1894,7 @@ export default withEnglishFallback({
|
||||
tipSidebar: "데이터를 보려면 왼쪽의 테이블을 클릭하세요",
|
||||
tipExecute: "쿼리를 실행하려면",
|
||||
fileOpened: "{name}을(를) 열었습니다",
|
||||
fileOpenFailed: "{name}을(를) 열지 못했습니다: {message}",
|
||||
mcpTitle: "AI 에이전트 연동",
|
||||
mcpDescription: "내장된 AI 어시스턴트 외에도 Claude Code, Cursor 등의 코딩 에이전트를 MCP로 연결하여 데이터베이스를 쿼리할 수 있습니다.",
|
||||
mcpLearnMore: "더 알아보기",
|
||||
|
||||
@@ -1967,6 +1967,7 @@ export default withEnglishFallback({
|
||||
tipSidebar: "Clique em uma tabela à esquerda para visualizar os dados",
|
||||
tipExecute: "para executar a consulta",
|
||||
fileOpened: "{name} aberto",
|
||||
fileOpenFailed: "Falha ao abrir {name}: {message}",
|
||||
mcpTitle: "Integração com Agente de IA",
|
||||
mcpDescription: "Além do assistente de IA integrado, você também pode usar Claude Code, Cursor e outros agentes de codificação para consultar seus bancos de dados via MCP.",
|
||||
mcpLearnMore: "Saiba mais",
|
||||
|
||||
@@ -1957,6 +1957,7 @@ export default withEnglishFallback({
|
||||
tipSidebar: "点击左侧表名查看数据",
|
||||
tipExecute: "执行查询",
|
||||
fileOpened: "已打开 {name}",
|
||||
fileOpenFailed: "打开 {name} 失败:{message}",
|
||||
mcpTitle: "AI 编程助手集成",
|
||||
mcpDescription: "除了内置 AI 助手,还可以通过 MCP 让 Claude Code、Cursor 等编程助手直接使用这里的数据库连接。",
|
||||
mcpLearnMore: "了解更多",
|
||||
|
||||
@@ -1966,6 +1966,7 @@ export default withEnglishFallback({
|
||||
tipSidebar: "點選左側資料表以檢視資料",
|
||||
tipExecute: "執行查詢",
|
||||
fileOpened: "已開啟 {name}",
|
||||
fileOpenFailed: "開啟 {name} 失敗:{message}",
|
||||
mcpTitle: "AI 程式設計助理整合",
|
||||
mcpDescription: "除了內建 AI 助理,也可以藉由 MCP 讓 Claude Code、Cursor 等程式設計助理查詢你的資料庫。",
|
||||
mcpLearnMore: "了解更多",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
function installLocalStorage() {
|
||||
const data = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => data.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => data.delete(key)),
|
||||
});
|
||||
}
|
||||
|
||||
function installApiMocks() {
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
|
||||
connectDb: vi.fn().mockResolvedValue("preview-1"),
|
||||
disconnectDb: vi.fn().mockResolvedValue(undefined),
|
||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
connectionDatabaseInfo: vi.fn().mockResolvedValue(undefined),
|
||||
listInstalledAgents: vi.fn().mockResolvedValue([]),
|
||||
sessionCredentialStatus: vi.fn().mockResolvedValue(false),
|
||||
forgetSessionCredential: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
// Result-snapshot cleanup hits a relative URL, which node's fetch rejects outright.
|
||||
// Unrelated to what these cases assert.
|
||||
vi.doMock("@/lib/tabs/tabResultCache", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/tabs/tabResultCache")>();
|
||||
return { ...actual, deleteTabResultSnapshotsForOwner: vi.fn().mockResolvedValue(undefined) };
|
||||
});
|
||||
}
|
||||
|
||||
function previewConnection(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
|
||||
return {
|
||||
id: "preview-1",
|
||||
name: "[Preview] sales.parquet",
|
||||
db_type: "duckdb",
|
||||
host: ":memory:",
|
||||
port: 0,
|
||||
username: "",
|
||||
password: "",
|
||||
one_time: true,
|
||||
...overrides,
|
||||
} as ConnectionConfig;
|
||||
}
|
||||
|
||||
describe("connectionStore one_time runtime cleanup", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
installLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// One-time connections are never persisted, so the backend's save_connections
|
||||
// sync never reclaims them; disconnect_db is the only reclaim point, which makes
|
||||
// an explicit disconnect on removal mandatory.
|
||||
it("removeConnection disconnects a one_time connection so the backend can reclaim it", async () => {
|
||||
installApiMocks();
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
store.connections = [previewConnection()];
|
||||
|
||||
await store.removeConnection("preview-1");
|
||||
|
||||
const { disconnectDb } = await import("@/lib/backend/api");
|
||||
// No clientAttempt: removal is terminal, so a superseded attempt number must not
|
||||
// skip the cleanup.
|
||||
expect(disconnectDb).toHaveBeenCalledWith("preview-1");
|
||||
});
|
||||
|
||||
it("removeConnection leaves saved connections to the save_connections sync", async () => {
|
||||
installApiMocks();
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
store.connections = [previewConnection({ id: "saved-1", one_time: false })];
|
||||
|
||||
await store.removeConnection("saved-1");
|
||||
|
||||
const { disconnectDb } = await import("@/lib/backend/api");
|
||||
expect(disconnectDb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The connection is gone for good, so its tabs can never reconnect: executing in
|
||||
// one would fail with "Connection config not found".
|
||||
it("removeConnection closes the tabs of a one_time connection", async () => {
|
||||
installApiMocks();
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
store.connections = [previewConnection()];
|
||||
queryStore.createTab("preview-1", "", "sales.parquet", "query");
|
||||
expect(queryStore.tabs.some((tab) => tab.connectionId === "preview-1")).toBe(true);
|
||||
|
||||
await store.removeConnection("preview-1");
|
||||
|
||||
expect(queryStore.tabs.some((tab) => tab.connectionId === "preview-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("removeConnection keeps the tabs of a saved connection for the disconnect handling mode", async () => {
|
||||
installApiMocks();
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
store.connections = [previewConnection({ id: "saved-1", one_time: false })];
|
||||
queryStore.createTab("saved-1", "", "query.sql", "query");
|
||||
|
||||
await store.removeConnection("saved-1");
|
||||
|
||||
expect(queryStore.tabs.some((tab) => tab.connectionId === "saved-1")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -711,6 +711,44 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return trackDisconnectRequest(connectionId, request, clientAttempt != null);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time connections are never persisted, so the backend's "not in the saved
|
||||
* list, so reclaim it" branch in `sync_connection_configs` never fires for them
|
||||
* (see dbx-core `should_retain_runtime_config`) and `disconnect_db` is the only
|
||||
* reclaim point. Removing one must disconnect it explicitly, or its runtime
|
||||
* config, pool, and tunnel live until the process exits.
|
||||
*
|
||||
* `clientAttempt` is deliberately omitted: removal is terminal, so a superseded
|
||||
* attempt number must not skip the cleanup.
|
||||
*/
|
||||
function releaseOneTimeRuntimeConnections(connectionIds: string[]) {
|
||||
for (const connectionId of connectionIds) {
|
||||
let request: Promise<void>;
|
||||
try {
|
||||
request = api.disconnectDb(connectionId);
|
||||
} catch (error) {
|
||||
request = Promise.reject(error);
|
||||
}
|
||||
void trackDisconnectRequest(connectionId, request, false).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-time connection is deleted outright rather than left behind for a
|
||||
* reconnect, so its tabs have nothing to point at once it is gone: executing in
|
||||
* one would fail with "Connection config not found". Close them regardless of
|
||||
* `disconnectTabHandlingMode`, which only makes sense for connections that can
|
||||
* still be reconnected.
|
||||
*/
|
||||
async function closeOneTimeConnectionTabs(connectionIds: string[]) {
|
||||
if (!connectionIds.length) return;
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const queryStore = useQueryStore();
|
||||
for (const connectionId of connectionIds) {
|
||||
queryStore.closeConnectionTabs(connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDisconnectKey(connectionId: string, attempt: number): string {
|
||||
return `${connectionId}:${attempt}`;
|
||||
}
|
||||
@@ -2700,8 +2738,11 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
if (!connectionIds.length) return;
|
||||
|
||||
const removedIds = new Set(connectionIds);
|
||||
const oneTimeIds = connectionIds.filter((id) => getConfig(id)?.one_time === true);
|
||||
const nextConnections = connections.value.filter((c) => !removedIds.has(c.id));
|
||||
await persistConnections(nextConnections);
|
||||
await closeOneTimeConnectionTabs(oneTimeIds);
|
||||
releaseOneTimeRuntimeConnections(oneTimeIds);
|
||||
await persistTimeoutInheritanceIds(
|
||||
settingsStore.editorSettings.connectTimeoutInheritConnectionIds.filter((id) => !removedIds.has(id)),
|
||||
settingsStore.editorSettings.queryTimeoutInheritConnectionIds.filter((id) => !removedIds.has(id)),
|
||||
|
||||
Reference in new issue
Block a user