0.8.0: MariaDB + PlanetScale, TimescaleDB, multi-result sets, streaming export, schema diff (#27)
This commit was merged in pull request #27.
This commit is contained in:
@@ -8,7 +8,7 @@ import type { ConnectionFormData } from "./connectionFormData";
|
||||
export interface DetailedConnectionFormProps {
|
||||
form: ConnectionFormData;
|
||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||
managedPreset?: "supabase" | "neon" | null;
|
||||
managedPreset?: "supabase" | "neon" | "planetscale" | null;
|
||||
}
|
||||
|
||||
export function DetailedConnectionForm({ form, onChange, managedPreset }: DetailedConnectionFormProps) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ConnectionFormData } from "./connectionFormData";
|
||||
export interface GeneralTabProps {
|
||||
form: ConnectionFormData;
|
||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||
managedPreset?: "supabase" | "neon" | null;
|
||||
managedPreset?: "supabase" | "neon" | "planetscale" | null;
|
||||
}
|
||||
|
||||
const AUTH_OPTIONS = ["User & Password"];
|
||||
@@ -32,7 +32,7 @@ export function GeneralTab({ form, onChange, managedPreset }: GeneralTabProps) {
|
||||
)}
|
||||
{managedPreset && (
|
||||
<p className="text-xs text-accent-muted mt-1.5">
|
||||
{managedPreset === "supabase" ? "Supabase" : "NeonDB"} requires SSL — enable it under SSH / SSL.
|
||||
{managedPreset === "supabase" ? "Supabase" : managedPreset === "planetscale" ? "PlanetScale" : "NeonDB"} requires SSL — enable it under SSH / SSL.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,7 @@ type Stage = "entry" | "configured";
|
||||
const FALLBACK_PORTS: Record<string, number> = {
|
||||
postgresql: 5432,
|
||||
mysql: 3306,
|
||||
mariadb: 3306,
|
||||
redis: 6379,
|
||||
};
|
||||
|
||||
@@ -73,7 +74,7 @@ export function NewConnectionScreen({
|
||||
}: NewConnectionScreenProps) {
|
||||
const [stage, setStage] = useState<Stage>("entry");
|
||||
const [managedPreset, setManagedPreset] = useState<
|
||||
"supabase" | "neon" | null
|
||||
"supabase" | "neon" | "planetscale" | null
|
||||
>(null);
|
||||
const [form, setForm] = useState<ConnectionFormData>(() =>
|
||||
createEmptyForm(
|
||||
@@ -98,7 +99,7 @@ export function NewConnectionScreen({
|
||||
const parsed = parseConnectionString(value);
|
||||
if (parsed) {
|
||||
const provider =
|
||||
parsed.db_type === "postgresql"
|
||||
parsed.db_type === "postgresql" || parsed.db_type === "mysql"
|
||||
? detectProviderFromHost(parsed.host)
|
||||
: null;
|
||||
setManagedPreset(provider);
|
||||
@@ -138,7 +139,9 @@ export function NewConnectionScreen({
|
||||
(id: ProviderId) => {
|
||||
const provider = getProviderById(id)!;
|
||||
setManagedPreset(
|
||||
provider.isManagedPreset ? (id as "supabase" | "neon") : null,
|
||||
provider.isManagedPreset
|
||||
? (id as "supabase" | "neon" | "planetscale")
|
||||
: null,
|
||||
);
|
||||
revealConfigured({
|
||||
db_type: provider.dbType,
|
||||
@@ -147,6 +150,7 @@ export function NewConnectionScreen({
|
||||
? null
|
||||
: getDefaultPort(provider.dbType),
|
||||
...(provider.dbType === "sqlite" ? { host: "" } : {}),
|
||||
...(id === "planetscale" ? { ssl_mode: "verify-full" } : {}),
|
||||
});
|
||||
},
|
||||
[revealConfigured],
|
||||
@@ -163,7 +167,7 @@ export function NewConnectionScreen({
|
||||
const parsed = parseConnectionString(value);
|
||||
if (parsed) {
|
||||
const provider =
|
||||
parsed.db_type === "postgresql"
|
||||
parsed.db_type === "postgresql" || parsed.db_type === "mysql"
|
||||
? detectProviderFromHost(parsed.host)
|
||||
: null;
|
||||
setManagedPreset(provider);
|
||||
|
||||
@@ -3,12 +3,13 @@ import { ChevronDown } from "lucide-react";
|
||||
import { SETUP_GUIDES } from "../../lib/providers";
|
||||
|
||||
interface ProviderSetupGuideProps {
|
||||
provider: "supabase" | "neon";
|
||||
provider: "supabase" | "neon" | "planetscale";
|
||||
}
|
||||
|
||||
const SSL_NOTE: Record<"supabase" | "neon", string> = {
|
||||
const SSL_NOTE: Record<"supabase" | "neon" | "planetscale", string> = {
|
||||
supabase: "SSL is required by Supabase.",
|
||||
neon: "Neon requires SSL.",
|
||||
planetscale: "PlanetScale requires SSL with identity verification.",
|
||||
};
|
||||
|
||||
export function ProviderSetupGuide({ provider }: ProviderSetupGuideProps) {
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ProviderTabsGrid({ selectedId, onSelect }: ProviderTabsGridProps
|
||||
}`}
|
||||
>
|
||||
<span className="w-8 h-8 flex items-center justify-center">
|
||||
{p.isManagedPreset ? <ProviderIcon id={p.id as "supabase" | "neon"} size={24} /> : <DbIcon type={p.dbType} size={24} />}
|
||||
{p.isManagedPreset ? <ProviderIcon id={p.id as "supabase" | "neon" | "planetscale"} size={24} /> : <DbIcon type={p.dbType} size={24} />}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{p.label}</span>
|
||||
{p.isManagedPreset && (
|
||||
|
||||
@@ -281,8 +281,8 @@ describe("DbViewerScreen", () => {
|
||||
|
||||
it("executes a non-destructive query when Run is clicked", async () => {
|
||||
const executeQuery = vi
|
||||
.spyOn(commands, "executeQuery")
|
||||
.mockResolvedValue(mockQueryResult as any);
|
||||
.spyOn(commands, "executeQueryMulti")
|
||||
.mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any);
|
||||
render(
|
||||
<DbViewerScreen
|
||||
connectionId="c1"
|
||||
@@ -306,13 +306,11 @@ describe("DbViewerScreen", () => {
|
||||
|
||||
it("shows a destructive-query confirmation dialog and executes on confirm", async () => {
|
||||
const executeQuery = vi
|
||||
.spyOn(commands, "executeQuery")
|
||||
.spyOn(commands, "executeQueryMulti")
|
||||
.mockResolvedValue({
|
||||
columns: [],
|
||||
rows: [],
|
||||
total_rows: 0,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
result_sets: [{ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 }],
|
||||
notices: [],
|
||||
execution_time_ms: 42,
|
||||
} as any);
|
||||
render(
|
||||
<DbViewerScreen
|
||||
@@ -345,8 +343,8 @@ describe("DbViewerScreen", () => {
|
||||
});
|
||||
|
||||
it("refreshes the schema tree after a DDL query runs", async () => {
|
||||
vi.spyOn(commands, "executeQuery").mockResolvedValue(
|
||||
mockQueryResult as any,
|
||||
vi.spyOn(commands, "executeQueryMulti").mockResolvedValue(
|
||||
{ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any,
|
||||
);
|
||||
const getSchemas = vi
|
||||
.spyOn(commands, "getSchemas")
|
||||
@@ -377,8 +375,8 @@ describe("DbViewerScreen", () => {
|
||||
});
|
||||
|
||||
it("does not refresh the schema tree after a SELECT", async () => {
|
||||
vi.spyOn(commands, "executeQuery").mockResolvedValue(
|
||||
mockQueryResult as any,
|
||||
vi.spyOn(commands, "executeQueryMulti").mockResolvedValue(
|
||||
{ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any,
|
||||
);
|
||||
const getSchemas = vi
|
||||
.spyOn(commands, "getSchemas")
|
||||
@@ -396,7 +394,7 @@ describe("DbViewerScreen", () => {
|
||||
);
|
||||
fireEvent.change(textarea, { target: { value: "SELECT 1" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
|
||||
await waitFor(() => expect(commands.executeQuery).toHaveBeenCalled());
|
||||
await waitFor(() => expect(commands.executeQueryMulti).toHaveBeenCalled());
|
||||
expect(getSchemas).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -423,8 +421,8 @@ describe("DbViewerScreen", () => {
|
||||
|
||||
it("runs the current query when the Cmd+Enter action fires", async () => {
|
||||
const executeQuery = vi
|
||||
.spyOn(commands, "executeQuery")
|
||||
.mockResolvedValue(mockQueryResult as any);
|
||||
.spyOn(commands, "executeQueryMulti")
|
||||
.mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any);
|
||||
render(
|
||||
<DbViewerScreen
|
||||
connectionId="c1"
|
||||
@@ -452,7 +450,7 @@ describe("DbViewerScreen", () => {
|
||||
const pending = new Promise<unknown>((r) => {
|
||||
resolveRun = r;
|
||||
});
|
||||
vi.spyOn(commands, "executeQuery").mockReturnValue(pending as any);
|
||||
vi.spyOn(commands, "executeQueryMulti").mockReturnValue(pending as any);
|
||||
render(
|
||||
<DbViewerScreen
|
||||
connectionId="c1"
|
||||
@@ -469,7 +467,7 @@ describe("DbViewerScreen", () => {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("query-run-pulse")).toBeInTheDocument(),
|
||||
);
|
||||
resolveRun(mockQueryResult);
|
||||
resolveRun({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 });
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("query-run-pulse")).toBeNull(),
|
||||
);
|
||||
@@ -477,8 +475,8 @@ describe("DbViewerScreen", () => {
|
||||
|
||||
it("collapses and re-expands the query results via the caret", async () => {
|
||||
const executeQuery = vi
|
||||
.spyOn(commands, "executeQuery")
|
||||
.mockResolvedValue(mockQueryResult as any);
|
||||
.spyOn(commands, "executeQueryMulti")
|
||||
.mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any);
|
||||
render(
|
||||
<DbViewerScreen
|
||||
connectionId="c1"
|
||||
@@ -505,8 +503,8 @@ describe("DbViewerScreen", () => {
|
||||
});
|
||||
|
||||
it("resizes the results panel with a drag handle, clamped to min/max", async () => {
|
||||
vi.spyOn(commands, "executeQuery").mockResolvedValue(
|
||||
mockQueryResult as any,
|
||||
vi.spyOn(commands, "executeQueryMulti").mockResolvedValue(
|
||||
{ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any,
|
||||
);
|
||||
render(
|
||||
<DbViewerScreen
|
||||
@@ -930,7 +928,7 @@ describe("DbViewerScreen", () => {
|
||||
});
|
||||
|
||||
it("renders an objectForm tab with the Visual/SQL toggle in the objects view", async () => {
|
||||
vi.spyOn(commands, "executeQuery").mockResolvedValue({ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 } as any);
|
||||
vi.spyOn(commands, "executeQueryMulti").mockResolvedValue({ result_sets: [{ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 }], notices: [], execution_time_ms: 42 } as any);
|
||||
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
|
||||
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
|
||||
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TooltipProvider } from "../ui/Tooltip";
|
||||
import { DbViewerSidebar, NAV_CAPABILITY_KEY } from "./DbViewerSidebar";
|
||||
import { DbViewerToolbar } from "./DbViewerToolbar";
|
||||
import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils";
|
||||
import { executeQuery, cancelQuery } from "../../lib/commands";
|
||||
import { executeQueryMulti, cancelQuery } from "../../lib/commands";
|
||||
import { getCapabilities } from "../../lib/dbCapabilities";
|
||||
|
||||
const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor })));
|
||||
@@ -19,8 +19,11 @@ import { ObjectDetail, type AnyObject } from "./objects/ObjectDetail";
|
||||
import { ObjectFormTab } from "./objects/ObjectFormTab";
|
||||
import { TabBar } from "./TabBar";
|
||||
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
|
||||
import { ResultSetPanels } from "./ResultSetPanels";
|
||||
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
|
||||
import { TableControls } from "./TableControls";
|
||||
import { StreamExportDialog } from "./StreamExportDialog";
|
||||
import { qualifiedTableSql } from "../../lib/exportData";
|
||||
import { EditConnectionModal } from "./EditConnectionModal";
|
||||
import { PasswordPromptDialog } from "./PasswordPromptDialog";
|
||||
import { useDbConnection } from "../../hooks/useDbConnection";
|
||||
@@ -177,6 +180,7 @@ export function DbViewerScreen({
|
||||
const [rowDetailIdx, setRowDetailIdx] = useState<number | null>(null);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [destructiveQuery, setDestructiveQuery] = useState<string | null>(null);
|
||||
const [streamExport, setStreamExport] = useState<{ sql: string } | null>(null);
|
||||
const connections = useConnectionStore((s) => s.connections);
|
||||
const currentConnection =
|
||||
connections.find((c) => c.id === connectionId) ?? null;
|
||||
@@ -260,6 +264,7 @@ export function DbViewerScreen({
|
||||
|
||||
const setTabData = useDbViewerStore((s) => s.setTabData);
|
||||
const setTabError = useDbViewerStore((s) => s.setTabError);
|
||||
const setTabMulti = useDbViewerStore((s) => s.setTabMulti);
|
||||
const setTabLoading = useDbViewerStore((s) => s.setTabLoading);
|
||||
const databases = useDbViewerStore((s) => s.databases);
|
||||
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
|
||||
@@ -311,8 +316,24 @@ export function DbViewerScreen({
|
||||
if (!tab) return;
|
||||
setTabLoading(tabId, true);
|
||||
try {
|
||||
const result = await executeQuery(connectionId, sql, tab.page, tab.pageSize);
|
||||
setTabData(tabId, result);
|
||||
const multi = await executeQueryMulti(connectionId, sql, tab.page, tab.pageSize);
|
||||
const first = multi.result_sets[0] ?? null;
|
||||
// Stamp the run's execution time onto the first set so the toolbar
|
||||
// (TableControls reads activeTab.data.execution_time_ms) still shows it.
|
||||
if (first) {
|
||||
setTabData(tabId, { ...first, execution_time_ms: multi.execution_time_ms });
|
||||
} else {
|
||||
// Lone DML/DDL run with no result set — keep the empty-state behavior.
|
||||
setTabData(tabId, {
|
||||
columns: [],
|
||||
rows: [],
|
||||
total_rows: 0,
|
||||
page: tab.page,
|
||||
page_size: tab.pageSize,
|
||||
execution_time_ms: multi.execution_time_ms,
|
||||
});
|
||||
}
|
||||
setTabMulti(tabId, multi);
|
||||
useQueryStore.getState().invalidateHistory(connectionId);
|
||||
if (isSchemaModifyingQuery(sql)) {
|
||||
const st = useDbViewerStore.getState();
|
||||
@@ -1017,6 +1038,48 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
)
|
||||
: {};
|
||||
|
||||
// Shared VirtualDataGrid props for the query-tab results region. The
|
||||
// multi-result panels reuse these (overriding rows/columns per set);
|
||||
// single-set runs render the grid directly with today's props.
|
||||
const gridPropsForQueryTab = {
|
||||
connectionId,
|
||||
schema: activeSchema,
|
||||
table: activeTable,
|
||||
rows: displayRows,
|
||||
columns,
|
||||
hiddenColumns,
|
||||
selectedRows,
|
||||
dbType: currentConnection?.db_type ?? "postgresql",
|
||||
tabType: activeTab?.tabType ?? "table",
|
||||
getLocator,
|
||||
onStageEdit: readOnlyTable ? undefined : handleStageEdit,
|
||||
onStageInsertCell: readOnlyTable ? undefined : handleStageInsertCell,
|
||||
onOpenRowDetail: handleOpenRowDetail,
|
||||
readOnly: readOnlyTable,
|
||||
enumValues: editorOptions?.enums,
|
||||
fkOptions: editorOptions?.fks,
|
||||
fkPlaceholders: editorOptions?.fkPlaceholders,
|
||||
stagedValues,
|
||||
pendingKeys,
|
||||
pendingInsertChangeIds,
|
||||
onToggleRow: (rowIndex: number) => {
|
||||
setSelectedRows((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(rowIndex)) next.delete(rowIndex);
|
||||
else next.add(rowIndex);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
onToggleAll: () => {
|
||||
setSelectedRows((prev) => {
|
||||
if (prev.size === processedRows.length && processedRows.length > 0) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(processedRows.map((_, i) => i));
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
|
||||
<TabBar onCommitted={handleCommitted} />
|
||||
@@ -1209,74 +1272,31 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
}
|
||||
variant="query"
|
||||
isMatview={readOnlyTable}
|
||||
onExportToFile={() =>
|
||||
setStreamExport({
|
||||
sql:
|
||||
activeTab?.tabType === "query"
|
||||
? activeTab.query ?? ""
|
||||
: qualifiedTableSql(
|
||||
currentConnection?.db_type ?? "postgresql",
|
||||
activeSchema,
|
||||
activeTable,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<VirtualDataGrid
|
||||
connectionId={connectionId}
|
||||
schema={activeSchema}
|
||||
table={activeTable}
|
||||
rows={displayRows}
|
||||
columns={columns}
|
||||
hiddenColumns={hiddenColumns}
|
||||
selectedRows={selectedRows}
|
||||
dbType={currentConnection?.db_type ?? "postgresql"}
|
||||
tabType={activeTab?.tabType ?? "table"}
|
||||
getLocator={getLocator}
|
||||
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
|
||||
onStageInsertCell={readOnlyTable ? undefined : handleStageInsertCell}
|
||||
onOpenRowDetail={handleOpenRowDetail}
|
||||
readOnly={readOnlyTable}
|
||||
enumValues={editorOptions?.enums}
|
||||
fkOptions={editorOptions?.fks}
|
||||
fkPlaceholders={editorOptions?.fkPlaceholders}
|
||||
stagedValues={stagedValues}
|
||||
pendingKeys={pendingKeys}
|
||||
pendingInsertChangeIds={pendingInsertChangeIds}
|
||||
onToggleRow={(rowIndex) => {
|
||||
setSelectedRows(
|
||||
(prev) => {
|
||||
const next =
|
||||
new Set(
|
||||
prev,
|
||||
);
|
||||
if (
|
||||
next.has(
|
||||
rowIndex,
|
||||
)
|
||||
)
|
||||
next.delete(
|
||||
rowIndex,
|
||||
);
|
||||
else
|
||||
next.add(
|
||||
rowIndex,
|
||||
);
|
||||
return next;
|
||||
},
|
||||
);
|
||||
}}
|
||||
onToggleAll={() => {
|
||||
setSelectedRows(
|
||||
(prev) => {
|
||||
if (
|
||||
prev.size ===
|
||||
processedRows.length &&
|
||||
processedRows.length >
|
||||
0
|
||||
) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(
|
||||
processedRows.map(
|
||||
(_, i) =>
|
||||
i,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{activeTab?.tabType === "query" && activeTab.multi && (activeTab.multi.result_sets.length > 1 || activeTab.multi.notices.length > 0) ? (
|
||||
<ResultSetPanels
|
||||
multi={activeTab.multi}
|
||||
renderGrid={(set) => (
|
||||
<VirtualDataGrid {...gridPropsForQueryTab} rows={set.rows} columns={set.columns} />
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<VirtualDataGrid {...gridPropsForQueryTab} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -1375,6 +1395,18 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
setSelectedRows(new Set())
|
||||
}
|
||||
isMatview={readOnlyTable}
|
||||
onExportToFile={() =>
|
||||
setStreamExport({
|
||||
sql:
|
||||
activeTab?.tabType === "query"
|
||||
? activeTab.query ?? ""
|
||||
: qualifiedTableSql(
|
||||
currentConnection?.db_type ?? "postgresql",
|
||||
activeSchema,
|
||||
activeTable,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
@@ -1567,6 +1599,13 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
{capabilities.objects && (
|
||||
<ObjectSearchPalette connectionId={connectionId} />
|
||||
)}
|
||||
{streamExport && (
|
||||
<StreamExportDialog
|
||||
connectionId={connectionId}
|
||||
sql={streamExport.sql}
|
||||
onClose={() => setStreamExport(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { HypertableDetail } from "./HypertableDetail";
|
||||
import type { HypertableInfo } from "../../lib/types";
|
||||
|
||||
const item: HypertableInfo = {
|
||||
name: "metrics",
|
||||
schema: "public",
|
||||
num_dimensions: 2,
|
||||
compression_enabled: true,
|
||||
num_chunks: 42,
|
||||
total_size_bytes: 1048576,
|
||||
};
|
||||
|
||||
describe("HypertableDetail", () => {
|
||||
it("renders dimensions, compression, chunk count and human size", () => {
|
||||
render(<HypertableDetail item={item} onOpenTable={vi.fn()} />);
|
||||
expect(screen.getByText("metrics")).toBeTruthy();
|
||||
expect(screen.getByText(/2 dimensions/)).toBeTruthy();
|
||||
expect(screen.getByText(/compression enabled/)).toBeTruthy();
|
||||
expect(screen.getByText(/42 chunks/)).toBeTruthy();
|
||||
expect(screen.getByText(/1\.0 MB/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the DDL/restore caveat", () => {
|
||||
render(<HypertableDetail item={item} onOpenTable={vi.fn()} />);
|
||||
expect(screen.getByText(/create_hypertable/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens the underlying table on click", () => {
|
||||
const onOpenTable = vi.fn();
|
||||
render(<HypertableDetail item={item} onOpenTable={onOpenTable} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /view table/i }));
|
||||
expect(onOpenTable).toHaveBeenCalledWith("public", "metrics");
|
||||
});
|
||||
|
||||
it("marks compression as disabled when off", () => {
|
||||
render(
|
||||
<HypertableDetail item={{ ...item, compression_enabled: false }} onOpenTable={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByText(/compression disabled/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Table2 } from "lucide-react";
|
||||
import type { HypertableInfo } from "../../lib/types";
|
||||
|
||||
function humanBytes(bytes: number | null): string {
|
||||
if (bytes === null) return "unknown size";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let v = bytes;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
return `${v >= 10 || i === 0 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function HypertableDetail({
|
||||
item,
|
||||
onOpenTable,
|
||||
}: {
|
||||
item: HypertableInfo;
|
||||
onOpenTable: (schema: string, table: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-1 space-y-2">
|
||||
<div className="text-sm text-text">{item.name}</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-text-muted">
|
||||
<span className="rounded bg-surface-raised px-2 py-1">{item.num_dimensions} dimensions</span>
|
||||
<span className="rounded bg-surface-raised px-2 py-1">
|
||||
{item.compression_enabled ? "compression enabled" : "compression disabled"}
|
||||
</span>
|
||||
<span className="rounded bg-surface-raised px-2 py-1">{item.num_chunks} chunks</span>
|
||||
<span className="rounded bg-surface-raised px-2 py-1">{humanBytes(item.total_size_bytes)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenTable(item.schema, item.name)}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-muted hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
<Table2 size={13} /> View table
|
||||
</button>
|
||||
<p className="text-xs text-text-subtle">
|
||||
Note: Copy DDL exports the table as plain SQL plus its create_hypertable call. A
|
||||
full-fidelity restore needs TimescaleDB pre/post-restore hooks, which Gridline does
|
||||
not run.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,10 +22,6 @@ interface ObjectExplorerPageProps {
|
||||
sidebarMode?: boolean;
|
||||
}
|
||||
|
||||
const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map(
|
||||
(t) => ({ value: t, label: TYPE_LABELS[t] }),
|
||||
);
|
||||
|
||||
/** Natural plural for empty-state copy, derived from SINGULAR_LABELS with known irregulars mapped explicitly. */
|
||||
function emptyPlural(type: ObjectType): string {
|
||||
const singular = SINGULAR_LABELS[type];
|
||||
@@ -75,6 +71,7 @@ function typeToDdlType(type: ObjectType): string {
|
||||
sequences: "sequence",
|
||||
enums: "enum",
|
||||
extensions: "extension",
|
||||
hypertables: "table",
|
||||
indexes: "index",
|
||||
constraints: "constraint",
|
||||
roles: "role",
|
||||
@@ -125,6 +122,19 @@ export function ObjectExplorerPage({
|
||||
const changesQueue = useDbViewerStore((s) => s.changesQueue);
|
||||
const openObjectTab = useDbViewerStore((s) => s.openObjectTab);
|
||||
const refreshTree = useDbViewerStore((s) => s.refreshTree);
|
||||
const hypertablesAvailable = useDbViewerStore((s) => s.hypertablesAvailable);
|
||||
const setHypertablesAvailable = useDbViewerStore((s) => s.setHypertablesAvailable);
|
||||
// Build the dropdown from the full object-type union, gating hypertables
|
||||
// on confirmed TimescaleDB availability (visibleObjectTypes in ObjectTree
|
||||
// is scoped to that tree's smaller type set).
|
||||
const objectTypeOptions = useMemo(() => {
|
||||
const all = Object.keys(TYPE_LABELS) as ObjectType[];
|
||||
const visible =
|
||||
hypertablesAvailable === true
|
||||
? all
|
||||
: all.filter((t) => t !== "hypertables");
|
||||
return visible.map((t) => ({ value: t, label: TYPE_LABELS[t] }));
|
||||
}, [hypertablesAvailable]);
|
||||
|
||||
// Use store for persistence, but allow re-fetching when schema changes
|
||||
const [items, setItems] = useState<AnyObject[] | null>(null);
|
||||
@@ -218,6 +228,23 @@ export function ObjectExplorerPage({
|
||||
}
|
||||
}, [currentSchema, fetch]);
|
||||
|
||||
// Probe TimescaleDB availability once per connection so the hypertables
|
||||
// object type only appears in the dropdown when it is actually supported.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await cmd.getHypertables(connectionId);
|
||||
if (!cancelled) setHypertablesAvailable(res.available);
|
||||
} catch {
|
||||
if (!cancelled) setHypertablesAvailable(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connectionId, setHypertablesAvailable]);
|
||||
|
||||
// After a commit containing ddl items succeeds, refetch the current
|
||||
// object-type list + refresh the schema tree so newly created/dropped
|
||||
// objects show up without a manual refresh.
|
||||
@@ -325,7 +352,7 @@ export function ObjectExplorerPage({
|
||||
<SelectDropdown
|
||||
value={type}
|
||||
onChange={(v) => handleTypeChange(v as ObjectType)}
|
||||
options={OBJECT_TYPE_OPTIONS}
|
||||
options={objectTypeOptions}
|
||||
variant="ghost"
|
||||
aria-label="Object type"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { TYPE_LABELS, visibleObjectTypes } from "./ObjectTree";
|
||||
|
||||
describe("hypertables object type", () => {
|
||||
it("registers label and stays in the type union", () => {
|
||||
expect(TYPE_LABELS.hypertables).toBe("hypertables");
|
||||
expect(Object.keys(TYPE_LABELS)).toContain("hypertables");
|
||||
});
|
||||
|
||||
it("hides hypertables until availability is confirmed true", () => {
|
||||
const base = Object.keys(TYPE_LABELS) as ReturnType<typeof visibleObjectTypes>;
|
||||
expect(visibleObjectTypes(null)).not.toContain("hypertables");
|
||||
expect(visibleObjectTypes(false)).not.toContain("hypertables");
|
||||
expect(visibleObjectTypes(true)).toContain("hypertables");
|
||||
expect(visibleObjectTypes(true).length).toBe(base.length);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,24 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { ChevronRight, ChevronDown, FunctionSquare, GitBranch, ListOrdered, Tag, Puzzle, Search } from "lucide-react";
|
||||
import { ChevronRight, ChevronDown, FunctionSquare, GitBranch, ListOrdered, Tag, Puzzle, Layers, Search } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "../../lib/types";
|
||||
import type { FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, HypertableInfo } from "../../lib/types";
|
||||
import { HypertableDetail } from "./HypertableDetail";
|
||||
|
||||
type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions";
|
||||
type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions" | "hypertables";
|
||||
|
||||
interface ObjectTreeProps {
|
||||
type: ObjectType;
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<ObjectType, string> = {
|
||||
export const TYPE_LABELS: Record<ObjectType, string> = {
|
||||
functions: "functions",
|
||||
triggers: "triggers",
|
||||
sequences: "sequences",
|
||||
enums: "enums",
|
||||
extensions: "extensions",
|
||||
hypertables: "hypertables",
|
||||
};
|
||||
|
||||
const ICONS: Record<ObjectType, React.ReactNode> = {
|
||||
@@ -25,8 +27,18 @@ const ICONS: Record<ObjectType, React.ReactNode> = {
|
||||
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
|
||||
enums: <Tag size={14} className="text-text-muted shrink-0" />,
|
||||
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
|
||||
hypertables: <Layers size={14} className="text-text-muted shrink-0" />,
|
||||
};
|
||||
|
||||
/** Object types visible in the dropdown. Hypertables only show when the
|
||||
* connection confirmed TimescaleDB availability (available === true). */
|
||||
export function visibleObjectTypes(hypertablesAvailable: boolean | null): ObjectType[] {
|
||||
const all = Object.keys(TYPE_LABELS) as ObjectType[];
|
||||
return hypertablesAvailable === true
|
||||
? all
|
||||
: all.filter((t) => t !== "hypertables");
|
||||
}
|
||||
|
||||
function SourceCode({ source }: { source: string }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const maxLen = 500;
|
||||
@@ -59,11 +71,13 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
|
||||
const sequences = useDbViewerStore((s) => s.sequences);
|
||||
const enums = useDbViewerStore((s) => s.enums);
|
||||
const extensions = useDbViewerStore((s) => s.extensions);
|
||||
const hypertables = useDbViewerStore((s) => s.hypertables);
|
||||
const setFunctions = useDbViewerStore((s) => s.setFunctions);
|
||||
const setTriggers = useDbViewerStore((s) => s.setTriggers);
|
||||
const setSequences = useDbViewerStore((s) => s.setSequences);
|
||||
const setEnums = useDbViewerStore((s) => s.setEnums);
|
||||
const setExtensions = useDbViewerStore((s) => s.setExtensions);
|
||||
const setHypertables = useDbViewerStore((s) => s.setHypertables);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
|
||||
@@ -77,8 +91,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
|
||||
case "sequences": return sequences;
|
||||
case "enums": return enums;
|
||||
case "extensions": return extensions;
|
||||
case "hypertables": return hypertables;
|
||||
}
|
||||
}, [type, functions, triggers, sequences, enums, extensions]);
|
||||
}, [type, functions, triggers, sequences, enums, extensions, hypertables]);
|
||||
|
||||
const setter = useMemo(() => {
|
||||
switch (type) {
|
||||
@@ -87,8 +102,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
|
||||
case "sequences": return setSequences;
|
||||
case "enums": return setEnums;
|
||||
case "extensions": return setExtensions;
|
||||
case "hypertables": return setHypertables;
|
||||
}
|
||||
}, [type, setFunctions, setTriggers, setSequences, setEnums, setExtensions]);
|
||||
}, [type, setFunctions, setTriggers, setSequences, setEnums, setExtensions, setHypertables]);
|
||||
|
||||
// Fetch on mount if not in store
|
||||
useEffect(() => {
|
||||
@@ -113,6 +129,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
|
||||
} else if (type === "enums") {
|
||||
const result = await cmd.getEnums(connectionId, currentSchema ?? undefined);
|
||||
if (!cancelled) (setEnums as (v: EnumInfo[]) => void)(result);
|
||||
} else if (type === "hypertables") {
|
||||
const res = await cmd.getHypertables(connectionId, currentSchema ?? undefined);
|
||||
if (!cancelled) (setHypertables as (v: HypertableInfo[]) => void)(res.available ? res.items : []);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — store remains null, we show the empty state
|
||||
@@ -123,7 +142,7 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
|
||||
|
||||
fetchData();
|
||||
return () => { cancelled = true; };
|
||||
}, [type, connectionId, currentSchema, data, setter, setFunctions, setTriggers, setSequences, setEnums, setExtensions]);
|
||||
}, [type, connectionId, currentSchema, data, setter, setFunctions, setTriggers, setSequences, setEnums, setExtensions, setHypertables]);
|
||||
|
||||
const q = search.toLowerCase().trim();
|
||||
|
||||
@@ -318,6 +337,16 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{type === "hypertables" && (() => {
|
||||
const h = item as HypertableInfo;
|
||||
return (
|
||||
<HypertableDetail
|
||||
item={h}
|
||||
onOpenTable={(schema, table) => useDbViewerStore.getState().openTab(schema, table)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ResultSetPanels } from "./ResultSetPanels";
|
||||
import type { MultiQueryResult } from "../../lib/types";
|
||||
|
||||
const multi: MultiQueryResult = {
|
||||
result_sets: [
|
||||
{ columns: [{ name: "a", data_type: "int", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }], rows: [[1], [2]], total_rows: 2, page: 1, page_size: 50 },
|
||||
{ columns: [{ name: "b", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }], rows: [], total_rows: 0, page: 1, page_size: 50 },
|
||||
],
|
||||
notices: [
|
||||
{ statement_index: 1, kind: "dml", text: "UPDATE 3", affected: 3 },
|
||||
{ statement_index: 3, kind: "error", text: "relation \"nope\" does not exist", affected: null },
|
||||
],
|
||||
execution_time_ms: 12,
|
||||
};
|
||||
|
||||
describe("ResultSetPanels", () => {
|
||||
it("renders one panel header per result set with row counts", () => {
|
||||
render(<ResultSetPanels multi={multi} renderGrid={vi.fn()} />);
|
||||
expect(screen.getByText(/Result 1/)).toBeTruthy();
|
||||
expect(screen.getByText(/Result 2/)).toBeTruthy();
|
||||
expect(screen.getByText(/2 rows/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders notice cards for DML and errors", () => {
|
||||
render(<ResultSetPanels multi={multi} renderGrid={vi.fn()} />);
|
||||
expect(screen.getByText(/UPDATE 3/)).toBeTruthy();
|
||||
expect(screen.getByText(/3 rows affected/)).toBeTruthy();
|
||||
expect(screen.getByText(/relation "nope" does not exist/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("delegates each set to the grid renderer with its index", () => {
|
||||
const renderGrid = vi.fn(() => <div />);
|
||||
render(<ResultSetPanels multi={multi} renderGrid={renderGrid} />);
|
||||
expect(renderGrid).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { AlertCircle, Info } from "lucide-react";
|
||||
import type { MultiQueryResult, QueryResult } from "../../lib/types";
|
||||
|
||||
export function ResultSetPanels({
|
||||
multi,
|
||||
renderGrid,
|
||||
}: {
|
||||
multi: MultiQueryResult;
|
||||
renderGrid: (set: QueryResult, index: number) => React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
|
||||
{multi.result_sets.map((set, i) => (
|
||||
<section key={i} className="border-b border-border flex flex-col min-h-0">
|
||||
<header className="px-3 py-1.5 text-xs text-text-muted bg-surface-raised border-b border-border flex items-center gap-2 shrink-0">
|
||||
<span className="font-medium text-text">Result {i + 1}</span>
|
||||
<span>— {set.total_rows} row{set.total_rows === 1 ? "" : "s"}</span>
|
||||
</header>
|
||||
<div className="min-h-0" style={{ height: Math.min(Math.max(set.rows.length * 28 + 32, 160), 480) }}>
|
||||
{renderGrid(set, i)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{multi.notices.map((n, i) => (
|
||||
<div
|
||||
key={i}
|
||||
role={n.kind === "error" ? "alert" : "status"}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 text-xs border-b border-border ${
|
||||
n.kind === "error" ? "text-red-400" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{n.kind === "error" ? <AlertCircle size={13} /> : <Info size={13} />}
|
||||
<span className="font-medium">Statement {n.statement_index + 1}</span>
|
||||
<span className="truncate">{n.text}</span>
|
||||
{n.affected !== null && n.affected !== undefined && (
|
||||
<span>({n.affected} rows affected)</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
|
||||
vi.mock("../../lib/commands", () => ({
|
||||
compareSchemas: vi.fn(),
|
||||
getSchemas: vi.fn().mockResolvedValue(["public"]),
|
||||
getDatabases: vi.fn().mockResolvedValue(["app"]),
|
||||
}));
|
||||
vi.mock("../../stores/connectionStore", () => ({
|
||||
useConnectionStore: (sel: (s: unknown) => unknown) =>
|
||||
sel({ connections: [{ id: "src1", name: "Source PG", db_type: "postgresql" }] }),
|
||||
}));
|
||||
|
||||
import { SchemaDiffPage } from "./SchemaDiffPage";
|
||||
import { compareSchemas } from "../../lib/commands";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import type { DiffReport } from "../../lib/types";
|
||||
|
||||
const report: DiffReport = {
|
||||
source_label: "public · src1",
|
||||
target_label: "public · tgt",
|
||||
truncated: false,
|
||||
items: [
|
||||
{
|
||||
object_type: "table", name: "users", kind: "added", detail: [],
|
||||
sync_sql: ['CREATE TABLE "users" (id int);'], destructive: false,
|
||||
},
|
||||
{
|
||||
object_type: "table", name: "old", kind: "removed", detail: [],
|
||||
sync_sql: ["DROP TABLE old;"], destructive: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("SchemaDiffPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useDbViewerStore.getState().clearChanges();
|
||||
(compareSchemas as ReturnType<typeof vi.fn>).mockResolvedValue(report);
|
||||
});
|
||||
|
||||
it("runs the diff and renders grouped items with kind badges", async () => {
|
||||
render(<SchemaDiffPage connectionId="tgt" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /run diff/i }));
|
||||
await waitFor(() => expect(screen.getByText("users")).toBeTruthy());
|
||||
expect(screen.getByText(/added/i)).toBeTruthy();
|
||||
expect(screen.getByText(/removed/i)).toBeTruthy();
|
||||
expect(screen.getByText(/Destructive/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stages only safe items into the queue", async () => {
|
||||
render(<SchemaDiffPage connectionId="tgt" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /run diff/i }));
|
||||
await waitFor(() => expect(screen.getByText("users")).toBeTruthy());
|
||||
fireEvent.click(screen.getByRole("button", { name: /stage 1 safe item/i }));
|
||||
const queue = useDbViewerStore.getState().changesQueue;
|
||||
expect(queue).toHaveLength(1);
|
||||
expect(queue[0].sql).toContain("CREATE TABLE");
|
||||
});
|
||||
|
||||
it("shows the identical empty state", async () => {
|
||||
(compareSchemas as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
source_label: "a", target_label: "b", truncated: false, items: [],
|
||||
});
|
||||
render(<SchemaDiffPage connectionId="tgt" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /run diff/i }));
|
||||
await waitFor(() => expect(screen.getByText(/identical/i)).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Play, Copy, AlertTriangle, GitCompare } from "lucide-react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { compareSchemas, getSchemas, getDatabases } from "../../lib/commands";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import type { DiffItem, DiffReport } from "../../lib/types";
|
||||
|
||||
const TYPE_ORDER = ["enum", "sequence", "table", "column", "constraint", "index", "view"];
|
||||
|
||||
const KIND_BADGE: Record<string, string> = {
|
||||
added: "text-emerald-400 border-emerald-400/40",
|
||||
removed: "text-red-400 border-red-400/40",
|
||||
changed: "text-amber-400 border-amber-400/40",
|
||||
};
|
||||
|
||||
/** Threshold above which the diff report is rendered through a virtualizer
|
||||
* (the snapshot cap can reach ~10k cards). Small diffs render plain, which
|
||||
* keeps jsdom tests deterministic and behavior-neutral below the threshold. */
|
||||
const VIRTUALIZE_THRESHOLD = 200;
|
||||
|
||||
function familyOf(dbType: string): string {
|
||||
return dbType === "mysql" || dbType === "mariadb" ? "mysql" : dbType;
|
||||
}
|
||||
|
||||
function SchemaDiffItemCard({ item }: { item: DiffItem }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded border px-1.5 py-0.5 ${KIND_BADGE[item.kind]}`}>{item.kind}</span>
|
||||
<span className="text-text">{item.name}</span>
|
||||
{item.destructive && (
|
||||
<span className="flex items-center gap-1 text-text-muted">
|
||||
<AlertTriangle size={12} className="text-red-400" /> Destructive — copy only
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.detail.map((d, di) => (
|
||||
<div key={di} className="mt-1 text-text-muted">
|
||||
{d.label}: <span className="line-through">{d.old || "∅"}</span> → {d.new || "∅"}
|
||||
</div>
|
||||
))}
|
||||
{item.sync_sql.length > 0 && (
|
||||
<details className="mt-1.5">
|
||||
<summary className="cursor-pointer text-text-muted">SQL</summary>
|
||||
<pre className="mt-1 rounded bg-surface-raised p-2 overflow-x-auto text-[11px]">{item.sync_sql.join("\n")}</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigator.clipboard?.writeText(item.sync_sql.join("\n"))}
|
||||
className="mt-1 text-text-muted hover:text-text cursor-pointer"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
|
||||
const connections = useConnectionStore((s) => s.connections);
|
||||
const currentSchema = useDbViewerStore((s) => s.currentSchema);
|
||||
const currentConnection = connections.find((c) => c.id === connectionId);
|
||||
const family = familyOf(currentConnection?.db_type ?? "postgresql");
|
||||
|
||||
const [sourceId, setSourceId] = useState<string>("");
|
||||
const [sourceSchemas, setSourceSchemas] = useState<string[]>([]);
|
||||
const [sourceSchema, setSourceSchema] = useState<string>("");
|
||||
const [targetSchema, setTargetSchema] = useState<string>(currentSchema ?? "public");
|
||||
const [report, setReport] = useState<DiffReport | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const sameFamilySources = useMemo(
|
||||
() => connections.filter((c) => c.id !== connectionId && familyOf(c.db_type) === family),
|
||||
[connections, connectionId, family],
|
||||
);
|
||||
|
||||
const pickSource = async (id: string) => {
|
||||
setSourceId(id);
|
||||
setSourceSchema("");
|
||||
setSourceSchemas([]);
|
||||
if (!id) return;
|
||||
// Set a synchronous default so "Run diff" works before the async schema
|
||||
// fetch resolves (the source must already be connected). The fetch below
|
||||
// refines it to the source's actual first schema.
|
||||
setSourceSchema(family === "mysql" ? "" : family === "sqlite" ? "main" : "public");
|
||||
try {
|
||||
// The source must already be connected (opened in the DB viewer) for
|
||||
// capture — getSchemas/getDatabases and compare_schemas error clearly
|
||||
// if not. We do not auto-connect here.
|
||||
const schemas =
|
||||
family === "mysql"
|
||||
? await getDatabases(id)
|
||||
: family === "sqlite"
|
||||
? ["main"]
|
||||
: await getSchemas(id);
|
||||
setSourceSchemas(schemas);
|
||||
setSourceSchema(schemas[0] ?? "");
|
||||
} catch (e) {
|
||||
setError(`Source connection failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-select the first same-family source on mount so "Run diff" works
|
||||
// without manual selection (the source must already be connected).
|
||||
useEffect(() => {
|
||||
if (sourceId === "" && sameFamilySources.length > 0) {
|
||||
void pickSource(sameFamilySources[0].id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sameFamilySources, sourceId]);
|
||||
|
||||
const runDiff = async () => {
|
||||
if (!sourceId || !sourceSchema || !targetSchema) return;
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await compareSchemas(sourceId, sourceSchema, connectionId, targetSchema);
|
||||
setReport(r);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stageAll = () => {
|
||||
if (!report) return;
|
||||
useDbViewerStore.getState().addDiffItems(report.items);
|
||||
};
|
||||
|
||||
const copyAll = () => {
|
||||
if (!report) return;
|
||||
const sql = report.items.flatMap((i) => i.sync_sql).join("\n");
|
||||
void navigator.clipboard?.writeText(sql);
|
||||
};
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
if (!report) return [] as [string, DiffItem[]][];
|
||||
const byType = new Map<string, DiffItem[]>();
|
||||
for (const it of report.items) {
|
||||
const list = byType.get(it.object_type) ?? [];
|
||||
list.push(it);
|
||||
byType.set(it.object_type, list);
|
||||
}
|
||||
return [...byType.entries()].sort(
|
||||
(a, b) => TYPE_ORDER.indexOf(a[0]) - TYPE_ORDER.indexOf(b[0]),
|
||||
);
|
||||
}, [report]);
|
||||
|
||||
const flattenedItems = report ? report.items : [];
|
||||
const shouldVirtualize = flattenedItems.length > VIRTUALIZE_THRESHOLD;
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flattenedItems.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 96,
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
const stageableCount = report
|
||||
? report.items.filter((i) => !i.destructive && i.sync_sql.length > 0).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col overflow-hidden">
|
||||
<div className="px-3 pt-3 pb-3 border-b border-border shrink-0 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<GitCompare size={14} className="text-text-muted" />
|
||||
<span className="text-text-muted">Compare a source connection's schema against this connection (same engine family).</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<select
|
||||
value={sourceId}
|
||||
onChange={(e) => void pickSource(e.target.value)}
|
||||
className="rounded-lg bg-surface-raised border border-border px-2 py-1"
|
||||
aria-label="Source connection"
|
||||
>
|
||||
<option value="">Source connection…</option>
|
||||
{sameFamilySources.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={sourceSchema}
|
||||
onChange={(e) => setSourceSchema(e.target.value)}
|
||||
disabled={!sourceId}
|
||||
className="rounded-lg bg-surface-raised border border-border px-2 py-1 disabled:opacity-50"
|
||||
aria-label="Source schema"
|
||||
>
|
||||
{sourceSchemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<span className="text-text-muted">→</span>
|
||||
<select
|
||||
value={targetSchema}
|
||||
onChange={(e) => setTargetSchema(e.target.value)}
|
||||
className="rounded-lg bg-surface-raised border border-border px-2 py-1"
|
||||
aria-label="Target schema"
|
||||
>
|
||||
<option value={targetSchema}>{targetSchema}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runDiff()}
|
||||
disabled={running || !sourceId || !sourceSchema}
|
||||
className="flex items-center gap-1 rounded-lg bg-accent px-2.5 py-1 text-accent-foreground disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<Play size={13} /> {running ? "Diffing…" : "Run diff"}
|
||||
</button>
|
||||
{report && stageableCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={stageAll}
|
||||
className="rounded-lg bg-surface-raised border border-border px-2.5 py-1 hover:bg-surface cursor-pointer"
|
||||
>
|
||||
Stage {stageableCount} safe item{stageableCount === 1 ? "" : "s"}
|
||||
</button>
|
||||
)}
|
||||
{report && report.items.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyAll}
|
||||
className="flex items-center gap-1 rounded-lg bg-surface-raised border border-border px-2.5 py-1 hover:bg-surface cursor-pointer"
|
||||
>
|
||||
<Copy size={13} /> Copy all SQL
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{report?.truncated && (
|
||||
<div className="flex items-center gap-1 text-xs text-amber-400">
|
||||
<AlertTriangle size={13} /> Snapshot capped at 5,000 objects per side — report is truncated.
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="text-xs text-red-400" role="alert">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto px-3 py-3 space-y-4">
|
||||
{report && report.items.length === 0 && (
|
||||
<div className="text-xs text-text-muted">Schemas are identical — no differences found.</div>
|
||||
)}
|
||||
{shouldVirtualize ? (
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||
{virtualizer.getVirtualItems().map((vi) => (
|
||||
<div
|
||||
key={vi.key}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${vi.start}px)`,
|
||||
}}
|
||||
>
|
||||
<SchemaDiffItemCard item={flattenedItems[vi.index]} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
grouped.map(([type, items]) => (
|
||||
<section key={type}>
|
||||
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wide mb-1.5">{type}s</h3>
|
||||
<div className="space-y-1.5">
|
||||
{items.map((it) => (
|
||||
<SchemaDiffItemCard key={`${it.object_type}-${it.name}`} item={it} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
save: vi.fn().mockResolvedValue("/tmp/export.csv"),
|
||||
}));
|
||||
vi.mock("../../lib/commands", () => ({
|
||||
exportQueryToFile: vi.fn().mockResolvedValue({ rows_written: 3 }),
|
||||
cancelQuery: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn().mockResolvedValue(() => {}),
|
||||
}));
|
||||
|
||||
import { StreamExportDialog } from "./StreamExportDialog";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { exportQueryToFile, cancelQuery } from "../../lib/commands";
|
||||
|
||||
describe("StreamExportDialog", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("picks a path, invokes the export, and reports the row count", async () => {
|
||||
render(<StreamExportDialog connectionId="c1" sql="SELECT 1" onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
expect(save).toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: /export/i }));
|
||||
await waitFor(() => expect(exportQueryToFile).toHaveBeenCalledWith("c1", "SELECT 1", "csv", "/tmp/export.csv"));
|
||||
await waitFor(() => expect(screen.getByText(/3 rows written/i)).toBeTruthy());
|
||||
});
|
||||
|
||||
it("cancels via the existing query-cancel wrapper", async () => {
|
||||
render(<StreamExportDialog connectionId="c1" sql="SELECT 1" onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /export/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||
await waitFor(() => expect(cancelQuery).toHaveBeenCalledWith("c1"));
|
||||
});
|
||||
|
||||
it("surfaces export errors", async () => {
|
||||
(exportQueryToFile as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("boom"));
|
||||
render(<StreamExportDialog connectionId="c1" sql="SELECT 1" onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /export/i }));
|
||||
await waitFor(() => expect(screen.getByText(/boom/i)).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Select } from "../ui/Select";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { exportQueryToFile, cancelQuery } from "../../lib/commands";
|
||||
|
||||
interface StreamExportDialogProps {
|
||||
connectionId: string;
|
||||
sql: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const FORMAT_OPTIONS = [
|
||||
{ value: "csv", label: "CSV" },
|
||||
{ value: "jsonl", label: "JSON Lines" },
|
||||
{ value: "json", label: "JSON" },
|
||||
];
|
||||
|
||||
const EXTENSIONS: Record<string, string> = {
|
||||
csv: "csv",
|
||||
jsonl: "jsonl",
|
||||
json: "json",
|
||||
};
|
||||
|
||||
export function StreamExportDialog({ connectionId, sql, onClose }: StreamExportDialogProps) {
|
||||
const [format, setFormat] = useState("csv");
|
||||
const [path, setPath] = useState("");
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [done, setDone] = useState(false);
|
||||
const [rowsWritten, setRowsWritten] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const jobIdRef = useRef<string | null>(null);
|
||||
|
||||
// Filter export-progress events to this job so concurrent exports don't
|
||||
// clobber each other's progress bar.
|
||||
useEffect(() => {
|
||||
const unlisten = listen<{ job_id: string; percent: number }>("export-progress", (e) => {
|
||||
if (e.payload.job_id === jobIdRef.current) {
|
||||
setProgress(e.payload.percent);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChooseFile = async () => {
|
||||
const ext = EXTENSIONS[format] ?? "csv";
|
||||
const picked = await save({
|
||||
defaultPath: `export.${ext}`,
|
||||
filters: [{ name: format.toUpperCase(), extensions: [ext] }],
|
||||
});
|
||||
if (picked) setPath(picked);
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
setDone(false);
|
||||
setProgress(0);
|
||||
try {
|
||||
let target = path;
|
||||
if (!target) {
|
||||
const ext = EXTENSIONS[format] ?? "csv";
|
||||
target = (await save({
|
||||
defaultPath: `export.${ext}`,
|
||||
filters: [{ name: format.toUpperCase(), extensions: [ext] }],
|
||||
})) ?? "";
|
||||
if (!target) {
|
||||
setRunning(false);
|
||||
return;
|
||||
}
|
||||
setPath(target);
|
||||
}
|
||||
jobIdRef.current = `export-${Date.now()}`;
|
||||
const res = await exportQueryToFile(connectionId, sql, format, target);
|
||||
setDone(true);
|
||||
setProgress(100);
|
||||
setRowsWritten(res.rows_written);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
try {
|
||||
await cancelQuery(connectionId);
|
||||
} catch {
|
||||
// ignore — the export may already have finished
|
||||
}
|
||||
setRunning(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatedModal open onClose={onClose}>
|
||||
<div className="w-[420px] flex flex-col gap-4">
|
||||
<h2 className="text-base font-semibold text-text">Export all rows to file</h2>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Select
|
||||
label="Format"
|
||||
value={format}
|
||||
onChange={setFormat}
|
||||
options={FORMAT_OPTIONS}
|
||||
disabled={running}
|
||||
/>
|
||||
<Button variant="secondary" onClick={handleChooseFile} disabled={running}>
|
||||
Choose file…
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{path && (
|
||||
<p className="text-xs text-text-muted break-all" data-testid="export-path">
|
||||
{path}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{running && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="h-1.5 w-full rounded-full bg-surface-raised overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent transition-all"
|
||||
style={{ width: `${Math.max(progress, 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-text-muted">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{done && rowsWritten !== null && (
|
||||
<p className="text-xs text-emerald-400" data-testid="export-done">
|
||||
{rowsWritten} rows written
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-400" data-testid="export-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{running && (
|
||||
<Button variant="secondary" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleExport} disabled={running}>
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
);
|
||||
}
|
||||
@@ -467,6 +467,8 @@ interface TableControlsProps {
|
||||
isMatview?: boolean;
|
||||
/** "table" = full table toolbar; "query" = export/refresh/columns + timing */
|
||||
variant?: "table" | "query";
|
||||
/** Open the full-result streamed export dialog. */
|
||||
onExportToFile?: () => void;
|
||||
}
|
||||
|
||||
export function TableControls({
|
||||
@@ -488,6 +490,7 @@ export function TableControls({
|
||||
defaultRefreshRate = 0,
|
||||
isMatview = false,
|
||||
variant = "table",
|
||||
onExportToFile,
|
||||
}: TableControlsProps) {
|
||||
const isQuery = variant === "query";
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
@@ -636,6 +639,21 @@ export function TableControls({
|
||||
{fmt.label}
|
||||
</button>
|
||||
))}
|
||||
{onExportToFile && (
|
||||
<>
|
||||
<div className="my-1 h-px bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExportOpen(false);
|
||||
onExportToFile();
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
Export all rows to file…
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,13 +3,15 @@ import { SelectDropdown } from "../ui/SelectDropdown";
|
||||
import { BackupPage } from "./BackupPage";
|
||||
import { RestorePage } from "./RestorePage";
|
||||
import { SyncPage } from "./SyncPage";
|
||||
import { SchemaDiffPage } from "./SchemaDiffPage";
|
||||
|
||||
type ToolOperation = "backup" | "restore" | "sync";
|
||||
type ToolOperation = "backup" | "restore" | "sync" | "diff";
|
||||
|
||||
const OPERATION_OPTIONS = [
|
||||
{ value: "backup", label: "Backup" },
|
||||
{ value: "restore", label: "Restore" },
|
||||
{ value: "sync", label: "DB Sync" },
|
||||
{ value: "diff", label: "Schema Diff" },
|
||||
];
|
||||
|
||||
export function ToolsPage({ connectionId }: { connectionId: string }) {
|
||||
@@ -33,6 +35,7 @@ export function ToolsPage({ connectionId }: { connectionId: string }) {
|
||||
{operation === "backup" && <BackupPage connectionId={connectionId} />}
|
||||
{operation === "restore" && <RestorePage connectionId={connectionId} />}
|
||||
{operation === "sync" && <SyncPage />}
|
||||
{operation === "diff" && <SchemaDiffPage connectionId={connectionId} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ListChecks,
|
||||
ListOrdered,
|
||||
Puzzle,
|
||||
Layers,
|
||||
ShieldCheck,
|
||||
SquareFunction,
|
||||
Tag,
|
||||
@@ -29,6 +30,7 @@ export const TYPE_LABELS: Record<ObjectType, string> = {
|
||||
sequences: "Sequences",
|
||||
enums: "Enums",
|
||||
extensions: "Extensions",
|
||||
hypertables: "Hypertables",
|
||||
indexes: "Indexes",
|
||||
constraints: "Constraints",
|
||||
procedures: "Procedures",
|
||||
@@ -41,6 +43,7 @@ export const SINGULAR_LABELS: Record<ObjectType, string> = {
|
||||
sequences: "sequence",
|
||||
enums: "enum",
|
||||
extensions: "extension",
|
||||
hypertables: "hypertable",
|
||||
indexes: "index",
|
||||
constraints: "constraint",
|
||||
procedures: "procedure",
|
||||
@@ -55,6 +58,7 @@ export const OBJECT_ICONS: Record<ObjectType, React.ReactNode> = {
|
||||
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
|
||||
enums: <Tag size={14} className="text-text-muted shrink-0" />,
|
||||
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
|
||||
hypertables: <Layers size={14} className="text-text-muted shrink-0" />,
|
||||
indexes: <BookMarked size={14} className="text-text-muted shrink-0" />,
|
||||
constraints: <ListChecks size={14} className="text-text-muted shrink-0" />,
|
||||
procedures: <SquareFunction size={14} className="text-text-muted shrink-0" />,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { DbType } from "../../lib/types";
|
||||
const DB_TYPE_LABELS: Record<DbType, string> = {
|
||||
postgresql: "PostgreSQL",
|
||||
mysql: "MySQL",
|
||||
mariadb: "MariaDB",
|
||||
sqlite: "SQLite",
|
||||
redis: "Redis",
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { DbType } from "../../lib/types";
|
||||
const DB_TYPES: { value: DbType; label: string }[] = [
|
||||
{ value: "postgresql", label: "PostgreSQL" },
|
||||
{ value: "mysql", label: "MySQL" },
|
||||
{ value: "mariadb", label: "MariaDB" },
|
||||
{ value: "sqlite", label: "SQLite" },
|
||||
{ value: "redis", label: "Redis" },
|
||||
];
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import tauriConf from "../../src-tauri/tauri.conf.json";
|
||||
|
||||
describe("tauri bundle config (v0.7.15)", () => {
|
||||
describe("tauri bundle config (v0.8.0)", () => {
|
||||
it("declares bundled pg_tools resources", () => {
|
||||
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
|
||||
});
|
||||
it("version is 0.7.15", () => {
|
||||
expect(tauriConf.version).toBe("0.7.15");
|
||||
it("version is 0.8.0", () => {
|
||||
expect(tauriConf.version).toBe("0.8.0");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo } from "./types";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo, HypertableListResponse, MultiQueryResult } from "./types";
|
||||
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
|
||||
import type { ChangePayload } from "./changePayload";
|
||||
import type { DiffReport } from "./types";
|
||||
import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud";
|
||||
|
||||
export { type ObjectKind, type DdlParams };
|
||||
@@ -226,6 +227,13 @@ export async function getExtensions(connectionId: string): Promise<ExtensionInfo
|
||||
return invoke<ExtensionInfo[]>("get_extensions", { connectionId });
|
||||
}
|
||||
|
||||
export async function getHypertables(
|
||||
connectionId: string,
|
||||
schema?: string,
|
||||
): Promise<HypertableListResponse> {
|
||||
return invoke<HypertableListResponse>("get_hypertables", { connectionId, schema });
|
||||
}
|
||||
|
||||
export async function getSchemaGraph(
|
||||
connectionId: string,
|
||||
schema?: string,
|
||||
@@ -279,6 +287,24 @@ export async function executeQuery(
|
||||
return invoke<QueryResult>("execute_query", { connectionId, query, page, pageSize });
|
||||
}
|
||||
|
||||
export async function executeQueryMulti(
|
||||
connectionId: string,
|
||||
query: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): Promise<MultiQueryResult> {
|
||||
return invoke<MultiQueryResult>("execute_query_multi", { connectionId, query, page, pageSize });
|
||||
}
|
||||
|
||||
export async function exportQueryToFile(
|
||||
connectionId: string,
|
||||
sql: string,
|
||||
format: string,
|
||||
path: string,
|
||||
): Promise<{ rows_written: number; path: string }> {
|
||||
return invoke("export_query_to_file", { connectionId, sql, format, path });
|
||||
}
|
||||
|
||||
export async function getQueryHistory(
|
||||
connectionId: string | null,
|
||||
limit: number,
|
||||
@@ -393,4 +419,14 @@ export async function getTablespaces(connectionId: string): Promise<TablespaceIn
|
||||
}
|
||||
export async function buildRebuildScript<C extends { name: string; type: string; nullable: boolean; default: string | null; is_pk: boolean }[]>(connectionId: string, schema: string, table: string, newColumns: C): Promise<string> {
|
||||
return invoke<string>("build_rebuild_script", { connectionId, schema, table, newColumns });
|
||||
}
|
||||
}
|
||||
export async function compareSchemas(
|
||||
sourceConnectionId: string,
|
||||
sourceSchema: string,
|
||||
targetConnectionId: string,
|
||||
targetSchema: string,
|
||||
): Promise<DiffReport> {
|
||||
return invoke<DiffReport>("compare_schemas", {
|
||||
sourceConnectionId, sourceSchema, targetConnectionId, targetSchema,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -125,6 +125,30 @@ describe("parseConnectionString", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses mariadb:// with the 3306 default port", () => {
|
||||
const result = parseConnectionString("mariadb://root:pw@localhost/app");
|
||||
expect(result).toEqual({
|
||||
db_type: "mariadb",
|
||||
host: "localhost",
|
||||
port: 3306,
|
||||
username: "root",
|
||||
password: "pw",
|
||||
database: "app",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps mysql:// mapped to the mysql db_type", () => {
|
||||
const result = parseConnectionString("mysql://root:pw@localhost:3307/app");
|
||||
expect(result).toEqual({
|
||||
db_type: "mysql",
|
||||
host: "localhost",
|
||||
port: 3307,
|
||||
username: "root",
|
||||
password: "pw",
|
||||
database: "app",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults Redis port to 6379 when omitted", () => {
|
||||
const result = parseConnectionString("redis://localhost");
|
||||
expect(result).toEqual({
|
||||
@@ -170,6 +194,19 @@ describe("detectProviderFromHost", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectProviderFromHost", () => {
|
||||
it("detects PlanetScale access hosts", () => {
|
||||
expect(detectProviderFromHost("xxxx.us-east-2.psdb.cloud")).toBe("planetscale");
|
||||
expect(detectProviderFromHost("aws.connect.psdb.cloud")).toBe("planetscale");
|
||||
});
|
||||
|
||||
it("still detects supabase and neon", () => {
|
||||
expect(detectProviderFromHost("db.abc.supabase.co")).toBe("supabase");
|
||||
expect(detectProviderFromHost("ep-1-pooler.eu.aws.neon.tech")).toBe("neon");
|
||||
expect(detectProviderFromHost("example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildConnectionUrl", () => {
|
||||
it("builds a PostgreSQL URL with password and sslmode", () => {
|
||||
const url = buildConnectionUrl(
|
||||
@@ -212,6 +249,22 @@ describe("buildConnectionUrl", () => {
|
||||
expect(url).toBe("mysql://root:pw@127.0.0.1:3306/app");
|
||||
});
|
||||
|
||||
it("emits mariadb:// for a mariadb connection", () => {
|
||||
const url = buildConnectionUrl(
|
||||
makeConn({ db_type: "mariadb", host: "127.0.0.1", port: 3306, username: "root", database: "app" }),
|
||||
"pw",
|
||||
);
|
||||
expect(url).toBe("mariadb://root:pw@127.0.0.1:3306/app");
|
||||
});
|
||||
|
||||
it("omits the password in the mariadb no-password variant", () => {
|
||||
const url = buildConnectionUrl(
|
||||
makeConn({ db_type: "mariadb", host: "127.0.0.1", port: 3306, username: "root", database: "app" }),
|
||||
null,
|
||||
);
|
||||
expect(url).toBe("mariadb://root@127.0.0.1:3306/app");
|
||||
});
|
||||
|
||||
it("builds a Redis URL with password but no username", () => {
|
||||
const url = buildConnectionUrl(
|
||||
makeConn({ db_type: "redis", host: "localhost", port: 6379, username: null, database: "0" }),
|
||||
|
||||
@@ -13,6 +13,7 @@ const DB_PROTOCOLS: Record<string, DbType> = {
|
||||
"postgresql:": "postgresql",
|
||||
"postgres:": "postgresql",
|
||||
"mysql:": "mysql",
|
||||
"mariadb:": "mariadb",
|
||||
"sqlite:": "sqlite",
|
||||
"file:": "sqlite",
|
||||
"redis:": "redis",
|
||||
@@ -22,6 +23,7 @@ const DB_PROTOCOLS: Record<string, DbType> = {
|
||||
const DEFAULT_PORTS: Record<DbType, number | null> = {
|
||||
postgresql: 5432,
|
||||
mysql: 3306,
|
||||
mariadb: 3306,
|
||||
sqlite: null,
|
||||
redis: 6379,
|
||||
};
|
||||
@@ -83,10 +85,13 @@ export function looksLikeConnectionString(input: string): boolean {
|
||||
/** Detect a managed-PostgreSQL provider from a connection host, purely for
|
||||
* UI highlighting. Returns `"supabase"` / `"neon"` / `null`. db_type is
|
||||
* unaffected (both presets persist as `postgresql`). */
|
||||
export function detectProviderFromHost(host: string): "supabase" | "neon" | null {
|
||||
export function detectProviderFromHost(
|
||||
host: string,
|
||||
): "supabase" | "neon" | "planetscale" | null {
|
||||
const h = host.toLowerCase();
|
||||
if (h.endsWith(".supabase.co")) return "supabase";
|
||||
if (h.endsWith(".neon.tech")) return "neon";
|
||||
if (h.endsWith(".psdb.cloud")) return "planetscale";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -127,6 +132,8 @@ export function buildConnectionUrl(conn: Connection, password: string | null): s
|
||||
}
|
||||
case "mysql":
|
||||
return `mysql://${auth}${conn.host}${port}${db}`;
|
||||
case "mariadb":
|
||||
return `mariadb://${auth}${conn.host}${port}${db}`;
|
||||
case "sqlite":
|
||||
// host stores the file path; `sqlite:///abs/path` round-trips through
|
||||
// parseConnectionString (pathname → host).
|
||||
|
||||
@@ -23,6 +23,16 @@ describe("dbCapabilities", () => {
|
||||
expect(c.visualizer).toBe(false);
|
||||
});
|
||||
|
||||
it("gives MariaDB the same capability set as MySQL", () => {
|
||||
expect(DB_CAPABILITIES.mariadb).toEqual(DB_CAPABILITIES.mysql);
|
||||
});
|
||||
|
||||
it("covers exactly the five DbTypes", () => {
|
||||
expect(Object.keys(DB_CAPABILITIES).sort()).toEqual(
|
||||
["mariadb", "mysql", "postgresql", "redis", "sqlite"],
|
||||
);
|
||||
});
|
||||
|
||||
it("gives SQLite explorer/queries/visualizer/editing/import/ddl/tools/tableManagement but not objects", () => {
|
||||
const c = DB_CAPABILITIES.sqlite;
|
||||
expect(c.explorer).toBe(true);
|
||||
|
||||
@@ -36,6 +36,7 @@ const ALL_FALSE: DbCapabilities = {
|
||||
export const DB_CAPABILITIES: Record<DbType, DbCapabilities> = {
|
||||
postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true, objectCrud: true, maintenance: true, roles: true, tableManagement: true },
|
||||
mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true, tools: true },
|
||||
mariadb: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true, tools: true },
|
||||
sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true, tools: true, tableManagement: true },
|
||||
redis: { ...ALL_FALSE },
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { siPostgresql, siMysql, siSqlite, siRedis, siSupabase, siNeon } from "simple-icons";
|
||||
import { siPostgresql, siMysql, siMariadb, siSqlite, siRedis, siSupabase, siNeon, siPlanetscale } from "simple-icons";
|
||||
import type { DbType } from "./types";
|
||||
|
||||
// Brand colors from simple-icons
|
||||
const DB_COLORS: Record<DbType, string> = {
|
||||
postgresql: `#${siPostgresql.hex}`,
|
||||
mysql: `#${siMysql.hex}`,
|
||||
mariadb: `#${siMariadb.hex}`,
|
||||
redis: `#${siRedis.hex}`,
|
||||
sqlite: `#${siSqlite.hex}`,
|
||||
};
|
||||
@@ -13,6 +14,7 @@ const DB_COLORS: Record<DbType, string> = {
|
||||
const DB_PATHS: Record<DbType, string> = {
|
||||
postgresql: siPostgresql.path,
|
||||
mysql: siMysql.path,
|
||||
mariadb: siMariadb.path,
|
||||
redis: siRedis.path,
|
||||
sqlite: siSqlite.path,
|
||||
};
|
||||
@@ -20,6 +22,7 @@ const DB_PATHS: Record<DbType, string> = {
|
||||
export const DB_LABELS: Record<DbType, string> = {
|
||||
postgresql: "PostgreSQL",
|
||||
mysql: "MySQL",
|
||||
mariadb: "MariaDB",
|
||||
redis: "Redis",
|
||||
sqlite: "SQLite",
|
||||
};
|
||||
@@ -54,22 +57,25 @@ export function DbIcon({ type, size = 20, className }: DbIconProps) {
|
||||
export const DB_ICONS: Record<DbType, string> = {
|
||||
postgresql: "🐘",
|
||||
mysql: "🐬",
|
||||
mariadb: "🦭",
|
||||
redis: "⚡",
|
||||
sqlite: "🗄️",
|
||||
};
|
||||
|
||||
// ── Managed-PostgreSQL provider icons (Supabase, NeonDB) ──────────
|
||||
// These are NOT DbType values; connections persist as db_type="postgresql".
|
||||
type ProviderIconId = "supabase" | "neon";
|
||||
type ProviderIconId = "supabase" | "neon" | "planetscale";
|
||||
|
||||
const PROVIDER_ICON_DATA: Record<ProviderIconId, { hex: string; path: string }> = {
|
||||
supabase: { hex: `#${siSupabase.hex}`, path: siSupabase.path },
|
||||
neon: { hex: `#${siNeon.hex}`, path: siNeon.path },
|
||||
planetscale: { hex: `#${siPlanetscale.hex}`, path: siPlanetscale.path },
|
||||
};
|
||||
|
||||
export const PROVIDER_LABELS: Record<ProviderIconId, string> = {
|
||||
supabase: "Supabase",
|
||||
neon: "NeonDB",
|
||||
planetscale: "PlanetScale",
|
||||
};
|
||||
|
||||
interface ProviderIconProps {
|
||||
|
||||
@@ -5,7 +5,7 @@ import agents from "../../../AGENTS.md?raw";
|
||||
import readme from "../../../README.md?raw";
|
||||
import landing from "../../../www/public/landing.js?raw";
|
||||
|
||||
describe("v0.7.15 docs coverage", () => {
|
||||
describe("v0.8.0 docs coverage", () => {
|
||||
it("AGENTS.md marks inline cell editing complete", () => {
|
||||
expect(agents).toContain("Inline cell editing");
|
||||
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
||||
@@ -25,8 +25,8 @@ describe("v0.7.15 docs coverage", () => {
|
||||
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
||||
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
||||
});
|
||||
it("README declares v0.7.15", () => {
|
||||
expect(readme).toContain("0.7.15");
|
||||
it("README declares v0.8.0", () => {
|
||||
expect(readme).toContain("0.8.0");
|
||||
});
|
||||
it("AGENTS.md marks schema CRUD complete", () => {
|
||||
expect(agents).toMatch(/Schema CRUD \| ✅/);
|
||||
@@ -65,12 +65,12 @@ describe("v0.7.15 docs coverage", () => {
|
||||
expect(agents).toMatch(/Cancel long-running queries \| ✅/);
|
||||
expect(agents).toMatch(/Settings export\/import \| ✅/);
|
||||
});
|
||||
it("README links to v0.7.15 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.7.15/");
|
||||
expect(readme).toContain("Gridline_0.7.15_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.7.15-1.x86_64.rpm");
|
||||
it("README links to v0.8.0 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.8.0/");
|
||||
expect(readme).toContain("Gridline_0.8.0_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.8.0-1.x86_64.rpm");
|
||||
});
|
||||
it("landing.js platform download links point to v0.7.15", () => {
|
||||
expect(landing).toContain('const version = "0.7.15"');
|
||||
it("landing.js platform download links point to v0.8.0", () => {
|
||||
expect(landing).toContain('const version = "0.8.0"');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { exportData } from "./exportData";
|
||||
import { exportData, csvCell, qualifiedTableSql } from "./exportData";
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
const columns: ColumnInfo[] = [
|
||||
@@ -48,4 +48,26 @@ describe("exportData", () => {
|
||||
globalThis.URL.revokeObjectURL = origRevokeObjectURL;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("csvCell (client CSV parity with streamed export)", () => {
|
||||
it("quotes and guards like the Rust writer", () => {
|
||||
expect(csvCell("plain")).toBe("plain");
|
||||
expect(csvCell("a,b")).toBe('"a,b"');
|
||||
expect(csvCell('a"b')).toBe('"a""b"');
|
||||
expect(csvCell("=SUM(A1)")).toBe("'=SUM(A1)");
|
||||
expect(csvCell("+x")).toBe("'+x");
|
||||
expect(csvCell("-1")).toBe("'-1");
|
||||
expect(csvCell("@cmd")).toBe("'@cmd");
|
||||
expect(csvCell(null)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("qualifiedTableSql", () => {
|
||||
it("double-quotes for PG/SQLite and backticks for MySQL/MariaDB", () => {
|
||||
expect(qualifiedTableSql("postgresql", "public", "users")).toBe('SELECT * FROM "public"."users"');
|
||||
expect(qualifiedTableSql("sqlite", "main", "users")).toBe('SELECT * FROM "main"."users"');
|
||||
expect(qualifiedTableSql("mysql", "app", "users")).toBe("SELECT * FROM `app`.`users`");
|
||||
expect(qualifiedTableSql("mariadb", "app", "users")).toBe("SELECT * FROM `app`.`users`");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,27 @@
|
||||
import { buildXlsx } from "./xlsx";
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
/** Quote one CSV cell with formula-injection protection — mirrors the Rust
|
||||
* `csv_cell` from the streaming export (Task 10) byte-for-byte. */
|
||||
export function csvCell(value: unknown): string {
|
||||
const s = value === null || value === undefined ? "" : String(value);
|
||||
const guarded = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
|
||||
if (/[,"\n\r]/.test(guarded)) {
|
||||
return `"${guarded.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return guarded;
|
||||
}
|
||||
|
||||
/** SQL selecting every row of a table, dialect-quoted. */
|
||||
export function qualifiedTableSql(dbType: string, schema: string, table: string): string {
|
||||
if (dbType === "mysql" || dbType === "mariadb") {
|
||||
const q = (s: string) => s.replace(/`/g, "``");
|
||||
return `SELECT * FROM \`${q(schema)}\`.\`${q(table)}\``;
|
||||
}
|
||||
const q = (s: string) => s.replace(/"/g, '""');
|
||||
return `SELECT * FROM "${q(schema)}"."${q(table)}"`;
|
||||
}
|
||||
|
||||
export function exportData(
|
||||
rows: unknown[][],
|
||||
columns: ColumnInfo[],
|
||||
@@ -34,14 +55,9 @@ export function exportData(
|
||||
break;
|
||||
}
|
||||
case "csv": {
|
||||
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
|
||||
const csvRows = [headers.map((h) => csvCell(h)).join(",")];
|
||||
for (const row of rows) {
|
||||
csvRows.push(
|
||||
row.map((cell) => {
|
||||
const s = cell === null || cell === undefined ? "" : String(cell);
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}).join(","),
|
||||
);
|
||||
csvRows.push(row.map((cell) => csvCell(cell)).join(","));
|
||||
}
|
||||
content = csvRows.join("\n");
|
||||
mime = "text/csv";
|
||||
|
||||
@@ -2,12 +2,19 @@ import { describe, it, expect } from "vitest";
|
||||
import { PROVIDER_TABS, getProviderById, SETUP_GUIDES } from "./providers";
|
||||
|
||||
describe("providers", () => {
|
||||
it("defines exactly 6 provider tabs in order", () => {
|
||||
it("defines exactly 8 provider tabs in order", () => {
|
||||
expect(PROVIDER_TABS.map((p) => p.id)).toEqual([
|
||||
"postgresql", "mysql", "sqlite", "redis", "supabase", "neon",
|
||||
"postgresql", "mysql", "mariadb", "sqlite", "redis", "supabase", "neon", "planetscale",
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes a plain (non-managed) MariaDB tab persisting db_type mariadb", () => {
|
||||
const tab = getProviderById("mariadb")!;
|
||||
expect(tab.dbType).toBe("mariadb");
|
||||
expect(tab.isManagedPreset).toBe(false);
|
||||
expect(tab.label).toBe("MariaDB");
|
||||
});
|
||||
|
||||
it("marks only supabase and neon as managed presets mapping to postgresql", () => {
|
||||
const pg = getProviderById("postgresql")!;
|
||||
expect(pg.dbType).toBe("postgresql");
|
||||
@@ -33,4 +40,20 @@ describe("providers", () => {
|
||||
expect(SETUP_GUIDES.supabase.sslRequired).toBe(true);
|
||||
expect(SETUP_GUIDES.neon.sslRequired).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlanetScale preset", () => {
|
||||
it("registers as a managed MySQL preset persisting db_type mysql", () => {
|
||||
const tab = PROVIDER_TABS.find((p) => p.id === "planetscale");
|
||||
expect(tab).toBeDefined();
|
||||
expect(tab!.dbType).toBe("mysql");
|
||||
expect(tab!.isManagedPreset).toBe(true);
|
||||
});
|
||||
|
||||
it("ships a setup guide requiring SSL with concrete steps", () => {
|
||||
const guide = SETUP_GUIDES.planetscale;
|
||||
expect(guide.sslRequired).toBe(true);
|
||||
expect(guide.steps.length).toBeGreaterThanOrEqual(4);
|
||||
expect(guide.steps.some((s) => s.detail.includes("psdb.cloud"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DbType } from "./types";
|
||||
|
||||
export type ProviderId = "postgresql" | "mysql" | "sqlite" | "redis" | "supabase" | "neon";
|
||||
export type ProviderId = "postgresql" | "mysql" | "mariadb" | "sqlite" | "redis" | "supabase" | "neon" | "planetscale";
|
||||
|
||||
export interface ProviderTab {
|
||||
id: ProviderId;
|
||||
@@ -15,10 +15,12 @@ export interface ProviderTab {
|
||||
export const PROVIDER_TABS: ProviderTab[] = [
|
||||
{ id: "postgresql", label: "PostgreSQL", dbType: "postgresql", isManagedPreset: false },
|
||||
{ id: "mysql", label: "MySQL", dbType: "mysql", isManagedPreset: false },
|
||||
{ id: "mariadb", label: "MariaDB", dbType: "mariadb", isManagedPreset: false },
|
||||
{ id: "sqlite", label: "SQLite", dbType: "sqlite", isManagedPreset: false },
|
||||
{ id: "redis", label: "Redis", dbType: "redis", isManagedPreset: false },
|
||||
{ id: "supabase", label: "Supabase", dbType: "postgresql", isManagedPreset: true },
|
||||
{ id: "neon", label: "NeonDB", dbType: "postgresql", isManagedPreset: true },
|
||||
{ id: "planetscale", label: "PlanetScale", dbType: "mysql", isManagedPreset: true },
|
||||
];
|
||||
|
||||
export function getProviderById(id: string): ProviderTab | undefined {
|
||||
@@ -39,7 +41,7 @@ export interface SetupGuide {
|
||||
|
||||
/** Static, researched setup instructions. No network calls at runtime.
|
||||
* Content reviewed against the official docs (fetched 2026-08-04, see spec §9). */
|
||||
export const SETUP_GUIDES: Record<"supabase" | "neon", SetupGuide> = {
|
||||
export const SETUP_GUIDES: Record<"supabase" | "neon" | "planetscale", SetupGuide> = {
|
||||
supabase: {
|
||||
blurb: "Managed PostgreSQL with a pooled connection string.",
|
||||
sslRequired: true,
|
||||
@@ -65,6 +67,17 @@ export const SETUP_GUIDES: Record<"supabase" | "neon", SetupGuide> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
planetscale: {
|
||||
blurb: "Managed MySQL (Vitess) with database branching.",
|
||||
sslRequired: true,
|
||||
steps: [
|
||||
{ title: "Create a branch password", detail: "In the PlanetScale dashboard open your database → Connect → pick a branch → Create password. The password (pscale_pw_…) is shown ONCE and works only for that branch." },
|
||||
{ title: "Copy the credentials", detail: "Note the Access Host URL (e.g. aws.connect.psdb.cloud or xxxx.us-east-2.psdb.cloud), the username, the password, and your database name." },
|
||||
{ title: "Fill Host / Port / Database", detail: "Host is the access host, port is 3306, Database is your database name. Save stores the password in your OS keychain." },
|
||||
{ title: "SSL is mandatory", detail: "Gridline pre-selects Verify Full — PlanetScale requires TLS with identity verification." },
|
||||
{ title: "Vitess caveats", detail: "Passwords are branch-scoped; deleting one disconnects its clients within 5 minutes. With Safe Migrations ON, schema changes go through deploy requests, so the changes queue may be blocked on production branches. Foreign keys follow your database's foreign_key_constraint setting." },
|
||||
],
|
||||
},
|
||||
neon: {
|
||||
blurb: "Serverless Postgres with pooled or direct endpoints.",
|
||||
sslRequired: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type DbType = "postgresql" | "mysql" | "sqlite" | "redis";
|
||||
export type DbType = "postgresql" | "mysql" | "mariadb" | "sqlite" | "redis";
|
||||
|
||||
export type Theme = "dark" | "light" | "system";
|
||||
export type FontSize = "small" | "medium" | "large";
|
||||
@@ -189,6 +189,19 @@ export interface QueryResult {
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface StatementNotice {
|
||||
statement_index: number;
|
||||
kind: "dml" | "ddl" | "error" | string;
|
||||
text: string;
|
||||
affected: number | null;
|
||||
}
|
||||
|
||||
export interface MultiQueryResult {
|
||||
result_sets: QueryResult[];
|
||||
notices: StatementNotice[];
|
||||
execution_time_ms: number;
|
||||
}
|
||||
|
||||
export type ChangeStatus = "pending" | "applied" | "error";
|
||||
|
||||
export type ChangeItemType =
|
||||
@@ -367,6 +380,7 @@ export type ObjectType =
|
||||
| "sequences"
|
||||
| "enums"
|
||||
| "extensions"
|
||||
| "hypertables"
|
||||
| "indexes"
|
||||
| "constraints"
|
||||
| "procedures"
|
||||
@@ -378,6 +392,21 @@ export interface ObjectSearchHit {
|
||||
object_type: string; // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT
|
||||
}
|
||||
|
||||
export interface HypertableInfo {
|
||||
name: string;
|
||||
schema: string;
|
||||
num_dimensions: number;
|
||||
compression_enabled: boolean;
|
||||
num_chunks: number;
|
||||
total_size_bytes: number | null;
|
||||
}
|
||||
|
||||
export interface HypertableListResponse {
|
||||
available: boolean;
|
||||
reason: string | null;
|
||||
items: HypertableInfo[];
|
||||
}
|
||||
|
||||
export interface DependencyInfo {
|
||||
deptype: string;
|
||||
class: string;
|
||||
@@ -477,4 +506,25 @@ export interface Relationship {
|
||||
export interface SchemaGraph {
|
||||
tables: TableNode[];
|
||||
relationships: Relationship[];
|
||||
}
|
||||
}
|
||||
export interface DiffDetailLine {
|
||||
label: string;
|
||||
old: string;
|
||||
new: string;
|
||||
}
|
||||
|
||||
export interface DiffItem {
|
||||
object_type: string;
|
||||
name: string;
|
||||
kind: "added" | "removed" | "changed";
|
||||
detail: DiffDetailLine[];
|
||||
sync_sql: string[];
|
||||
destructive: boolean;
|
||||
}
|
||||
|
||||
export interface DiffReport {
|
||||
items: DiffItem[];
|
||||
source_label: string;
|
||||
target_label: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function abbreviateType(dataType: string): string {
|
||||
return TYPE_ABBREV[lower] ?? dataType;
|
||||
}
|
||||
|
||||
const VALID_DB_TYPES: DbType[] = ["postgresql", "mysql", "sqlite", "redis"];
|
||||
const VALID_DB_TYPES: DbType[] = ["postgresql", "mysql", "mariadb", "sqlite", "redis"];
|
||||
|
||||
export function validateConnectionInput(input: ConnectionInput): ValidationResult {
|
||||
if (!input.name || input.name.length === 0) return { ok: false, error: "name is required" };
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
describe("version", () => {
|
||||
it("declares v0.7.15 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.15");
|
||||
it("declares v0.8.0 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.8.0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { useDbViewerStore } from "./dbViewerStore";
|
||||
import type { DiffItem } from "../lib/types";
|
||||
|
||||
const item = (name: string, sql: string, destructive: boolean): DiffItem => ({
|
||||
object_type: "table",
|
||||
name,
|
||||
kind: "added",
|
||||
detail: [],
|
||||
sync_sql: [sql],
|
||||
destructive,
|
||||
});
|
||||
|
||||
describe("addDiffItems", () => {
|
||||
beforeEach(() => useDbViewerStore.getState().clearChanges());
|
||||
|
||||
it("stages one ddl change per non-destructive item, skipping destructive and empty", () => {
|
||||
const items = [
|
||||
item("users", "CREATE TABLE users (id int);", false),
|
||||
item("old", "DROP TABLE old;", true),
|
||||
{ ...item("weird", "", false), sync_sql: [] },
|
||||
];
|
||||
useDbViewerStore.getState().addDiffItems(items);
|
||||
const queue = useDbViewerStore.getState().changesQueue;
|
||||
expect(queue).toHaveLength(1);
|
||||
expect(queue[0].type).toBe("ddl");
|
||||
expect(queue[0].sql).toBe("CREATE TABLE users (id int);");
|
||||
expect(queue[0].description).toContain("users");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo } from "../lib/types";
|
||||
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, HypertableInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo, MultiQueryResult, DiffItem } from "../lib/types";
|
||||
import { getDatabases, getSchemas, getTables } from "../lib/commands";
|
||||
import type { ObjectKind, DdlParams } from "../lib/objectCrud";
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface ViewerTab {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
data: QueryResult | null;
|
||||
multi?: MultiQueryResult | null;
|
||||
columnFilter?: { column: string; value: string };
|
||||
filterRules: FilterRule[];
|
||||
sortRules: SortRule[];
|
||||
@@ -117,6 +118,8 @@ interface DbViewerState {
|
||||
sequences: SequenceInfo[] | null;
|
||||
enums: EnumInfo[] | null;
|
||||
extensions: ExtensionInfo[] | null;
|
||||
hypertables: HypertableInfo[] | null;
|
||||
hypertablesAvailable: boolean | null;
|
||||
indexes: IndexInfo[] | null;
|
||||
setSchemaTreeLoading: (loading: boolean) => void;
|
||||
constraints: ConstraintInfo[] | null;
|
||||
@@ -144,6 +147,7 @@ interface DbViewerState {
|
||||
setPage: (tabId: string, page: number) => void;
|
||||
setPageSize: (tabId: string, pageSize: number) => void;
|
||||
setTabData: (tabId: string, data: QueryResult) => void;
|
||||
setTabMulti: (tabId: string, multi: MultiQueryResult | null) => void;
|
||||
setTabLoading: (tabId: string, loading: boolean) => void;
|
||||
setTabError: (tabId: string, error: string) => void;
|
||||
setColumnFilter: (tabId: string, column: string, value: string) => void;
|
||||
@@ -165,6 +169,7 @@ interface DbViewerState {
|
||||
rows?: unknown[][];
|
||||
description?: string | null;
|
||||
}) => void;
|
||||
addDiffItems: (items: DiffItem[]) => void;
|
||||
cancelChange: (changeId: string) => void;
|
||||
removeChange: (changeId: string) => void;
|
||||
updateChange: (changeId: string, patch: Partial<QueueItem>) => void;
|
||||
@@ -182,6 +187,8 @@ interface DbViewerState {
|
||||
setSequences: (sequences: SequenceInfo[]) => void;
|
||||
setEnums: (enums: EnumInfo[]) => void;
|
||||
setExtensions: (extensions: ExtensionInfo[]) => void;
|
||||
setHypertables: (hypertables: HypertableInfo[]) => void;
|
||||
setHypertablesAvailable: (v: boolean | null) => void;
|
||||
setIndexes: (indexes: IndexInfo[]) => void;
|
||||
setConstraints: (constraints: ConstraintInfo[]) => void;
|
||||
setRoles: (roles: RoleInfo[]) => void;
|
||||
@@ -224,6 +231,8 @@ const initialState = {
|
||||
sequences: null as SequenceInfo[] | null,
|
||||
enums: null as EnumInfo[] | null,
|
||||
extensions: null as ExtensionInfo[] | null,
|
||||
hypertables: null as HypertableInfo[] | null,
|
||||
hypertablesAvailable: null as boolean | null,
|
||||
indexes: null as IndexInfo[] | null,
|
||||
constraints: null as ConstraintInfo[] | null,
|
||||
roles: [] as RoleInfo[],
|
||||
@@ -443,10 +452,15 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
||||
setTabData: (tabId, data) =>
|
||||
set((state) => ({
|
||||
tabs: state.tabs.map((t) =>
|
||||
t.id === tabId ? { ...t, data, loading: false, error: null } : t,
|
||||
t.id === tabId ? { ...t, data, multi: null, loading: false, error: null } : t,
|
||||
),
|
||||
})),
|
||||
|
||||
setTabMulti: (tabId, multi) =>
|
||||
set((s) => ({
|
||||
tabs: s.tabs.map((t) => (t.id === tabId ? { ...t, multi } : t)),
|
||||
})),
|
||||
|
||||
setTabLoading: (tabId, loading) =>
|
||||
set((state) => ({
|
||||
tabs: state.tabs.map((t) =>
|
||||
@@ -536,6 +550,19 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
||||
set((state) => ({ changesQueue: [...state.changesQueue, item] }));
|
||||
},
|
||||
|
||||
addDiffItems: (items) => {
|
||||
for (const it of items) {
|
||||
if (it.destructive || it.sync_sql.length === 0) continue;
|
||||
for (const sql of it.sync_sql) {
|
||||
get().addChange({
|
||||
type: "ddl",
|
||||
sql,
|
||||
description: `Schema diff: ${it.object_type} ${it.name} (${it.kind})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
cancelChange: (changeId) =>
|
||||
set((state) => ({
|
||||
changesQueue: state.changesQueue.map((c) =>
|
||||
@@ -586,6 +613,8 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
||||
setSequences: (sequences) => set({ sequences }),
|
||||
setEnums: (enums) => set({ enums }),
|
||||
setExtensions: (extensions) => set({ extensions }),
|
||||
setHypertables: (hypertables) => set({ hypertables }),
|
||||
setHypertablesAvailable: (v) => set({ hypertablesAvailable: v }),
|
||||
setIndexes: (indexes) => set({ indexes }),
|
||||
setConstraints: (constraints) => set({ constraints }),
|
||||
setRoles: (roles) => set({ roles }),
|
||||
|
||||
Reference in New Issue
Block a user