0.8.0: MariaDB + PlanetScale, TimescaleDB, multi-result sets, streaming export, schema diff #27

Merged
adrianbonpin merged 19 commits from feat/0.8.0 into prod 2026-09-11 17:20:45 +00:00
7 changed files with 111 additions and 14 deletions
Showing only changes of commit 3e3881cfd5 - Show all commits
@@ -22,10 +22,6 @@ interface ObjectExplorerPageProps {
sidebarMode?: boolean; 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. */ /** Natural plural for empty-state copy, derived from SINGULAR_LABELS with known irregulars mapped explicitly. */
function emptyPlural(type: ObjectType): string { function emptyPlural(type: ObjectType): string {
const singular = SINGULAR_LABELS[type]; const singular = SINGULAR_LABELS[type];
@@ -75,6 +71,7 @@ function typeToDdlType(type: ObjectType): string {
sequences: "sequence", sequences: "sequence",
enums: "enum", enums: "enum",
extensions: "extension", extensions: "extension",
hypertables: "table",
indexes: "index", indexes: "index",
constraints: "constraint", constraints: "constraint",
roles: "role", roles: "role",
@@ -125,6 +122,19 @@ export function ObjectExplorerPage({
const changesQueue = useDbViewerStore((s) => s.changesQueue); const changesQueue = useDbViewerStore((s) => s.changesQueue);
const openObjectTab = useDbViewerStore((s) => s.openObjectTab); const openObjectTab = useDbViewerStore((s) => s.openObjectTab);
const refreshTree = useDbViewerStore((s) => s.refreshTree); 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 // Use store for persistence, but allow re-fetching when schema changes
const [items, setItems] = useState<AnyObject[] | null>(null); const [items, setItems] = useState<AnyObject[] | null>(null);
@@ -218,6 +228,23 @@ export function ObjectExplorerPage({
} }
}, [currentSchema, fetch]); }, [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 // After a commit containing ddl items succeeds, refetch the current
// object-type list + refresh the schema tree so newly created/dropped // object-type list + refresh the schema tree so newly created/dropped
// objects show up without a manual refresh. // objects show up without a manual refresh.
@@ -325,7 +352,7 @@ export function ObjectExplorerPage({
<SelectDropdown <SelectDropdown
value={type} value={type}
onChange={(v) => handleTypeChange(v as ObjectType)} onChange={(v) => handleTypeChange(v as ObjectType)}
options={OBJECT_TYPE_OPTIONS} options={objectTypeOptions}
variant="ghost" variant="ghost"
aria-label="Object type" 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,23 @@
import { useEffect, useState, useMemo } from "react"; 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 { useDbViewerStore } from "../../stores/dbViewerStore";
import * as cmd from "../../lib/commands"; 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";
type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions"; type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions" | "hypertables";
interface ObjectTreeProps { interface ObjectTreeProps {
type: ObjectType; type: ObjectType;
connectionId: string; connectionId: string;
} }
const TYPE_LABELS: Record<ObjectType, string> = { export const TYPE_LABELS: Record<ObjectType, string> = {
functions: "functions", functions: "functions",
triggers: "triggers", triggers: "triggers",
sequences: "sequences", sequences: "sequences",
enums: "enums", enums: "enums",
extensions: "extensions", extensions: "extensions",
hypertables: "hypertables",
}; };
const ICONS: Record<ObjectType, React.ReactNode> = { const ICONS: Record<ObjectType, React.ReactNode> = {
@@ -25,8 +26,18 @@ const ICONS: Record<ObjectType, React.ReactNode> = {
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />, sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
enums: <Tag 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" />, 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 }) { function SourceCode({ source }: { source: string }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const maxLen = 500; const maxLen = 500;
@@ -59,11 +70,13 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
const sequences = useDbViewerStore((s) => s.sequences); const sequences = useDbViewerStore((s) => s.sequences);
const enums = useDbViewerStore((s) => s.enums); const enums = useDbViewerStore((s) => s.enums);
const extensions = useDbViewerStore((s) => s.extensions); const extensions = useDbViewerStore((s) => s.extensions);
const hypertables = useDbViewerStore((s) => s.hypertables);
const setFunctions = useDbViewerStore((s) => s.setFunctions); const setFunctions = useDbViewerStore((s) => s.setFunctions);
const setTriggers = useDbViewerStore((s) => s.setTriggers); const setTriggers = useDbViewerStore((s) => s.setTriggers);
const setSequences = useDbViewerStore((s) => s.setSequences); const setSequences = useDbViewerStore((s) => s.setSequences);
const setEnums = useDbViewerStore((s) => s.setEnums); const setEnums = useDbViewerStore((s) => s.setEnums);
const setExtensions = useDbViewerStore((s) => s.setExtensions); const setExtensions = useDbViewerStore((s) => s.setExtensions);
const setHypertables = useDbViewerStore((s) => s.setHypertables);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set()); const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
@@ -77,8 +90,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
case "sequences": return sequences; case "sequences": return sequences;
case "enums": return enums; case "enums": return enums;
case "extensions": return extensions; case "extensions": return extensions;
case "hypertables": return hypertables;
} }
}, [type, functions, triggers, sequences, enums, extensions]); }, [type, functions, triggers, sequences, enums, extensions, hypertables]);
const setter = useMemo(() => { const setter = useMemo(() => {
switch (type) { switch (type) {
@@ -87,8 +101,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
case "sequences": return setSequences; case "sequences": return setSequences;
case "enums": return setEnums; case "enums": return setEnums;
case "extensions": return setExtensions; 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 // Fetch on mount if not in store
useEffect(() => { useEffect(() => {
@@ -113,6 +128,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
} else if (type === "enums") { } else if (type === "enums") {
const result = await cmd.getEnums(connectionId, currentSchema ?? undefined); const result = await cmd.getEnums(connectionId, currentSchema ?? undefined);
if (!cancelled) (setEnums as (v: EnumInfo[]) => void)(result); 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 { } catch {
// Silently fail — store remains null, we show the empty state // Silently fail — store remains null, we show the empty state
@@ -123,7 +141,7 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
fetchData(); fetchData();
return () => { cancelled = true; }; 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(); const q = search.toLowerCase().trim();
@@ -6,6 +6,7 @@ import {
ListChecks, ListChecks,
ListOrdered, ListOrdered,
Puzzle, Puzzle,
Layers,
ShieldCheck, ShieldCheck,
SquareFunction, SquareFunction,
Tag, Tag,
@@ -29,6 +30,7 @@ export const TYPE_LABELS: Record<ObjectType, string> = {
sequences: "Sequences", sequences: "Sequences",
enums: "Enums", enums: "Enums",
extensions: "Extensions", extensions: "Extensions",
hypertables: "Hypertables",
indexes: "Indexes", indexes: "Indexes",
constraints: "Constraints", constraints: "Constraints",
procedures: "Procedures", procedures: "Procedures",
@@ -41,6 +43,7 @@ export const SINGULAR_LABELS: Record<ObjectType, string> = {
sequences: "sequence", sequences: "sequence",
enums: "enum", enums: "enum",
extensions: "extension", extensions: "extension",
hypertables: "hypertable",
indexes: "index", indexes: "index",
constraints: "constraint", constraints: "constraint",
procedures: "procedure", procedures: "procedure",
@@ -55,6 +58,7 @@ export const OBJECT_ICONS: Record<ObjectType, React.ReactNode> = {
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />, sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
enums: <Tag 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" />, 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" />, indexes: <BookMarked size={14} className="text-text-muted shrink-0" />,
constraints: <ListChecks 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" />, procedures: <SquareFunction size={14} className="text-text-muted shrink-0" />,
+8 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core"; 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 } from "./types";
import type { FilterRule, SortRule } from "../stores/dbViewerStore"; import type { FilterRule, SortRule } from "../stores/dbViewerStore";
import type { ChangePayload } from "./changePayload"; import type { ChangePayload } from "./changePayload";
import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud"; import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud";
@@ -226,6 +226,13 @@ export async function getExtensions(connectionId: string): Promise<ExtensionInfo
return invoke<ExtensionInfo[]>("get_extensions", { connectionId }); 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( export async function getSchemaGraph(
connectionId: string, connectionId: string,
schema?: string, schema?: string,
+16
View File
@@ -367,6 +367,7 @@ export type ObjectType =
| "sequences" | "sequences"
| "enums" | "enums"
| "extensions" | "extensions"
| "hypertables"
| "indexes" | "indexes"
| "constraints" | "constraints"
| "procedures" | "procedures"
@@ -378,6 +379,21 @@ export interface ObjectSearchHit {
object_type: string; // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT 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 { export interface DependencyInfo {
deptype: string; deptype: string;
class: string; class: string;
+9 -1
View File
@@ -1,5 +1,5 @@
import { create } from "zustand"; 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 } from "../lib/types";
import { getDatabases, getSchemas, getTables } from "../lib/commands"; import { getDatabases, getSchemas, getTables } from "../lib/commands";
import type { ObjectKind, DdlParams } from "../lib/objectCrud"; import type { ObjectKind, DdlParams } from "../lib/objectCrud";
@@ -117,6 +117,8 @@ interface DbViewerState {
sequences: SequenceInfo[] | null; sequences: SequenceInfo[] | null;
enums: EnumInfo[] | null; enums: EnumInfo[] | null;
extensions: ExtensionInfo[] | null; extensions: ExtensionInfo[] | null;
hypertables: HypertableInfo[] | null;
hypertablesAvailable: boolean | null;
indexes: IndexInfo[] | null; indexes: IndexInfo[] | null;
setSchemaTreeLoading: (loading: boolean) => void; setSchemaTreeLoading: (loading: boolean) => void;
constraints: ConstraintInfo[] | null; constraints: ConstraintInfo[] | null;
@@ -182,6 +184,8 @@ interface DbViewerState {
setSequences: (sequences: SequenceInfo[]) => void; setSequences: (sequences: SequenceInfo[]) => void;
setEnums: (enums: EnumInfo[]) => void; setEnums: (enums: EnumInfo[]) => void;
setExtensions: (extensions: ExtensionInfo[]) => void; setExtensions: (extensions: ExtensionInfo[]) => void;
setHypertables: (hypertables: HypertableInfo[]) => void;
setHypertablesAvailable: (v: boolean | null) => void;
setIndexes: (indexes: IndexInfo[]) => void; setIndexes: (indexes: IndexInfo[]) => void;
setConstraints: (constraints: ConstraintInfo[]) => void; setConstraints: (constraints: ConstraintInfo[]) => void;
setRoles: (roles: RoleInfo[]) => void; setRoles: (roles: RoleInfo[]) => void;
@@ -224,6 +228,8 @@ const initialState = {
sequences: null as SequenceInfo[] | null, sequences: null as SequenceInfo[] | null,
enums: null as EnumInfo[] | null, enums: null as EnumInfo[] | null,
extensions: null as ExtensionInfo[] | null, extensions: null as ExtensionInfo[] | null,
hypertables: null as HypertableInfo[] | null,
hypertablesAvailable: null as boolean | null,
indexes: null as IndexInfo[] | null, indexes: null as IndexInfo[] | null,
constraints: null as ConstraintInfo[] | null, constraints: null as ConstraintInfo[] | null,
roles: [] as RoleInfo[], roles: [] as RoleInfo[],
@@ -586,6 +592,8 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
setSequences: (sequences) => set({ sequences }), setSequences: (sequences) => set({ sequences }),
setEnums: (enums) => set({ enums }), setEnums: (enums) => set({ enums }),
setExtensions: (extensions) => set({ extensions }), setExtensions: (extensions) => set({ extensions }),
setHypertables: (hypertables) => set({ hypertables }),
setHypertablesAvailable: (v) => set({ hypertablesAvailable: v }),
setIndexes: (indexes) => set({ indexes }), setIndexes: (indexes) => set({ indexes }),
setConstraints: (constraints) => set({ constraints }), setConstraints: (constraints) => set({ constraints }),
setRoles: (roles) => set({ roles }), setRoles: (roles) => set({ roles }),