refactor: convert to bun-workspaces monorepo (desktop/ + www/)
- Move the Tauri app (React frontend + Rust backend) into desktop/ via git mv — configs unchanged (relative paths: tauri.conf.json, vite.config.ts) - Root package.json becomes a private bun workspace container with orchestration scripts; desktop package renamed gridline-desktop - Scaffold www/ with Astro 7 + Tailwind v4 (hand-wired vite plugin, static output, ready for Dokploy) - Update release.yml for the new layout: projectPath: desktop, desktop/src-tauri resource paths, rust-cache workspace path - Update README + AGENTS.md structure trees and dev commands - Verified: vitest (1074), cargo test (354), desktop build, tauri dev (window launches), tauri build (dmg + app bundles), Astro build
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import tauriConf from "../../src-tauri/tauri.conf.json";
|
||||
|
||||
describe("tauri bundle config (v0.7.10)", () => {
|
||||
it("declares bundled pg_tools resources", () => {
|
||||
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
|
||||
});
|
||||
it("version is 0.7.10", () => {
|
||||
expect(tauriConf.version).toBe("0.7.10");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildChangePayload, buildChangeSql } from "./changePayload";
|
||||
import type { QueueItem } from "../stores/dbViewerStore";
|
||||
|
||||
const base = { id: "c1", status: "pending" as const, createdAt: 0 };
|
||||
|
||||
describe("buildChangePayload", () => {
|
||||
it("insert -> {schema, table, data}", () => {
|
||||
const item: QueueItem = { ...base, type: "insert", sql: "", schema: "public", table: "t",
|
||||
newData: { a: 1 } } as unknown as QueueItem;
|
||||
const p = buildChangePayload(item);
|
||||
expect(p).toEqual({ id: "c1", type: "insert", schema: "public", table: "t", data: "{\"a\":1}" });
|
||||
});
|
||||
|
||||
it("delete -> {schema, table, primary_key}", () => {
|
||||
const item = { ...base, type: "delete", sql: "", schema: "public", table: "t",
|
||||
primaryKey: { id: 5 } } as unknown as QueueItem;
|
||||
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "delete", schema: "public", table: "t", primary_key: "{\"id\":5}" });
|
||||
});
|
||||
|
||||
it("bulk_insert -> {schema, table, columns, rows}", () => {
|
||||
const item = { ...base, type: "bulk_insert", sql: "", schema: "public", table: "t",
|
||||
columns: ["a", "b"], rows: [[1, 2], [3, 4]] } as unknown as QueueItem;
|
||||
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "bulk_insert", schema: "public", table: "t", columns: ["a", "b"], rows: [[1, 2], [3, 4]] });
|
||||
});
|
||||
|
||||
it("drop_table -> {schema, table}", () => {
|
||||
const item = { ...base, type: "drop_table", sql: "", schema: "public", table: "t" } as unknown as QueueItem;
|
||||
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "drop_table", schema: "public", table: "t" });
|
||||
});
|
||||
|
||||
it("empty_table -> {schema, table}", () => {
|
||||
const item = { ...base, type: "empty_table", sql: "", schema: "public", table: "t" } as unknown as QueueItem;
|
||||
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "empty_table", schema: "public", table: "t" });
|
||||
});
|
||||
|
||||
it("alter_table -> {schema, table, sql, rollback_sql}", () => {
|
||||
const item = { ...base, type: "alter_table", sql: "ALTER TABLE t ADD c int", schema: "public", table: "t" } as unknown as QueueItem;
|
||||
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "alter_table", schema: "public", table: "t", sql: "ALTER TABLE t ADD c int", rollback_sql: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChangeSql", () => {
|
||||
it("insert", () => {
|
||||
const item = { id: "1", type: "insert", schema: "public", table: "t", newData: { a: 1, b: "x" } } as any;
|
||||
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" ("a", "b") VALUES (1, \'x\')');
|
||||
});
|
||||
it("update", () => {
|
||||
const item = { id: "1", type: "update", schema: "public", table: "t", primaryKey: { id: 5 }, newData: { name: "O'Brien" } } as any;
|
||||
expect(buildChangeSql(item)).toBe('UPDATE "public"."t" SET "name" = \'O\'\'Brien\' WHERE "id" = 5');
|
||||
});
|
||||
it("delete", () => {
|
||||
const item = { id: "1", type: "delete", schema: "public", table: "t", primaryKey: { id: 5 } } as any;
|
||||
expect(buildChangeSql(item)).toBe('DELETE FROM "public"."t" WHERE "id" = 5');
|
||||
});
|
||||
it("bulk_insert", () => {
|
||||
const item = { id: "1", type: "bulk_insert", schema: "public", table: "t", columns: ["a", "b"], rows: [[1, "y"], [2, null]] } as any;
|
||||
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" ("a", "b") VALUES (1, \'y\'), (2, NULL)');
|
||||
});
|
||||
it("empty_table / drop_table", () => {
|
||||
expect(buildChangeSql({ id: "1", type: "empty_table", schema: "public", table: "t" } as any)).toBe('DELETE FROM "public"."t"');
|
||||
expect(buildChangeSql({ id: "1", type: "drop_table", schema: "public", table: "t" } as any)).toBe('DROP TABLE "public"."t"');
|
||||
});
|
||||
});
|
||||
|
||||
function rebuildItem(sql: string): QueueItem {
|
||||
return {
|
||||
id: "ch-rb1", type: "rebuild_table", sql, status: "pending",
|
||||
createdAt: 0,
|
||||
} as unknown as QueueItem;
|
||||
}
|
||||
|
||||
describe("rebuild_table payload", () => {
|
||||
it("builds a rebuild_table payload with the script", () => {
|
||||
const p = buildChangePayload(rebuildItem("CREATE TABLE _t();"));
|
||||
expect(p).toEqual({ id: "ch-rb1", type: "rebuild_table", sql: "CREATE TABLE _t();" });
|
||||
});
|
||||
it("buildChangeSql returns the script verbatim", () => {
|
||||
expect(buildChangeSql(rebuildItem("SELECT 1;"))).toBe("SELECT 1;");
|
||||
});
|
||||
});
|
||||
|
||||
const ddlItem: QueueItem = {
|
||||
id: "ch-1", type: "ddl",
|
||||
sql: "CREATE TYPE public.role AS ENUM ('admin')",
|
||||
status: "pending", createdAt: 0,
|
||||
};
|
||||
describe("ddl change", () => {
|
||||
it("builds a ddl payload with id + type + sql", () => {
|
||||
expect(buildChangePayload(ddlItem)).toEqual({
|
||||
id: "ch-1", type: "ddl", sql: "CREATE TYPE public.role AS ENUM ('admin')",
|
||||
});
|
||||
});
|
||||
it("preview SQL is the raw sql", () => {
|
||||
expect(buildChangeSql(ddlItem)).toBe("CREATE TYPE public.role AS ENUM ('admin')");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { QueueItem } from "../stores/dbViewerStore";
|
||||
|
||||
/**
|
||||
* Payload emitted for a single queued change, keyed to match the Rust
|
||||
* `Change` enum variants (snake_case). Sent to `executeChange`.
|
||||
*/
|
||||
export type ChangePayload = Record<string, unknown>;
|
||||
|
||||
function j(v: unknown): string {
|
||||
return JSON.stringify(v ?? {});
|
||||
}
|
||||
|
||||
function q(s: string): string {
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function lit(v: unknown): string {
|
||||
if (v === null || v === undefined) return "NULL";
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
if (typeof v === "number") return String(v);
|
||||
if (typeof v === "string") return `'${v.replace(/'/g, "''")}'`;
|
||||
return `'${JSON.stringify(v).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function tableRef(schema: string | undefined, table: string | undefined): string {
|
||||
if (!table) return "-";
|
||||
return schema ? `${q(schema)}.${q(table)}` : q(table);
|
||||
}
|
||||
|
||||
export function buildChangeSql(item: QueueItem): string {
|
||||
const t = tableRef(item.schema, item.table);
|
||||
switch (item.type) {
|
||||
case "insert": {
|
||||
const data = item.newData ?? {};
|
||||
const cols = Object.keys(data);
|
||||
return `INSERT INTO ${t} (${cols.map(q).join(", ")}) VALUES (${cols.map((c) => lit(data[c])).join(", ")})`;
|
||||
}
|
||||
case "update": {
|
||||
const data = item.newData ?? {};
|
||||
const pk = item.primaryKey ?? {};
|
||||
const setClause = Object.keys(data).map((c) => `${q(c)} = ${lit(data[c])}`).join(", ");
|
||||
const whereClause = Object.keys(pk).map((c) => `${q(c)} = ${lit(pk[c])}`).join(" AND ");
|
||||
return `UPDATE ${t} SET ${setClause} WHERE ${whereClause}`;
|
||||
}
|
||||
case "delete": {
|
||||
const pk = item.primaryKey ?? {};
|
||||
const whereClause = Object.keys(pk).map((c) => `${q(c)} = ${lit(pk[c])}`).join(" AND ");
|
||||
return `DELETE FROM ${t} WHERE ${whereClause}`;
|
||||
}
|
||||
case "bulk_insert": {
|
||||
const cols = item.columns ?? [];
|
||||
const rows = item.rows ?? [];
|
||||
const valueRows = rows
|
||||
.map((row) => `(${row.map(lit).join(", ")})`)
|
||||
.join(", ");
|
||||
return `INSERT INTO ${t} (${cols.map(q).join(", ")}) VALUES ${valueRows}`;
|
||||
}
|
||||
case "empty_table":
|
||||
return `DELETE FROM ${t}`;
|
||||
case "ddl":
|
||||
return item.sql ?? "";
|
||||
case "rebuild_table":
|
||||
return item.sql ?? "";
|
||||
case "drop_table":
|
||||
return `DROP TABLE ${t}`;
|
||||
default:
|
||||
return item.sql ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
export function buildChangePayload(item: QueueItem): ChangePayload {
|
||||
const schema = item.schema ?? "";
|
||||
const table = item.table ?? "";
|
||||
switch (item.type) {
|
||||
case "insert":
|
||||
return { id: item.id, type: "insert", schema, table, data: j(item.newData) };
|
||||
case "update":
|
||||
return { id: item.id, type: "update", schema, table,
|
||||
primary_key: j(item.primaryKey), old_data: j(item.oldData), new_data: j(item.newData) };
|
||||
case "delete":
|
||||
return { id: item.id, type: "delete", schema, table, primary_key: j(item.primaryKey) };
|
||||
case "alter_table":
|
||||
return { id: item.id, type: "alter_table", schema, table, sql: item.sql, rollback_sql: "" };
|
||||
case "bulk_insert":
|
||||
return { id: item.id, type: "bulk_insert", schema, table,
|
||||
columns: item.columns ?? [], rows: item.rows ?? [] };
|
||||
case "drop_table":
|
||||
return { id: item.id, type: "drop_table", schema, table };
|
||||
case "empty_table":
|
||||
return { id: item.id, type: "empty_table", schema, table };
|
||||
case "ddl":
|
||||
return { id: item.id, type: "ddl", sql: item.sql };
|
||||
case "rebuild_table":
|
||||
return { id: item.id, type: "rebuild_table", sql: item.sql };
|
||||
default:
|
||||
return { id: item.id, type: item.type, sql: item.sql };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockResolvedValue({ tables: [], relationships: [] }),
|
||||
}));
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
testConnection,
|
||||
getRoles,
|
||||
getRolePrivileges,
|
||||
getTableColumns,
|
||||
getTableRebuildReadiness,
|
||||
runMaintenance,
|
||||
getTablespaces,
|
||||
buildRebuildScript,
|
||||
dbConnect,
|
||||
dbDisconnect,
|
||||
getDatabases,
|
||||
getSchemas,
|
||||
getTables,
|
||||
getTableData,
|
||||
executeChange,
|
||||
refreshConnection,
|
||||
getSchemaGraph,
|
||||
executeQuery,
|
||||
getQueryHistory,
|
||||
clearQueryHistory,
|
||||
setHistoryFavorite,
|
||||
saveQuery,
|
||||
getSavedQueries,
|
||||
updateSavedQuery,
|
||||
deleteSavedQuery,
|
||||
setConnectionFavorite,
|
||||
recordRecentConnection,
|
||||
getRecentConnections,
|
||||
clearRecentConnections,
|
||||
getIndexes,
|
||||
getConstraints,
|
||||
createSchema,
|
||||
renameSchema,
|
||||
dropSchema,
|
||||
searchObjects,
|
||||
getObjectDdl,
|
||||
getObjectDependencies,
|
||||
} from "./commands";
|
||||
import * as cmd from "./commands";
|
||||
import type { SchemaGraph } from "./types";
|
||||
import type { QueryHistoryEntry } from "./commands";
|
||||
|
||||
describe("commands", () => {
|
||||
it("testConnection has correct signature", () => {
|
||||
expect(typeof testConnection).toBe("function");
|
||||
});
|
||||
|
||||
it("dbConnect returns void promise", () => {
|
||||
expect(typeof dbConnect).toBe("function");
|
||||
});
|
||||
|
||||
it("dbDisconnect returns void promise", () => {
|
||||
expect(typeof dbDisconnect).toBe("function");
|
||||
});
|
||||
|
||||
it("getDatabases returns string array promise", () => {
|
||||
expect(typeof getDatabases).toBe("function");
|
||||
});
|
||||
|
||||
it("getSchemas returns string array promise", () => {
|
||||
expect(typeof getSchemas).toBe("function");
|
||||
});
|
||||
|
||||
it("getTables returns TableInfo array promise", () => {
|
||||
expect(typeof getTables).toBe("function");
|
||||
});
|
||||
|
||||
it("getTableData returns QueryResult-shaped promise", () => {
|
||||
expect(typeof getTableData).toBe("function");
|
||||
});
|
||||
|
||||
it("executeChange returns void promise", () => {
|
||||
expect(typeof executeChange).toBe("function");
|
||||
});
|
||||
|
||||
it("refreshConnection returns full tree promise", () => {
|
||||
expect(typeof refreshConnection).toBe("function");
|
||||
});
|
||||
|
||||
describe("getSchemaGraph", () => {
|
||||
it("is a callable function with correct signature", () => {
|
||||
expect(typeof getSchemaGraph).toBe("function");
|
||||
const result: Promise<SchemaGraph> = getSchemaGraph("conn-1", "public");
|
||||
expect(result).toBeInstanceOf(Promise);
|
||||
});
|
||||
|
||||
it("accepts schema as optional", () => {
|
||||
const result: Promise<SchemaGraph> = getSchemaGraph("conn-1");
|
||||
expect(result).toBeInstanceOf(Promise);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("query commands", () => {
|
||||
it("executeQuery calls invoke with correct args", async () => {
|
||||
const mockResult = { columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 };
|
||||
vi.mocked(invoke).mockResolvedValueOnce(mockResult);
|
||||
|
||||
const result = await executeQuery("conn-1", "SELECT 1", 1, 50);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("execute_query", {
|
||||
connectionId: "conn-1",
|
||||
query: "SELECT 1",
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
});
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it("getQueryHistory calls invoke", async () => {
|
||||
const mockHistory = [{ id: "h1", connection_id: "c1", query_text: "SELECT 1", status: "success", executed_at: "2025-01-01" }];
|
||||
vi.mocked(invoke).mockResolvedValueOnce(mockHistory);
|
||||
const result = await getQueryHistory("conn-1", 10, 0);
|
||||
expect(invoke).toHaveBeenCalledWith("get_query_history", { connectionId: "conn-1", limit: 10, offset: 0 });
|
||||
expect(result).toEqual(mockHistory);
|
||||
});
|
||||
|
||||
it("clearQueryHistory calls invoke", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce(undefined);
|
||||
await clearQueryHistory("conn-1");
|
||||
expect(invoke).toHaveBeenCalledWith("clear_query_history", { connectionId: "conn-1" });
|
||||
});
|
||||
|
||||
it("getQueryHistory with null calls invoke with null connectionId", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||
await getQueryHistory(null, 50, 0);
|
||||
expect(invoke).toHaveBeenCalledWith("get_query_history", {
|
||||
connectionId: null,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("clearQueryHistory with null clears all", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce(undefined);
|
||||
await clearQueryHistory(null);
|
||||
expect(invoke).toHaveBeenCalledWith("clear_query_history", {
|
||||
connectionId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Query History — v6", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("QueryHistoryEntry includes favorite field", () => {
|
||||
const entry: QueryHistoryEntry = {
|
||||
id: "h1",
|
||||
connection_id: "c1",
|
||||
query_text: "SELECT 1",
|
||||
execution_time_ms: 42,
|
||||
row_count: 5,
|
||||
status: "success",
|
||||
error_message: null,
|
||||
executed_at: "2026-01-01T00:00:00Z",
|
||||
favorite: false, // NEW — must be accepted
|
||||
};
|
||||
expect(entry.favorite).toBe(false);
|
||||
});
|
||||
|
||||
it("setHistoryFavorite calls invoke with correct params", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await setHistoryFavorite("entry-id-1", "conn-abc");
|
||||
expect(mockInvoke).toHaveBeenCalledWith("set_history_favorite", {
|
||||
id: "entry-id-1",
|
||||
connectionId: "conn-abc",
|
||||
});
|
||||
});
|
||||
|
||||
it("saveQuery calls invoke with correct params", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await saveQuery({
|
||||
connectionId: "conn-xyz",
|
||||
name: "My Saved Query",
|
||||
queryText: "SELECT * FROM users",
|
||||
folder: "reports",
|
||||
});
|
||||
expect(mockInvoke).toHaveBeenCalledWith("save_query", {
|
||||
connectionId: "conn-xyz",
|
||||
name: "My Saved Query",
|
||||
queryText: "SELECT * FROM users",
|
||||
folder: "reports",
|
||||
});
|
||||
});
|
||||
|
||||
it("saveQuery accepts null connectionId for global queries", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await saveQuery({
|
||||
connectionId: null,
|
||||
name: "Global query",
|
||||
queryText: "SELECT 1",
|
||||
folder: "",
|
||||
});
|
||||
expect(mockInvoke).toHaveBeenCalledWith("save_query", {
|
||||
connectionId: null,
|
||||
name: "Global query",
|
||||
queryText: "SELECT 1",
|
||||
folder: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("getSavedQueries calls invoke with correct params", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue([]);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await getSavedQueries("conn-123");
|
||||
expect(mockInvoke).toHaveBeenCalledWith("get_saved_queries", {
|
||||
connectionId: "conn-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("getSavedQueries accepts null for all-connections", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue([]);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await getSavedQueries(null);
|
||||
expect(mockInvoke).toHaveBeenCalledWith("get_saved_queries", {
|
||||
connectionId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("updateSavedQuery calls invoke with correct params", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await updateSavedQuery("q-id", { name: "Renamed" });
|
||||
expect(mockInvoke).toHaveBeenCalledWith("update_saved_query", {
|
||||
id: "q-id",
|
||||
patch: { name: "Renamed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("deleteSavedQuery calls invoke with correct params", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await deleteSavedQuery("q-id");
|
||||
expect(mockInvoke).toHaveBeenCalledWith("delete_saved_query", {
|
||||
id: "q-id",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("v0.5.0 command wrappers", () => {
|
||||
it("setConnectionFavorite invokes set_connection_favorite with camelCase", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await setConnectionFavorite("c1", true);
|
||||
expect(mockInvoke).toHaveBeenCalledWith("set_connection_favorite", { connectionId: "c1", favorite: true });
|
||||
});
|
||||
|
||||
it("recordRecentConnection invokes record_recent_connection", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await recordRecentConnection("c1");
|
||||
expect(mockInvoke).toHaveBeenCalledWith("record_recent_connection", { connectionId: "c1" });
|
||||
});
|
||||
|
||||
it("getRecentConnections invokes get_recent_connections with limit", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue([]);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await getRecentConnections(8);
|
||||
expect(mockInvoke).toHaveBeenCalledWith("get_recent_connections", { limit: 8 });
|
||||
});
|
||||
|
||||
it("clearRecentConnections invokes clear_recent_connections", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await clearRecentConnections();
|
||||
expect(mockInvoke).toHaveBeenCalledWith("clear_recent_connections", {});
|
||||
});
|
||||
|
||||
it("getIndexes invokes get_indexes with connectionId + schema", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue([]);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await getIndexes("c1", "public");
|
||||
expect(mockInvoke).toHaveBeenCalledWith("get_indexes", { connectionId: "c1", schema: "public" });
|
||||
});
|
||||
|
||||
it("getConstraints invokes get_constraints with connectionId + schema", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue([]);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await getConstraints("c1", "public");
|
||||
expect(mockInvoke).toHaveBeenCalledWith("get_constraints", { connectionId: "c1", schema: "public" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("object management commands (v0.7.5)", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("createSchema calls invoke with name", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce(undefined);
|
||||
await createSchema("c1", "my_schema");
|
||||
expect(invoke).toHaveBeenCalledWith("create_schema", { connectionId: "c1", name: "my_schema" });
|
||||
});
|
||||
it("renameSchema maps old/new names", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce(undefined);
|
||||
await renameSchema("c1", "old", "new");
|
||||
expect(invoke).toHaveBeenCalledWith("rename_schema", { connectionId: "c1", oldName: "old", newName: "new" });
|
||||
});
|
||||
it("dropSchema passes cascade", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce(undefined);
|
||||
await dropSchema("c1", "s", true);
|
||||
expect(invoke).toHaveBeenCalledWith("drop_schema", { connectionId: "c1", name: "s", cascade: true });
|
||||
});
|
||||
it("searchObjects maps query+schema", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||
await searchObjects("c1", "public", "user");
|
||||
expect(invoke).toHaveBeenCalledWith("search_objects", { connectionId: "c1", schema: "public", query: "user" });
|
||||
});
|
||||
it("getObjectDdl maps objectType+name", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce("CREATE SEQUENCE ...");
|
||||
await getObjectDdl("c1", "public", "sequence", "users_id_seq");
|
||||
expect(invoke).toHaveBeenCalledWith("get_object_ddl", { connectionId: "c1", schema: "public", objectType: "sequence", name: "users_id_seq" });
|
||||
});
|
||||
it("getObjectDependencies maps objectType+name", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||
await getObjectDependencies("c1", "public", "table", "orders");
|
||||
expect(invoke).toHaveBeenCalledWith("get_object_dependencies", { connectionId: "c1", schema: "public", objectType: "table", name: "orders" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("v0.7.7 command wrappers", () => {
|
||||
it("getRoles calls get_roles", async () => {
|
||||
const mockRoles = [
|
||||
{
|
||||
name: "postgres",
|
||||
superuser: true,
|
||||
inherit: true,
|
||||
create_db: true,
|
||||
create_role: true,
|
||||
can_login: true,
|
||||
replication: true,
|
||||
bypass_rls: false,
|
||||
connection_limit: -1,
|
||||
valid_until: null,
|
||||
memberships: [],
|
||||
},
|
||||
];
|
||||
vi.mocked(invoke).mockResolvedValueOnce(mockRoles);
|
||||
|
||||
const result = await getRoles("c1");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("get_roles", { connectionId: "c1" });
|
||||
expect(result).toEqual(mockRoles);
|
||||
});
|
||||
|
||||
it("getRolePrivileges calls get_role_privileges with role", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getRolePrivileges("c1", "app");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("get_role_privileges", { connectionId: "c1", role: "app" });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("getTableColumns calls get_table_columns", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getTableColumns("c1", "public", "users");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("get_table_columns", { connectionId: "c1", schema: "public", table: "users" });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("getTableRebuildReadiness calls get_table_rebuild_readiness", async () => {
|
||||
const mockReadiness = { ok: true, reasons: [] };
|
||||
vi.mocked(invoke).mockResolvedValueOnce(mockReadiness);
|
||||
|
||||
const result = await getTableRebuildReadiness("c1", "public", "users");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("get_table_rebuild_readiness", { connectionId: "c1", schema: "public", table: "users" });
|
||||
expect(result).toEqual(mockReadiness);
|
||||
});
|
||||
|
||||
it("runMaintenance calls run_maintenance with action", async () => {
|
||||
const mockResult = { duration_ms: 5, message: "ok" };
|
||||
vi.mocked(invoke).mockResolvedValueOnce(mockResult);
|
||||
|
||||
const result = await runMaintenance("c1", "public", "users", "vacuum");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("run_maintenance", { connectionId: "c1", schema: "public", table: "users", action: "vacuum" });
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it("getTablespaces calls get_tablespaces", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getTablespaces("c1");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("get_tablespaces", { connectionId: "c1" });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("buildRebuildScript calls build_rebuild_script with newColumns", async () => {
|
||||
const mockScript = "CREATE TABLE _t();";
|
||||
const cols = [{ name: "id", type: "int", nullable: false, default: null, is_pk: true }];
|
||||
vi.mocked(invoke).mockResolvedValueOnce(mockScript);
|
||||
|
||||
const result = await buildRebuildScript("c1", "public", "users", cols);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("build_rebuild_script", { connectionId: "c1", schema: "public", table: "users", newColumns: cols });
|
||||
expect(result).toEqual(mockScript);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v0.7.8 command wrappers exist", () => {
|
||||
it("exports the backup/cancel/settings wrappers", () => {
|
||||
for (const name of ["cancelQuery","mysqlDump","mysqlRestore","mysqlSync","detectMysqlTools","sqliteDump","sqliteRestore","sqliteSync","exportSettings","importSettings"]) {
|
||||
expect(typeof (cmd as Record<string, unknown>)[name]).toBe("function");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,396 @@
|
||||
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 { FilterRule, SortRule } from "../stores/dbViewerStore";
|
||||
import type { ChangePayload } from "./changePayload";
|
||||
import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud";
|
||||
|
||||
export { type ObjectKind, type DdlParams };
|
||||
|
||||
/** Build one or more SQL statements for an object CRUD operation. */
|
||||
export function buildObjectDdl(connectionId: string, kind: ObjectKind, params: DdlParams): Promise<string[]> {
|
||||
return buildObjectDdlImpl(connectionId, kind, params);
|
||||
}
|
||||
|
||||
// NOTE on argument key naming:
|
||||
// Tauri v2's #[tauri::command] macro converts Rust snake_case parameter names
|
||||
// to camelCase keys on the IPC boundary. So a Rust param `connection_id` must be
|
||||
// sent as `connectionId` here, `page_size` as `pageSize`, `tag_ids` as `tagIds`,
|
||||
// `folder_id` as `folderId`. Single-word params (id, input, config, json, schema,
|
||||
// table, page, key, value, change) are unchanged.
|
||||
|
||||
export async function getConnections(): Promise<Connection[]> { return invoke<Connection[]>("get_connections"); }
|
||||
export async function createConnection(input: ConnectionInput): Promise<Connection> { return invoke<Connection>("create_connection", { input }); }
|
||||
export async function updateConnection(id: string, input: ConnectionInput): Promise<Connection> { return invoke<Connection>("update_connection", { id, input }); }
|
||||
export async function deleteConnection(id: string): Promise<void> { return invoke<void>("delete_connection", { id }); }
|
||||
export async function addConnectionTags(connectionId: string, tagIds: string[]): Promise<void> { return invoke<void>("add_connection_tags", { connectionId, tagIds }); }
|
||||
export async function getFolders(): Promise<Folder[]> { return invoke<Folder[]>("get_folders"); }
|
||||
export async function createFolder(input: FolderInput): Promise<Folder> { return invoke<Folder>("create_folder", { input }); }
|
||||
export async function updateFolder(id: string, input: FolderInput): Promise<Folder> { return invoke<Folder>("update_folder", { id, input }); }
|
||||
export async function deleteFolder(id: string): Promise<void> { return invoke<void>("delete_folder", { id }); }
|
||||
export async function addFolderTags(folderId: string, tagIds: string[]): Promise<void> { return invoke<void>("add_folder_tags", { folderId, tagIds }); }
|
||||
export async function getTags(): Promise<Tag[]> { return invoke<Tag[]>("get_tags"); }
|
||||
export async function createTag(input: TagInput): Promise<Tag> { return invoke<Tag>("create_tag", { input }); }
|
||||
export async function deleteTag(id: string): Promise<void> { return invoke<void>("delete_tag", { id }); }
|
||||
export async function updateTag(id: string, input: TagInput): Promise<Tag> { return invoke<Tag>("update_tag", { id, input }); }
|
||||
export async function getSettings(): Promise<Settings> { return invoke<Settings>("get_settings"); }
|
||||
export async function updateSetting(key: string, value: string): Promise<void> { return invoke<void>("update_setting", { key, value }); }
|
||||
export async function importConnections(json: string): Promise<ImportResult> { return invoke<ImportResult>("import_connections", { json }); }
|
||||
export async function exportConnections(): Promise<string> { return invoke<string>("export_connections"); }
|
||||
export async function testConnection(input: ConnectionInput): Promise<ConnectionTestResult> {
|
||||
return invoke<ConnectionTestResult>("test_connection", { config: input });
|
||||
}
|
||||
|
||||
// ─── Keychain ──────────────────────────────────────────────────
|
||||
|
||||
export async function saveConnectionPassword(connectionId: string, password: string): Promise<void> {
|
||||
return invoke<void>("save_connection_password", { connectionId, password });
|
||||
}
|
||||
|
||||
export async function getConnectionPassword(connectionId: string): Promise<string | null> {
|
||||
return invoke<string | null>("get_connection_password", { connectionId });
|
||||
}
|
||||
|
||||
export async function deleteConnectionPassword(connectionId: string): Promise<void> {
|
||||
return invoke<void>("delete_connection_password", { connectionId });
|
||||
}
|
||||
|
||||
// ─── Keychain: SSH secrets ────────────────────────────────────
|
||||
|
||||
export async function saveConnectionSshPassword(connectionId: string, password: string): Promise<void> {
|
||||
return invoke<void>("save_connection_ssh_password", { connectionId, password });
|
||||
}
|
||||
|
||||
export async function getConnectionSshPassword(connectionId: string): Promise<string | null> {
|
||||
return invoke<string | null>("get_connection_ssh_password", { connectionId });
|
||||
}
|
||||
|
||||
export async function deleteConnectionSshPassword(connectionId: string): Promise<void> {
|
||||
return invoke<void>("delete_connection_ssh_password", { connectionId });
|
||||
}
|
||||
|
||||
export async function saveConnectionSshPassphrase(connectionId: string, passphrase: string): Promise<void> {
|
||||
return invoke<void>("save_connection_ssh_passphrase", { connectionId, passphrase });
|
||||
}
|
||||
|
||||
export async function getConnectionSshPassphrase(connectionId: string): Promise<string | null> {
|
||||
return invoke<string | null>("get_connection_ssh_passphrase", { connectionId });
|
||||
}
|
||||
|
||||
export async function deleteConnectionSshPassphrase(connectionId: string): Promise<void> {
|
||||
return invoke<void>("delete_connection_ssh_passphrase", { connectionId });
|
||||
}
|
||||
|
||||
export async function recreateDemoDb(): Promise<string> {
|
||||
return invoke<string>("recreate_demo_db");
|
||||
}
|
||||
|
||||
export async function regenerateDemoDb(): Promise<string> {
|
||||
return invoke<string>("regenerate_demo_db");
|
||||
}
|
||||
|
||||
// ─── DB Viewer Lifecycle ────────────────────────────────────────
|
||||
|
||||
export async function dbConnect(connectionId: string, input: ConnectionInput): Promise<void> {
|
||||
return invoke<void>("db_connect", { connectionId, config: input });
|
||||
}
|
||||
|
||||
export async function dbDisconnect(connectionId: string): Promise<void> {
|
||||
return invoke<void>("db_disconnect", { connectionId });
|
||||
}
|
||||
|
||||
export async function getDatabases(connectionId: string): Promise<string[]> {
|
||||
return invoke<string[]>("get_databases", { connectionId });
|
||||
}
|
||||
|
||||
export async function getSchemas(connectionId: string): Promise<string[]> {
|
||||
return invoke<string[]>("get_schemas", { connectionId });
|
||||
}
|
||||
|
||||
export async function getTables(connectionId: string, schema?: string): Promise<TableInfo[]> {
|
||||
return invoke<TableInfo[]>("get_tables", { connectionId, schema });
|
||||
}
|
||||
|
||||
export async function getTableData(
|
||||
connectionId: string,
|
||||
schema: string,
|
||||
table: string,
|
||||
page?: number,
|
||||
pageSize?: number,
|
||||
filters?: FilterRule[],
|
||||
sorts?: SortRule[],
|
||||
): Promise<QueryResult> {
|
||||
return invoke<QueryResult>("get_table_data", { connectionId, schema, table, page, pageSize, filters, sorts });
|
||||
}
|
||||
|
||||
export async function executeChange(connectionId: string, change: ChangePayload): Promise<void> {
|
||||
return invoke<void>("execute_change", { connectionId, change });
|
||||
}
|
||||
|
||||
export async function getTableDdl(connectionId: string, schema: string, table: string): Promise<string> {
|
||||
return invoke<string>("get_table_ddl", { connectionId, schema, table });
|
||||
}
|
||||
|
||||
export async function getFkPreview(
|
||||
connectionId: string,
|
||||
schema: string,
|
||||
table: string,
|
||||
column: string,
|
||||
value: string,
|
||||
): Promise<QueryResult> {
|
||||
return invoke<QueryResult>("get_fk_preview", { connectionId, schema, table, column, value });
|
||||
}
|
||||
|
||||
export async function refreshConnection(connectionId: string): Promise<void> {
|
||||
return invoke<void>("refresh_connection", { connectionId });
|
||||
}
|
||||
|
||||
// ─── Backup / Restore / Sync ──────────────────────────────────
|
||||
|
||||
export async function detectPgTools(): Promise<PgToolStatus> {
|
||||
return invoke<PgToolStatus>("detect_pg_tools");
|
||||
}
|
||||
|
||||
export async function pgDump(connectionId: string, options: BackupOptions): Promise<string> {
|
||||
return invoke<string>("pg_dump", { connectionId, options });
|
||||
}
|
||||
|
||||
export async function pgRestore(connectionId: string, options: RestoreOptions): Promise<string> {
|
||||
return invoke<string>("pg_restore", { connectionId, options });
|
||||
}
|
||||
|
||||
export async function dbSync(options: SyncOptions): Promise<string> {
|
||||
return invoke<string>("db_sync", { options });
|
||||
}
|
||||
|
||||
// ─── v0.7.8: Cancel / MySQL / SQLite / Settings export-import ────
|
||||
|
||||
export async function cancelQuery(connectionId: string): Promise<void> {
|
||||
return invoke<void>("cancel_query", { connectionId });
|
||||
}
|
||||
|
||||
export async function detectMysqlTools(): Promise<MySqlToolStatus> {
|
||||
return invoke<MySqlToolStatus>("detect_mysql_tools");
|
||||
}
|
||||
|
||||
export async function mysqlDump(connectionId: string, options: MySqlBackupOptions): Promise<string> {
|
||||
return invoke<string>("mysql_dump", { connectionId, options });
|
||||
}
|
||||
|
||||
export async function mysqlRestore(connectionId: string, options: MySqlRestoreOptions): Promise<string> {
|
||||
return invoke<string>("mysql_restore", { connectionId, options });
|
||||
}
|
||||
|
||||
export async function mysqlSync(options: SyncOptions): Promise<string> {
|
||||
return invoke<string>("mysql_sync", { options });
|
||||
}
|
||||
|
||||
export async function sqliteDump(connectionId: string, options: SqliteBackupOptions): Promise<string> {
|
||||
return invoke<string>("sqlite_dump", { connectionId, options });
|
||||
}
|
||||
|
||||
export async function sqliteRestore(connectionId: string, options: SqliteRestoreOptions): Promise<string> {
|
||||
return invoke<string>("sqlite_restore", { connectionId, options });
|
||||
}
|
||||
|
||||
export async function sqliteSync(options: SyncOptions): Promise<string> {
|
||||
return invoke<string>("sqlite_sync", { options });
|
||||
}
|
||||
|
||||
export async function exportSettings(): Promise<string> {
|
||||
return invoke<string>("export_settings");
|
||||
}
|
||||
|
||||
export async function importSettings(json: string): Promise<void> {
|
||||
return invoke<void>("import_settings", { json });
|
||||
}
|
||||
|
||||
// ─── Object Explorer (Functions, Triggers, Sequences, Enums, Extensions) ────
|
||||
|
||||
export async function getFunctions(connectionId: string, schema?: string): Promise<FunctionInfo[]> {
|
||||
return invoke<FunctionInfo[]>("get_functions", { connectionId, schema });
|
||||
}
|
||||
|
||||
export async function getTriggers(connectionId: string, schema?: string): Promise<TriggerInfo[]> {
|
||||
return invoke<TriggerInfo[]>("get_triggers", { connectionId, schema });
|
||||
}
|
||||
|
||||
export async function getSequences(connectionId: string, schema?: string): Promise<SequenceInfo[]> {
|
||||
return invoke<SequenceInfo[]>("get_sequences", { connectionId, schema });
|
||||
}
|
||||
|
||||
export async function getEnums(connectionId: string, schema?: string): Promise<EnumInfo[]> {
|
||||
return invoke<EnumInfo[]>("get_enums", { connectionId, schema });
|
||||
}
|
||||
|
||||
export async function getExtensions(connectionId: string): Promise<ExtensionInfo[]> {
|
||||
return invoke<ExtensionInfo[]>("get_extensions", { connectionId });
|
||||
}
|
||||
|
||||
export async function getSchemaGraph(
|
||||
connectionId: string,
|
||||
schema?: string,
|
||||
): Promise<SchemaGraph> {
|
||||
return invoke<SchemaGraph>("get_schema_graph", { connectionId, schema });
|
||||
}
|
||||
|
||||
// ─── Query History ──────────────────────────────────────────────
|
||||
|
||||
export interface QueryHistoryEntry {
|
||||
id: string;
|
||||
connection_id: string;
|
||||
query_text: string;
|
||||
execution_time_ms: number | null;
|
||||
row_count: number | null;
|
||||
status: string;
|
||||
error_message: string | null;
|
||||
executed_at: string;
|
||||
favorite: boolean; // NEW — v6
|
||||
}
|
||||
|
||||
export interface SavedQuery {
|
||||
id: string;
|
||||
connection_id: string | null;
|
||||
name: string;
|
||||
query_text: string;
|
||||
folder: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SaveQueryInput {
|
||||
connectionId: string | null;
|
||||
name: string;
|
||||
queryText: string;
|
||||
folder: string;
|
||||
}
|
||||
|
||||
export interface UpdateSavedQueryPatch {
|
||||
name?: string;
|
||||
queryText?: string;
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
export async function executeQuery(
|
||||
connectionId: string,
|
||||
query: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): Promise<QueryResult> {
|
||||
return invoke<QueryResult>("execute_query", { connectionId, query, page, pageSize });
|
||||
}
|
||||
|
||||
export async function getQueryHistory(
|
||||
connectionId: string | null,
|
||||
limit: number,
|
||||
offset: number,
|
||||
): Promise<QueryHistoryEntry[]> {
|
||||
return invoke<QueryHistoryEntry[]>("get_query_history", { connectionId, limit, offset });
|
||||
}
|
||||
|
||||
export async function clearQueryHistory(connectionId: string | null): Promise<void> {
|
||||
return invoke<void>("clear_query_history", { connectionId });
|
||||
}
|
||||
|
||||
export async function setHistoryFavorite(
|
||||
id: string,
|
||||
connectionId: string,
|
||||
): Promise<void> {
|
||||
return invoke<void>("set_history_favorite", { id, connectionId });
|
||||
}
|
||||
|
||||
export async function saveQuery(input: SaveQueryInput): Promise<SavedQuery> {
|
||||
return invoke<SavedQuery>("save_query", {
|
||||
connectionId: input.connectionId,
|
||||
name: input.name,
|
||||
queryText: input.queryText,
|
||||
folder: input.folder,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSavedQueries(
|
||||
connectionId: string | null,
|
||||
): Promise<SavedQuery[]> {
|
||||
return invoke<SavedQuery[]>("get_saved_queries", { connectionId });
|
||||
}
|
||||
|
||||
export async function updateSavedQuery(
|
||||
id: string,
|
||||
patch: UpdateSavedQueryPatch,
|
||||
): Promise<void> {
|
||||
return invoke<void>("update_saved_query", { id, patch });
|
||||
}
|
||||
|
||||
export async function deleteSavedQuery(id: string): Promise<void> {
|
||||
return invoke<void>("delete_saved_query", { id });
|
||||
}
|
||||
|
||||
// ─── v0.5.0: Favorites / Recents / Indexes / Constraints ──────────
|
||||
|
||||
export async function setConnectionFavorite(connectionId: string, favorite: boolean): Promise<void> {
|
||||
return invoke<void>("set_connection_favorite", { connectionId, favorite });
|
||||
}
|
||||
|
||||
export async function recordRecentConnection(connectionId: string): Promise<void> {
|
||||
return invoke<void>("record_recent_connection", { connectionId });
|
||||
}
|
||||
|
||||
export async function getRecentConnections(limit: number): Promise<RecentConnection[]> {
|
||||
return invoke<RecentConnection[]>("get_recent_connections", { limit });
|
||||
}
|
||||
|
||||
export async function clearRecentConnections(): Promise<void> {
|
||||
return invoke<void>("clear_recent_connections", {});
|
||||
}
|
||||
|
||||
export async function getIndexes(connectionId: string, schema?: string): Promise<IndexInfo[]> {
|
||||
return invoke<IndexInfo[]>("get_indexes", { connectionId, schema });
|
||||
}
|
||||
|
||||
export async function getConstraints(connectionId: string, schema?: string): Promise<ConstraintInfo[]> {
|
||||
return invoke<ConstraintInfo[]>("get_constraints", { connectionId, schema });
|
||||
}
|
||||
|
||||
// ─── v0.7.5: Object management (schemas, search, DDL, dependencies) ──
|
||||
|
||||
export async function createSchema(connectionId: string, name: string): Promise<void> {
|
||||
return invoke<void>("create_schema", { connectionId, name });
|
||||
}
|
||||
export async function renameSchema(connectionId: string, oldName: string, newName: string): Promise<void> {
|
||||
return invoke<void>("rename_schema", { connectionId, oldName, newName });
|
||||
}
|
||||
export async function dropSchema(connectionId: string, name: string, cascade: boolean): Promise<void> {
|
||||
return invoke<void>("drop_schema", { connectionId, name, cascade });
|
||||
}
|
||||
export async function searchObjects(connectionId: string, schema: string, query: string): Promise<ObjectSearchHit[]> {
|
||||
return invoke<ObjectSearchHit[]>("search_objects", { connectionId, schema, query });
|
||||
}
|
||||
export async function getObjectDdl(connectionId: string, schema: string, objectType: string, name: string): Promise<string> {
|
||||
return invoke<string>("get_object_ddl", { connectionId, schema, objectType, name });
|
||||
}
|
||||
export async function getObjectDependencies(connectionId: string, schema: string, objectType: string, name: string): Promise<DependencyInfo[]> {
|
||||
return invoke<DependencyInfo[]>("get_object_dependencies", { connectionId, schema, objectType, name });
|
||||
}
|
||||
|
||||
// ─── v0.7.7: Roles / privileges / rebuild / maintenance ──────────
|
||||
|
||||
export async function getRoles(connectionId: string): Promise<RoleInfo[]> {
|
||||
return invoke<RoleInfo[]>("get_roles", { connectionId });
|
||||
}
|
||||
export async function getRolePrivileges(connectionId: string, role: string): Promise<PrivilegeEntry[]> {
|
||||
return invoke<PrivilegeEntry[]>("get_role_privileges", { connectionId, role });
|
||||
}
|
||||
export async function getTableColumns(connectionId: string, schema: string, table: string): Promise<ColumnInfo[]> {
|
||||
return invoke<ColumnInfo[]>("get_table_columns", { connectionId, schema, table });
|
||||
}
|
||||
export async function getTableRebuildReadiness(connectionId: string, schema: string, table: string): Promise<RebuildReadiness> {
|
||||
return invoke<RebuildReadiness>("get_table_rebuild_readiness", { connectionId, schema, table });
|
||||
}
|
||||
export async function runMaintenance(connectionId: string, schema: string, table: string, action: "vacuum" | "analyze" | "reindex"): Promise<MaintenanceResult> {
|
||||
return invoke<MaintenanceResult>("run_maintenance", { connectionId, schema, table, action });
|
||||
}
|
||||
export async function getTablespaces(connectionId: string): Promise<TablespaceInfo[]> {
|
||||
return invoke<TablespaceInfo[]>("get_tablespaces", { connectionId });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseConnectionString, looksLikeConnectionString, detectProviderFromHost, buildConnectionUrl } from "./connectionString";
|
||||
import type { Connection } from "./types";
|
||||
|
||||
function makeConn(overrides: Partial<Connection> = {}): Connection {
|
||||
return {
|
||||
id: "c1",
|
||||
name: "Test",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
username: "user",
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
favorite: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseConnectionString", () => {
|
||||
it("parses a PostgreSQL URL", () => {
|
||||
const result = parseConnectionString("postgresql://user:pass@localhost:5432/mydb");
|
||||
expect(result).toEqual({
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
username: "user",
|
||||
password: "pass",
|
||||
database: "mydb",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a MySQL URL without password", () => {
|
||||
const result = parseConnectionString("mysql://root@127.0.0.1:3306/app");
|
||||
expect(result).toEqual({
|
||||
db_type: "mysql",
|
||||
host: "127.0.0.1",
|
||||
port: 3306,
|
||||
username: "root",
|
||||
password: null,
|
||||
database: "app",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a Redis URL", () => {
|
||||
const result = parseConnectionString("redis://user:pass@localhost:6379/0");
|
||||
expect(result).toEqual({
|
||||
db_type: "redis",
|
||||
host: "localhost",
|
||||
port: 6379,
|
||||
username: "user",
|
||||
password: "pass",
|
||||
database: "0",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a SQLite file URL — path maps to host (v0.7.0 fix)", () => {
|
||||
const result = parseConnectionString("sqlite:///path/to/db.sqlite");
|
||||
expect(result).toEqual({
|
||||
db_type: "sqlite",
|
||||
host: "/path/to/db.sqlite",
|
||||
port: null,
|
||||
username: null,
|
||||
password: null,
|
||||
database: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a SQLite file: URL — path maps to host", () => {
|
||||
const result = parseConnectionString("file:///Users/me/data.db");
|
||||
expect(result).toEqual({
|
||||
db_type: "sqlite",
|
||||
host: "/Users/me/data.db",
|
||||
port: null,
|
||||
username: null,
|
||||
password: null,
|
||||
database: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults PostgreSQL port to 5432 when omitted", () => {
|
||||
const result = parseConnectionString("postgresql://user@host/db");
|
||||
expect(result).toEqual({
|
||||
db_type: "postgresql",
|
||||
host: "host",
|
||||
port: 5432,
|
||||
username: "user",
|
||||
password: null,
|
||||
database: "db",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves database name when query parameters are present", () => {
|
||||
const result = parseConnectionString("postgresql://user@host/db?sslmode=require");
|
||||
expect(result).toEqual({
|
||||
db_type: "postgresql",
|
||||
host: "host",
|
||||
port: 5432,
|
||||
username: "user",
|
||||
password: null,
|
||||
database: "db",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for an empty string", () => {
|
||||
expect(parseConnectionString("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a non-URL string", () => {
|
||||
expect(parseConnectionString("hello world")).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults MySQL port to 3306 when omitted", () => {
|
||||
const result = parseConnectionString("mysql://user@host/db");
|
||||
expect(result).toEqual({
|
||||
db_type: "mysql",
|
||||
host: "host",
|
||||
port: 3306,
|
||||
username: "user",
|
||||
password: null,
|
||||
database: "db",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults Redis port to 6379 when omitted", () => {
|
||||
const result = parseConnectionString("redis://localhost");
|
||||
expect(result).toEqual({
|
||||
db_type: "redis",
|
||||
host: "localhost",
|
||||
port: 6379,
|
||||
username: null,
|
||||
password: null,
|
||||
database: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeConnectionString", () => {
|
||||
it("returns true for postgres URL", () => {
|
||||
expect(looksLikeConnectionString("postgresql://a@b/c")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for Redis URL", () => {
|
||||
expect(looksLikeConnectionString("redis://localhost")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for SQLite URL", () => {
|
||||
expect(looksLikeConnectionString("sqlite:///path/to/db.sqlite")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for normal search text", () => {
|
||||
expect(looksLikeConnectionString("production database")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectProviderFromHost", () => {
|
||||
it("detects Supabase by host suffix", () => {
|
||||
expect(detectProviderFromHost("db.abcdefghijklmnopqrst.supabase.co")).toBe("supabase");
|
||||
expect(detectProviderFromHost("aws-us-east-1.pooler.supabase.com")).toBeNull();
|
||||
});
|
||||
it("detects NeonDB by host suffix", () => {
|
||||
expect(detectProviderFromHost("ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tech")).toBe("neon");
|
||||
});
|
||||
it("returns null for plain Postgres hosts", () => {
|
||||
expect(detectProviderFromHost("localhost")).toBeNull();
|
||||
expect(detectProviderFromHost("prod.example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildConnectionUrl", () => {
|
||||
it("builds a PostgreSQL URL with password and sslmode", () => {
|
||||
const url = buildConnectionUrl(
|
||||
makeConn({ db_type: "postgresql", host: "prod.example.com", port: 5432, username: "alice", database: "app", ssl_mode: "require" }),
|
||||
"s3cret",
|
||||
);
|
||||
expect(url).toBe("postgresql://alice:s3cret@prod.example.com:5432/app?sslmode=require");
|
||||
});
|
||||
|
||||
it("omits password when null (no-password variant)", () => {
|
||||
const url = buildConnectionUrl(
|
||||
makeConn({ db_type: "postgresql", host: "prod.example.com", port: 5432, username: "alice", database: "app" }),
|
||||
null,
|
||||
);
|
||||
expect(url).toBe("postgresql://alice@prod.example.com:5432/app");
|
||||
});
|
||||
|
||||
it("omits sslmode when disable or unset", () => {
|
||||
expect(
|
||||
buildConnectionUrl(makeConn({ ssl_mode: "disable" }), null),
|
||||
).toBe("postgresql://user@localhost:5432");
|
||||
expect(
|
||||
buildConnectionUrl(makeConn({ ssl_mode: undefined }), null),
|
||||
).toBe("postgresql://user@localhost:5432");
|
||||
});
|
||||
|
||||
it("percent-encodes special characters in user/password/database", () => {
|
||||
const url = buildConnectionUrl(
|
||||
makeConn({ username: "a b", database: "my db" }),
|
||||
"p@ss w:rd",
|
||||
);
|
||||
expect(url).toBe("postgresql://a%20b:p%40ss%20w%3Ard@localhost:5432/my%20db");
|
||||
});
|
||||
|
||||
it("builds a MySQL URL", () => {
|
||||
const url = buildConnectionUrl(
|
||||
makeConn({ db_type: "mysql", host: "127.0.0.1", port: 3306, username: "root", database: "app" }),
|
||||
"pw",
|
||||
);
|
||||
expect(url).toBe("mysql://root:pw@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" }),
|
||||
"pw",
|
||||
);
|
||||
expect(url).toBe("redis://:pw@localhost:6379/0");
|
||||
});
|
||||
|
||||
it("builds a SQLite URL from the host file path", () => {
|
||||
const url = buildConnectionUrl(
|
||||
makeConn({ db_type: "sqlite", host: "/Users/me/data.db", port: null, username: null, database: null }),
|
||||
null,
|
||||
);
|
||||
expect(url).toBe("sqlite:///Users/me/data.db");
|
||||
});
|
||||
|
||||
it("round-trips through parseConnectionString", () => {
|
||||
const conn = makeConn({ db_type: "postgresql", host: "localhost", port: 5432, username: "user", database: "mydb" });
|
||||
const url = buildConnectionUrl(conn, "pass");
|
||||
expect(parseConnectionString(url)).toEqual({
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
username: "user",
|
||||
password: "pass",
|
||||
database: "mydb",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { Connection, DbType } from "./types";
|
||||
|
||||
export interface ParsedConnectionString {
|
||||
db_type: DbType;
|
||||
host: string;
|
||||
port: number | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
database: string | null;
|
||||
}
|
||||
|
||||
const DB_PROTOCOLS: Record<string, DbType> = {
|
||||
"postgresql:": "postgresql",
|
||||
"postgres:": "postgresql",
|
||||
"mysql:": "mysql",
|
||||
"sqlite:": "sqlite",
|
||||
"file:": "sqlite",
|
||||
"redis:": "redis",
|
||||
"rediss:": "redis",
|
||||
};
|
||||
|
||||
const DEFAULT_PORTS: Record<DbType, number | null> = {
|
||||
postgresql: 5432,
|
||||
mysql: 3306,
|
||||
sqlite: null,
|
||||
redis: 6379,
|
||||
};
|
||||
|
||||
export function parseConnectionString(input: string): ParsedConnectionString | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const db_type = DB_PROTOCOLS[url.protocol];
|
||||
if (!db_type) return null;
|
||||
|
||||
const port = url.port ? Number(url.port) : (DEFAULT_PORTS[db_type] ?? null);
|
||||
const username = url.username || null;
|
||||
const password = url.password || null;
|
||||
const pathname = url.pathname;
|
||||
|
||||
let host: string;
|
||||
let database: string | null;
|
||||
if (db_type === "sqlite") {
|
||||
// SQLite: the file path lives in the URL pathname. Map it to `host`
|
||||
// (the field the backend opens) and leave `database` null. `url.pathname`
|
||||
// always starts with "/"; the fallback covers `sqlite://path` (no
|
||||
// pathname) where the URL's hostname holds the relative path.
|
||||
host = pathname || url.hostname || "";
|
||||
database = null;
|
||||
} else {
|
||||
host = url.hostname || "localhost";
|
||||
database = pathname.replace(/^\//, "") || null;
|
||||
}
|
||||
|
||||
return {
|
||||
db_type,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
database,
|
||||
};
|
||||
}
|
||||
|
||||
export function looksLikeConnectionString(input: string): boolean {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return false;
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
return !!DB_PROTOCOLS[url.protocol];
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const h = host.toLowerCase();
|
||||
if (h.endsWith(".supabase.co")) return "supabase";
|
||||
if (h.endsWith(".neon.tech")) return "neon";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a connection URL string from a saved connection, for copying into
|
||||
* env vars / other tools. `password` is the decrypted keychain value (or
|
||||
* `null` to omit it — e.g. the "no password" copy variant).
|
||||
*
|
||||
* Formats per DB type:
|
||||
* - postgresql: `postgresql://user:pass@host:port/db?sslmode=...`
|
||||
* - mysql: `mysql://user:pass@host:port/db`
|
||||
* - sqlite: `sqlite:///abs/path` (host stores the file path)
|
||||
* - redis: `redis://user:pass@host:port/db`
|
||||
*
|
||||
* Username/password/database are percent-encoded; the ssl_mode is appended as
|
||||
* a query param for PostgreSQL when set to anything other than `disable`.
|
||||
*/
|
||||
export function buildConnectionUrl(conn: Connection, password: string | null): string {
|
||||
const enc = (s: string) => encodeURIComponent(s);
|
||||
const port = conn.port ? `:${conn.port}` : "";
|
||||
const user = conn.username ? enc(conn.username) : "";
|
||||
const auth = password
|
||||
? user
|
||||
? `${user}:${enc(password)}@`
|
||||
: `:${enc(password)}@`
|
||||
: user
|
||||
? `${user}@`
|
||||
: "";
|
||||
const db = conn.database ? `/${enc(conn.database)}` : "";
|
||||
|
||||
switch (conn.db_type) {
|
||||
case "postgresql": {
|
||||
const ssl =
|
||||
conn.ssl_mode && conn.ssl_mode !== "disable"
|
||||
? `?sslmode=${encodeURIComponent(conn.ssl_mode)}`
|
||||
: "";
|
||||
return `postgresql://${auth}${conn.host}${port}${db}${ssl}`;
|
||||
}
|
||||
case "mysql":
|
||||
return `mysql://${auth}${conn.host}${port}${db}`;
|
||||
case "sqlite":
|
||||
// host stores the file path; `sqlite:///abs/path` round-trips through
|
||||
// parseConnectionString (pathname → host).
|
||||
return `sqlite://${conn.host}`;
|
||||
case "redis":
|
||||
return `redis://${auth}${conn.host}${port}${db}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseCsv } from "./csvParser";
|
||||
|
||||
describe("parseCsv", () => {
|
||||
it("parses a simple header + rows", () => {
|
||||
expect(parseCsv("a,b,c\n1,2,3\n4,5,6")).toEqual({
|
||||
headers: ["a", "b", "c"], rows: [["1", "2", "3"], ["4", "5", "6"]],
|
||||
});
|
||||
});
|
||||
|
||||
it("handles quoted fields containing commas and quotes", () => {
|
||||
expect(parseCsv('x,y\n"a,b","c""d"""')).toEqual({
|
||||
headers: ["x", "y"], rows: [["a,b", 'c"d"']],
|
||||
});
|
||||
});
|
||||
|
||||
it("supports CRLF line endings", () => {
|
||||
expect(parseCsv("a,b\r\n1,2\r\n")).toEqual({ headers: ["a", "b"], rows: [["1", "2"]] });
|
||||
});
|
||||
|
||||
it("strips a leading UTF-8 BOM", () => {
|
||||
expect(parseCsv("\uFEFFa,b\n1,2")).toEqual({ headers: ["a", "b"], rows: [["1", "2"]] });
|
||||
});
|
||||
|
||||
it("returns empty rows for header-only input", () => {
|
||||
expect(parseCsv("a,b,c")).toEqual({ headers: ["a", "b", "c"], rows: [] });
|
||||
});
|
||||
|
||||
it("errors on empty input", () => {
|
||||
expect(() => parseCsv("")).toThrow(/empty/i);
|
||||
});
|
||||
|
||||
it("ragged rows pad with empty strings", () => {
|
||||
expect(parseCsv("a,b\n1")).toEqual({ headers: ["a", "b"], rows: [["1", ""]] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
export interface ParsedCsv {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
export function parseCsv(input: string): ParsedCsv {
|
||||
const text = input.replace(/^\uFEFF/, "");
|
||||
if (text.trim() === "") throw new Error("CSV input is empty");
|
||||
|
||||
const rows: string[][] = [];
|
||||
let field = "";
|
||||
let row: string[] = [];
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (text[i + 1] === '"') { field += '"'; i++; }
|
||||
else inQuotes = false;
|
||||
} else field += ch;
|
||||
} else if (ch === '"') {
|
||||
inQuotes = true;
|
||||
} else if (ch === ',') {
|
||||
row.push(field); field = "";
|
||||
} else if (ch === '\n' || ch === '\r') {
|
||||
if (ch === '\r' && text[i + 1] === '\n') i++;
|
||||
row.push(field); field = "";
|
||||
rows.push(row); row = [];
|
||||
} else field += ch;
|
||||
}
|
||||
if (field !== "" || row.length > 0) { row.push(field); rows.push(row); }
|
||||
if (rows.length === 0) throw new Error("CSV input is empty");
|
||||
|
||||
const [headers, ...data] = rows;
|
||||
const width = headers.length;
|
||||
const padded = data.map((r) => {
|
||||
const out = r.slice(0, width);
|
||||
while (out.length < width) out.push("");
|
||||
return out;
|
||||
});
|
||||
return { headers, rows: padded };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { DB_CAPABILITIES, getCapabilities, type DbCapabilities } from "./dbCapabilities";
|
||||
|
||||
describe("dbCapabilities", () => {
|
||||
it("gives PostgreSQL every capability", () => {
|
||||
const c = DB_CAPABILITIES.postgresql;
|
||||
expect(c).toEqual<DbCapabilities>({
|
||||
explorer: true, queries: true, objects: true, visualizer: true,
|
||||
tools: true, editing: true, import: true, ddl: true, objectCrud: true,
|
||||
maintenance: true, roles: true, tableManagement: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("gives MySQL explorer/queries/editing/import/ddl/tools but not objects/visualizer", () => {
|
||||
const c = DB_CAPABILITIES.mysql;
|
||||
expect(c.explorer).toBe(true);
|
||||
expect(c.queries).toBe(true);
|
||||
expect(c.editing).toBe(true);
|
||||
expect(c.import).toBe(true);
|
||||
expect(c.ddl).toBe(true);
|
||||
expect(c.tools).toBe(true);
|
||||
expect(c.objects).toBe(false);
|
||||
expect(c.visualizer).toBe(false);
|
||||
});
|
||||
|
||||
it("gives SQLite explorer/queries/visualizer/editing/import/ddl/tools/tableManagement but not objects", () => {
|
||||
const c = DB_CAPABILITIES.sqlite;
|
||||
expect(c.explorer).toBe(true);
|
||||
expect(c.queries).toBe(true);
|
||||
expect(c.visualizer).toBe(true);
|
||||
expect(c.editing).toBe(true);
|
||||
expect(c.import).toBe(true);
|
||||
expect(c.ddl).toBe(true);
|
||||
expect(c.tools).toBe(true);
|
||||
expect(c.tableManagement).toBe(true);
|
||||
expect(c.objects).toBe(false);
|
||||
});
|
||||
|
||||
it("gives Redis nothing (connection+test only)", () => {
|
||||
expect(DB_CAPABILITIES.redis).toEqual<DbCapabilities>({
|
||||
explorer: false, queries: false, objects: false, visualizer: false,
|
||||
tools: false, editing: false, import: false, ddl: false, objectCrud: false,
|
||||
maintenance: false, roles: false, tableManagement: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("getCapabilities returns all-false for an unknown type", () => {
|
||||
const c = getCapabilities("postgres" as never);
|
||||
expect(Object.values(c).every((v) => v === false)).toBe(true);
|
||||
});
|
||||
|
||||
it("getCapabilities returns the matrix entry for known types", () => {
|
||||
expect(getCapabilities("postgresql")).toBe(DB_CAPABILITIES.postgresql);
|
||||
expect(getCapabilities("redis")).toBe(DB_CAPABILITIES.redis);
|
||||
});
|
||||
|
||||
it("objects capability (search/ddl/dependencies) is PG-only", () => {
|
||||
expect(getCapabilities("postgresql").objects).toBe(true);
|
||||
expect(getCapabilities("mysql").objects).toBe(false);
|
||||
expect(getCapabilities("sqlite").objects).toBe(false);
|
||||
expect(getCapabilities("redis").objects).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("objectCrud capability", () => {
|
||||
it("is true only for postgresql", () => {
|
||||
expect(DB_CAPABILITIES.postgresql.objectCrud).toBe(true);
|
||||
expect(DB_CAPABILITIES.mysql.objectCrud).toBe(false);
|
||||
expect(DB_CAPABILITIES.sqlite.objectCrud).toBe(false);
|
||||
expect(DB_CAPABILITIES.redis.objectCrud).toBe(false);
|
||||
});
|
||||
it("unknown types get objectCrud false", () => {
|
||||
expect(getCapabilities("oracle").objectCrud).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v0.7.7 capabilities", () => {
|
||||
it("postgresql has maintenance, roles, tableManagement", () => {
|
||||
expect(DB_CAPABILITIES.postgresql.maintenance).toBe(true);
|
||||
expect(DB_CAPABILITIES.postgresql.roles).toBe(true);
|
||||
expect(DB_CAPABILITIES.postgresql.tableManagement).toBe(true);
|
||||
});
|
||||
it("mysql/sqlite/redis disable maintenance and roles; only sqlite also gets tableManagement", () => {
|
||||
for (const t of ["mysql", "sqlite", "redis"] as const) {
|
||||
expect(DB_CAPABILITIES[t].maintenance).toBe(false);
|
||||
expect(DB_CAPABILITIES[t].roles).toBe(false);
|
||||
}
|
||||
expect(DB_CAPABILITIES.sqlite.tableManagement).toBe(true);
|
||||
expect(DB_CAPABILITIES.mysql.tableManagement).toBe(false);
|
||||
expect(DB_CAPABILITIES.redis.tableManagement).toBe(false);
|
||||
});
|
||||
it("getCapabilities is safe for unknown types", () => {
|
||||
expect(getCapabilities("bogus").maintenance).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dbCapabilities v0.7.8", () => {
|
||||
it("enables tools for mysql and sqlite", () => {
|
||||
expect(DB_CAPABILITIES.mysql.tools).toBe(true);
|
||||
expect(DB_CAPABILITIES.sqlite.tools).toBe(true);
|
||||
expect(DB_CAPABILITIES.postgresql.tools).toBe(true);
|
||||
});
|
||||
|
||||
it("enables tableManagement for sqlite", () => {
|
||||
expect(DB_CAPABILITIES.sqlite.tableManagement).toBe(true);
|
||||
expect(DB_CAPABILITIES.mysql.tableManagement).toBe(false);
|
||||
});
|
||||
|
||||
it("still reports editing/import/ddl for mysql and sqlite", () => {
|
||||
expect(DB_CAPABILITIES.mysql.editing).toBe(true);
|
||||
expect(DB_CAPABILITIES.mysql.import).toBe(true);
|
||||
expect(DB_CAPABILITIES.mysql.ddl).toBe(true);
|
||||
expect(DB_CAPABILITIES.sqlite.editing).toBe(true);
|
||||
expect(DB_CAPABILITIES.sqlite.objects).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { DbType } from "./types";
|
||||
|
||||
export interface DbCapabilities {
|
||||
/** Table browser + data grid. */
|
||||
explorer: boolean;
|
||||
/** SQL editor + history/saved. */
|
||||
queries: boolean;
|
||||
/** Functions/triggers/sequences/enums/extensions. */
|
||||
objects: boolean;
|
||||
/** ER schema diagram. */
|
||||
visualizer: boolean;
|
||||
/** Backup / restore / sync. */
|
||||
tools: boolean;
|
||||
/** Inline cell edits + changes queue. */
|
||||
editing: boolean;
|
||||
/** CSV/JSON import. */
|
||||
import: boolean;
|
||||
/** Copy table schema (DDL). */
|
||||
ddl: boolean;
|
||||
/** Create / edit / drop PostgreSQL objects via the changes queue. */
|
||||
objectCrud: boolean;
|
||||
/** Table maintenance (VACUUM/ANALYZE/REINDEX) + rebuild-table. */
|
||||
maintenance: boolean;
|
||||
/** Role browsing/management. */
|
||||
roles: boolean;
|
||||
/** Rebuild-table (rewrite in place) support. */
|
||||
tableManagement: boolean;
|
||||
}
|
||||
|
||||
const ALL_FALSE: DbCapabilities = {
|
||||
explorer: false, queries: false, objects: false, visualizer: false,
|
||||
tools: false, editing: false, import: false, ddl: false, objectCrud: false,
|
||||
maintenance: false, roles: false, tableManagement: false,
|
||||
};
|
||||
|
||||
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 },
|
||||
sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true, tools: true, tableManagement: true },
|
||||
redis: { ...ALL_FALSE },
|
||||
};
|
||||
|
||||
/** Safe accessor — unknown types get the all-false capability set. */
|
||||
export function getCapabilities(dbType: string): DbCapabilities {
|
||||
return (DB_CAPABILITIES as Record<string, DbCapabilities>)[dbType] ?? ALL_FALSE;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { siPostgresql, siMysql, siSqlite, siRedis, siSupabase, siNeon } 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}`,
|
||||
redis: `#${siRedis.hex}`,
|
||||
sqlite: `#${siSqlite.hex}`,
|
||||
};
|
||||
|
||||
// SVG path data for each DB icon
|
||||
const DB_PATHS: Record<DbType, string> = {
|
||||
postgresql: siPostgresql.path,
|
||||
mysql: siMysql.path,
|
||||
redis: siRedis.path,
|
||||
sqlite: siSqlite.path,
|
||||
};
|
||||
|
||||
export const DB_LABELS: Record<DbType, string> = {
|
||||
postgresql: "PostgreSQL",
|
||||
mysql: "MySQL",
|
||||
redis: "Redis",
|
||||
sqlite: "SQLite",
|
||||
};
|
||||
|
||||
interface DbIconProps {
|
||||
type: DbType;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DbIcon({ type, size = 20, className }: DbIconProps) {
|
||||
const path = DB_PATHS[type];
|
||||
const color = DB_COLORS[type];
|
||||
if (!path) return <span className="text-lg">❓</span>;
|
||||
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
fill={color}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d={path} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Keep backward compat for any legacy emoji usage
|
||||
export const DB_ICONS: Record<DbType, string> = {
|
||||
postgresql: "🐘",
|
||||
mysql: "🐬",
|
||||
redis: "⚡",
|
||||
sqlite: "🗄️",
|
||||
};
|
||||
|
||||
// ── Managed-PostgreSQL provider icons (Supabase, NeonDB) ──────────
|
||||
// These are NOT DbType values; connections persist as db_type="postgresql".
|
||||
type ProviderIconId = "supabase" | "neon";
|
||||
|
||||
const PROVIDER_ICON_DATA: Record<ProviderIconId, { hex: string; path: string }> = {
|
||||
supabase: { hex: `#${siSupabase.hex}`, path: siSupabase.path },
|
||||
neon: { hex: `#${siNeon.hex}`, path: siNeon.path },
|
||||
};
|
||||
|
||||
export const PROVIDER_LABELS: Record<ProviderIconId, string> = {
|
||||
supabase: "Supabase",
|
||||
neon: "NeonDB",
|
||||
};
|
||||
|
||||
interface ProviderIconProps {
|
||||
id: ProviderIconId;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProviderIcon({ id, size = 20, className }: ProviderIconProps) {
|
||||
const data = PROVIDER_ICON_DATA[id];
|
||||
if (!data) return <span className="text-lg">❓</span>;
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
fill={data.hex}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d={data.path} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
// Import the docs as raw strings (vite/client declares `*?raw`); this keeps the
|
||||
// test free of a `node:fs` dependency so `tsc` (bun run build) stays clean.
|
||||
import agents from "../../../AGENTS.md?raw";
|
||||
import readme from "../../../README.md?raw";
|
||||
|
||||
describe("v0.7.10 docs coverage", () => {
|
||||
it("AGENTS.md marks inline cell editing complete", () => {
|
||||
expect(agents).toContain("Inline cell editing");
|
||||
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
||||
});
|
||||
it("AGENTS.md marks indexes + constraints complete", () => {
|
||||
expect(agents).toMatch(/Indexes \(per table\) \| ✅/);
|
||||
expect(agents).toMatch(/Constraints \(CHECK, UNIQUE beyond PK\/FK\) \| ✅/);
|
||||
});
|
||||
it("AGENTS.md marks materialized views complete", () => {
|
||||
expect(agents).toMatch(/Materialized views \| ✅/);
|
||||
});
|
||||
it("AGENTS.md marks stored procedures complete", () => {
|
||||
expect(agents).toMatch(/Stored procedures \| ✅/);
|
||||
});
|
||||
it("AGENTS.md marks favorites + recents + status indicator complete", () => {
|
||||
expect(agents).toMatch(/Favorites \/ Recent connections \| ✅/);
|
||||
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
||||
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
||||
});
|
||||
it("README declares v0.7.10", () => {
|
||||
expect(readme).toContain("0.7.10");
|
||||
});
|
||||
it("AGENTS.md marks schema CRUD complete", () => {
|
||||
expect(agents).toMatch(/Schema CRUD \| ✅/);
|
||||
});
|
||||
it("AGENTS.md marks global object search complete", () => {
|
||||
expect(agents).toMatch(/Global object search \| ✅/);
|
||||
});
|
||||
it("AGENTS.md marks copy-as-DDL complete", () => {
|
||||
expect(agents).toMatch(/Copy as DDL for any object \| ✅/);
|
||||
});
|
||||
it("AGENTS.md marks object dependencies complete", () => {
|
||||
expect(agents).toMatch(/Object dependencies \| ✅/);
|
||||
});
|
||||
it("README notes bundled PostgreSQL tools", () => {
|
||||
expect(readme.toLowerCase()).toContain("bundled");
|
||||
});
|
||||
it("README marks inline editing complete (not Upcoming)", () => {
|
||||
// Key Features lists inline editing as a shipped feature
|
||||
expect(readme).toMatch(/- \*\*Inline cell editing\*\* —/);
|
||||
// comparison-table row for the stage→commit queue carries the Gridline ✅ marker
|
||||
// (5-column table: Feature | DB Pro | Beekeeper | TablePlus | Gridline)
|
||||
expect(readme).toMatch(/Changes queue \(stage → commit\)\s*\|[^|]*❌[^|]*\|[^|]*\|[^|]*\|\s*\*\*✅ Queue → Commit All\*\*/);
|
||||
expect(readme).not.toMatch(/Inline cell editing.*Upcoming/);
|
||||
});
|
||||
it("AGENTS.md marks object management CRUD complete", () => {
|
||||
expect(agents).toMatch(/Object management CRUD \| ✅/);
|
||||
});
|
||||
it("AGENTS.md marks the keychain toggle complete", () => {
|
||||
expect(agents).toMatch(/Enable keychain toggle \| ✅/);
|
||||
});
|
||||
it("AGENTS.md marks the Objects view tabbed workspace complete", () => {
|
||||
expect(agents).toMatch(/Objects view tabbed workspace \| ✅/);
|
||||
});
|
||||
it("AGENTS.md marks xlsx/SQLite-dump/cancel/settings-import complete", () => {
|
||||
expect(agents).toMatch(/Excel \(\.xlsx\) export \| ✅/);
|
||||
expect(agents).toMatch(/Cancel long-running queries \| ✅/);
|
||||
expect(agents).toMatch(/Settings export\/import \| ✅/);
|
||||
});
|
||||
it("README links to v0.7.10 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.7.10/");
|
||||
expect(readme).toContain("Gridline_0.7.10_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.7.10-1.x86_64.rpm");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export type Environment = "production" | "staging" | "development" | null;
|
||||
|
||||
export const ENV_LABELS: Record<string, string> = {
|
||||
production: "Production",
|
||||
staging: "Staging",
|
||||
development: "Development",
|
||||
};
|
||||
|
||||
export const ENV_COLORS: Record<string, string> = {
|
||||
production: "bg-red-500/20 text-red-400 border-red-500/30",
|
||||
staging: "bg-amber-500/20 text-amber-400 border-amber-500/30",
|
||||
development: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { exportData } from "./exportData";
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
const columns: ColumnInfo[] = [
|
||||
{ name: "id", data_type: "int", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
{ name: "v", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
|
||||
describe("exportData", () => {
|
||||
it("csv quotes cells and escapes quotes", () => {
|
||||
const create = vi.spyOn(document, "createElement");
|
||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
|
||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
const click = vi.fn();
|
||||
create.mockReturnValue({ click } as unknown as HTMLAnchorElement);
|
||||
exportData([[1, "a"], [2, 'b"c']], columns, "csv", "t");
|
||||
expect(click).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("json serializes rows as objects", () => {
|
||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
|
||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
const click = vi.fn();
|
||||
vi.spyOn(document, "createElement").mockReturnValue({ click } as unknown as HTMLAnchorElement);
|
||||
exportData([[1, "a"]], columns, "json", "t");
|
||||
expect(click).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("xlsx produces a Blob with the xlsx MIME and extension download", () => {
|
||||
const origCreateObjectURL = URL.createObjectURL;
|
||||
const origRevokeObjectURL = URL.revokeObjectURL;
|
||||
globalThis.URL.createObjectURL = vi.fn(() => "blob:x") as any;
|
||||
globalThis.URL.revokeObjectURL = vi.fn() as any;
|
||||
const a = { click: vi.fn(), href: "", download: "" };
|
||||
vi.spyOn(document, "createElement").mockReturnValue(a as any);
|
||||
const rows = [[1]];
|
||||
const cols: ColumnInfo[] = [
|
||||
{ name: "id", data_type: "integer", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
try {
|
||||
exportData(rows, cols, "xlsx", "t");
|
||||
expect(a.download).toBe("t.xlsx");
|
||||
expect((URL.createObjectURL as any).mock.calls[0][0] instanceof Blob).toBe(true);
|
||||
expect((URL.createObjectURL as any).mock.calls[0][0].type).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
} finally {
|
||||
globalThis.URL.createObjectURL = origCreateObjectURL;
|
||||
globalThis.URL.revokeObjectURL = origRevokeObjectURL;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { buildXlsx } from "./xlsx";
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
export function exportData(
|
||||
rows: unknown[][],
|
||||
columns: ColumnInfo[],
|
||||
format: string,
|
||||
tableName: string,
|
||||
) {
|
||||
const headers = columns.map((c) => c.name);
|
||||
let content: string;
|
||||
let mime: string;
|
||||
|
||||
switch (format) {
|
||||
case "xlsx": {
|
||||
const bytes = buildXlsx(rows, columns);
|
||||
const blob = new Blob([bytes], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${tableName}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
case "json": {
|
||||
const jsonRows = rows.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
columns.forEach((c, i) => { obj[c.name] = row[i] ?? null; });
|
||||
return obj;
|
||||
});
|
||||
content = JSON.stringify(jsonRows, null, 2);
|
||||
mime = "application/json";
|
||||
break;
|
||||
}
|
||||
case "csv": {
|
||||
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
|
||||
for (const row of rows) {
|
||||
csvRows.push(
|
||||
row.map((cell) => {
|
||||
const s = cell === null || cell === undefined ? "" : String(cell);
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}).join(","),
|
||||
);
|
||||
}
|
||||
content = csvRows.join("\n");
|
||||
mime = "text/csv";
|
||||
break;
|
||||
}
|
||||
case "sql": {
|
||||
const lines = [`-- ${tableName}`];
|
||||
for (const row of rows) {
|
||||
const vals = row.map((cell) =>
|
||||
cell === null ? "NULL"
|
||||
: typeof cell === "number" ? String(cell)
|
||||
: `'${String(cell).replace(/'/g, "''")}'`,
|
||||
);
|
||||
lines.push(`INSERT INTO ${tableName} (${headers.join(", ")}) VALUES (${vals.join(", ")});`);
|
||||
}
|
||||
content = lines.join("\n");
|
||||
mime = "application/sql";
|
||||
break;
|
||||
}
|
||||
case "md": {
|
||||
const mdRows = [`| ${headers.join(" | ")} |`, `| ${headers.map(() => "---").join(" | ")} |`];
|
||||
for (const row of rows) {
|
||||
mdRows.push(`| ${row.map((cell) => cell === null ? "*NULL*" : String(cell)).join(" | ")} |`);
|
||||
}
|
||||
content = mdRows.join("\n");
|
||||
mime = "text/markdown";
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${tableName}.${format === "md" ? "md" : format}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { handleImport, handleExport } from "./importExport";
|
||||
import * as commands from "./commands";
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: vi.fn(),
|
||||
save: vi.fn(),
|
||||
}));
|
||||
vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("handleImport", () => {
|
||||
it("returns null when user cancels (no file selected)", async () => {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
(open as any).mockResolvedValue(null);
|
||||
const result = await handleImport();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
it("imports and reloads when a file is selected", async () => {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
(open as any).mockResolvedValue("/path/to/connections.json");
|
||||
(readTextFile as any).mockResolvedValue(
|
||||
'[{"name":"A","db_type":"postgresql","host":"h","port":5432}]',
|
||||
);
|
||||
const spy = vi
|
||||
.spyOn(commands, "importConnections")
|
||||
.mockResolvedValue({ imported: 1, skipped: 0, skippedRecords: [] });
|
||||
const result = await handleImport();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(result?.imported).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleExport", () => {
|
||||
it("returns null when user cancels", async () => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
(save as any).mockResolvedValue(null);
|
||||
const result = await handleExport();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
it("exports to selected path", async () => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
(save as any).mockResolvedValue("/path/out.json");
|
||||
(writeTextFile as any).mockResolvedValue(undefined);
|
||||
vi.spyOn(commands, "exportConnections").mockResolvedValue(
|
||||
'{"version":1,"connections":[]}',
|
||||
);
|
||||
const result = await handleExport();
|
||||
expect(result).toBe("/path/out.json");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { importConnections, exportConnections } from "./commands";
|
||||
import type { ImportResult } from "./types";
|
||||
|
||||
export async function handleImport(): Promise<ImportResult | null> {
|
||||
const filePath = await open({
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
});
|
||||
if (!filePath) return null;
|
||||
const json = await readTextFile(filePath);
|
||||
return importConnections(json);
|
||||
}
|
||||
|
||||
export async function handleExport(): Promise<string | null> {
|
||||
const filePath = await save({
|
||||
defaultPath: "connections.json",
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
});
|
||||
if (!filePath) return null;
|
||||
const json = await exportConnections();
|
||||
await writeTextFile(filePath, json);
|
||||
return filePath;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { normalizeImport, coerceRow } from "./importNormalize";
|
||||
|
||||
describe("normalizeImport", () => {
|
||||
it("parses CSV input", () => {
|
||||
expect(normalizeImport("a,b\n1,2\n3,4")).toEqual({
|
||||
headers: ["a", "b"],
|
||||
rows: [
|
||||
["1", "2"],
|
||||
["3", "4"],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a JSON array of objects", () => {
|
||||
expect(normalizeImport('[{"a":"1","b":"2"},{"a":"3","b":"4"}]')).toEqual({
|
||||
headers: ["a", "b"],
|
||||
rows: [
|
||||
["1", "2"],
|
||||
["3", "4"],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a JSON object whose sole value is an array", () => {
|
||||
expect(normalizeImport('{"data":[{"x":"10","y":"20"},{"x":"30","y":"40"}]}')).toEqual({
|
||||
headers: ["x", "y"],
|
||||
rows: [
|
||||
["10", "20"],
|
||||
["30", "40"],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on empty input", () => {
|
||||
expect(() => normalizeImport("")).toThrow(/empty/i);
|
||||
expect(() => normalizeImport(" ")).toThrow(/empty/i);
|
||||
});
|
||||
|
||||
it("throws on invalid JSON", () => {
|
||||
expect(() => normalizeImport('{"a":')).toThrow(/invalid json/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coerceRow", () => {
|
||||
it("converts empty strings to null", () => {
|
||||
expect(coerceRow("")).toBeNull();
|
||||
});
|
||||
|
||||
it("converts boolean literals", () => {
|
||||
expect(coerceRow("true")).toBe(true);
|
||||
expect(coerceRow("false")).toBe(false);
|
||||
});
|
||||
|
||||
it("converts numeric strings to numbers", () => {
|
||||
expect(coerceRow("42")).toBe(42);
|
||||
expect(coerceRow("4.5")).toBe(4.5);
|
||||
expect(coerceRow("-7")).toBe(-7);
|
||||
});
|
||||
|
||||
it("keeps other values as strings", () => {
|
||||
expect(coerceRow("abc")).toBe("abc");
|
||||
expect(coerceRow("12abc")).toBe("12abc");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { parseCsv } from "./csvParser";
|
||||
|
||||
export interface NormalizedImport {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
export function normalizeImport(text: string): NormalizedImport {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === "") throw new Error("Import input is empty");
|
||||
|
||||
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new Error("Invalid JSON");
|
||||
}
|
||||
|
||||
let rows: Record<string, unknown>[];
|
||||
if (Array.isArray(parsed)) {
|
||||
rows = parsed as Record<string, unknown>[];
|
||||
} else if (parsed && typeof parsed === "object") {
|
||||
const values = Object.values(parsed);
|
||||
const arrays = values.filter((v): v is Record<string, unknown>[] => Array.isArray(v));
|
||||
if (arrays.length === 1 && values.length === 1) {
|
||||
rows = arrays[0];
|
||||
} else {
|
||||
rows = [parsed as Record<string, unknown>];
|
||||
}
|
||||
} else {
|
||||
throw new Error("JSON import must be an array of objects or an object");
|
||||
}
|
||||
|
||||
if (rows.length === 0) return { headers: [], rows: [] };
|
||||
|
||||
const headers = Object.keys(rows[0] ?? {});
|
||||
const data = rows.map((r) => headers.map((h) => String((r as Record<string, unknown>)[h] ?? "")));
|
||||
return { headers, rows: data };
|
||||
}
|
||||
|
||||
return parseCsv(text);
|
||||
}
|
||||
|
||||
export function coerceRow(v: string): unknown {
|
||||
if (v === "") return null;
|
||||
if (v === "true") return true;
|
||||
if (v === "false") return false;
|
||||
if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
|
||||
return v;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { saveConnectionPassword, deleteConnectionPassword } from "./commands";
|
||||
|
||||
// Persist (or purge) the DB password according to the keychain toggle.
|
||||
// use_keychain defaults to true (opt-out): ON = OS keychain; OFF = purge + session-only.
|
||||
// Shared by connectionStore.createConnection and EditConnectionModal.
|
||||
export async function persistDbPassword(
|
||||
connectionId: string,
|
||||
useKeychain: boolean | undefined,
|
||||
password: string | null | undefined,
|
||||
): Promise<void> {
|
||||
const useKc = useKeychain ?? true;
|
||||
if (useKc && password) {
|
||||
await saveConnectionPassword(connectionId, password);
|
||||
} else if (!useKc) {
|
||||
try { await deleteConnectionPassword(connectionId); } catch { /* purge; ignore missing */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Local Monaco setup — bundles monaco-editor with the app so no CDN is
|
||||
* needed and the editor works fully offline.
|
||||
*
|
||||
* Must be imported once, before any @monaco-editor/react <Editor /> mounts.
|
||||
*/
|
||||
import * as monaco from "monaco-editor";
|
||||
import { loader } from "@monaco-editor/react";
|
||||
import EditorWorker from "monaco-editor/editor/editor.worker?worker";
|
||||
import { useDbViewerStore } from "../stores/dbViewerStore";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
import {
|
||||
buildSqlSuggestions,
|
||||
buildColumnSuggestions,
|
||||
getColumnsForTable,
|
||||
getCachedColumns,
|
||||
parseTableRef,
|
||||
} from "./sqlCompletion";
|
||||
|
||||
// Use the bundled editor worker (SQL has no dedicated language worker)
|
||||
self.MonacoEnvironment = {
|
||||
getWorker: () => new EditorWorker(),
|
||||
};
|
||||
|
||||
// Hand the already-imported monaco instance to @monaco-editor/react so it
|
||||
// skips its CDN download entirely.
|
||||
loader.config({ monaco });
|
||||
|
||||
// SQL autocomplete:
|
||||
// - after `table.` (or `schema.table.`): suggest that table's columns
|
||||
// (fetched lazily via schema introspection and cached per schema)
|
||||
// - otherwise: keywords + table names from the active schema
|
||||
monaco.languages.registerCompletionItemProvider("sql", {
|
||||
provideCompletionItems: (
|
||||
model,
|
||||
position,
|
||||
): monaco.languages.CompletionList | Promise<monaco.languages.CompletionList> => {
|
||||
const { tables, currentSchema } = useDbViewerStore.getState();
|
||||
const line = model.getLineContent(position.lineNumber);
|
||||
const before = line.slice(0, position.column - 1);
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = new monaco.Range(
|
||||
position.lineNumber,
|
||||
word.startColumn,
|
||||
position.lineNumber,
|
||||
word.endColumn,
|
||||
);
|
||||
const withRange = (s: { label: string; insertText: string; kind: string }) => ({
|
||||
label: s.label,
|
||||
insertText: s.insertText,
|
||||
kind:
|
||||
s.kind === "keyword"
|
||||
? monaco.languages.CompletionItemKind.Keyword
|
||||
: s.kind === "table"
|
||||
? monaco.languages.CompletionItemKind.Struct
|
||||
: monaco.languages.CompletionItemKind.Field,
|
||||
range,
|
||||
});
|
||||
|
||||
const tableRef = parseTableRef(before);
|
||||
if (tableRef) {
|
||||
const schema = tableRef.schema ?? currentSchema;
|
||||
const connectionId = useUiStore.getState().activeConnectionId;
|
||||
const cached = schema ? getCachedColumns(schema, tableRef.table) : undefined;
|
||||
if (cached) {
|
||||
return {
|
||||
suggestions: buildColumnSuggestions(cached).map(withRange),
|
||||
};
|
||||
}
|
||||
// Not introspected yet: warm the cache in the background and ask Monaco
|
||||
// to re-request once the columns are available.
|
||||
void getColumnsForTable(connectionId, schema, tableRef.table);
|
||||
return { suggestions: [], incomplete: true };
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: buildSqlSuggestions(tables, currentSchema).map(withRange),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,491 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
buildObjectDdl,
|
||||
dropCrudParams,
|
||||
getAvailableExtensions,
|
||||
initialCrudParams,
|
||||
} from "./objectCrud";
|
||||
import type { CrudItem } from "./objectCrud";
|
||||
|
||||
describe("buildObjectDdl", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("invokes build_object_ddl with connectionId, kind, params", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([
|
||||
"CREATE TYPE public.role AS ENUM ('admin')",
|
||||
]);
|
||||
const sql = await buildObjectDdl("c1", "enum", {
|
||||
schema: "public",
|
||||
name: "role",
|
||||
action: { op: "create", labels: ["admin"] },
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith("build_object_ddl", {
|
||||
connectionId: "c1",
|
||||
kind: "enum",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "role",
|
||||
action: { op: "create", labels: ["admin"] },
|
||||
},
|
||||
});
|
||||
expect(sql).toEqual(["CREATE TYPE public.role AS ENUM ('admin')"]);
|
||||
});
|
||||
|
||||
it("passes through drop params for sequences", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce(['DROP SEQUENCE "public"."s"']);
|
||||
const sql = await buildObjectDdl("c1", "sequence", {
|
||||
schema: "public",
|
||||
name: "s",
|
||||
action: { op: "drop" },
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith("build_object_ddl", {
|
||||
connectionId: "c1",
|
||||
kind: "sequence",
|
||||
params: { schema: "public", name: "s", action: { op: "drop" } },
|
||||
});
|
||||
expect(sql).toEqual(['DROP SEQUENCE "public"."s"']);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailableExtensions", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("invokes get_available_extensions with connectionId", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([
|
||||
{ name: "pgcrypto", version: "1.3", comment: null },
|
||||
]);
|
||||
const exts = await getAvailableExtensions("c1");
|
||||
expect(invoke).toHaveBeenCalledWith("get_available_extensions", {
|
||||
connectionId: "c1",
|
||||
});
|
||||
expect(exts).toEqual([{ name: "pgcrypto", version: "1.3", comment: null }]);
|
||||
});
|
||||
|
||||
it("returns empty array when no extensions available", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||
const exts = await getAvailableExtensions("c2");
|
||||
expect(exts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initialCrudParams", () => {
|
||||
it("create shape starts empty with sensible defaults", () => {
|
||||
const p = initialCrudParams(
|
||||
"sequence",
|
||||
{ schema: "public", name: "" },
|
||||
"create",
|
||||
);
|
||||
expect(p).toEqual({
|
||||
schema: "public",
|
||||
name: "",
|
||||
action: {
|
||||
op: "create",
|
||||
increment: "1",
|
||||
min_value: "1",
|
||||
max_value: "9223372036854775807",
|
||||
start: "1",
|
||||
cycle: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("edit prefills the name from the item", () => {
|
||||
const p = initialCrudParams(
|
||||
"sequence",
|
||||
{ schema: "public", name: "s" },
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
schema: "public",
|
||||
name: "s",
|
||||
action: { op: "create", increment: "1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("enum edit carries labels through", () => {
|
||||
const p = initialCrudParams(
|
||||
"enum",
|
||||
{ schema: "public", name: "role", labels: ["admin"] },
|
||||
"edit",
|
||||
);
|
||||
expect(p.action).toMatchObject({ op: "create", labels: ["admin"] });
|
||||
});
|
||||
|
||||
it("index create keeps the owning table and an empty column list", () => {
|
||||
const p = initialCrudParams(
|
||||
"index",
|
||||
{ schema: "public", table: "users", name: "" },
|
||||
"create",
|
||||
);
|
||||
expect(p).toEqual({
|
||||
schema: "public",
|
||||
table: "users",
|
||||
name: "",
|
||||
action: { op: "create", unique: false, method: "", columns: [], predicate: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("constraint create starts as a CHECK on the owning table", () => {
|
||||
const p = initialCrudParams(
|
||||
"constraint",
|
||||
{ schema: "public", table: "users", name: "" },
|
||||
"create",
|
||||
);
|
||||
expect(p).toEqual({
|
||||
schema: "public",
|
||||
table: "users",
|
||||
name: "",
|
||||
action: { op: "check", expression: "" },
|
||||
});
|
||||
});
|
||||
|
||||
it("procedure edit prefills is_procedure from kind", () => {
|
||||
const p = initialCrudParams(
|
||||
"procedure",
|
||||
{ schema: "public", name: "do_thing" },
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
schema: "public",
|
||||
name: "do_thing",
|
||||
is_procedure: true,
|
||||
action: { op: "create_or_replace", args: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it("view edit prefills the definition and marks non-materialized", () => {
|
||||
const p = initialCrudParams(
|
||||
"view",
|
||||
{ schema: "public", name: "v", definition: "SELECT 1" },
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
schema: "public",
|
||||
name: "v",
|
||||
materialized: false,
|
||||
action: { op: "create", definition: "SELECT 1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("function edit prefills body, zipped args, return_type, and language", () => {
|
||||
const p = initialCrudParams(
|
||||
"function",
|
||||
{
|
||||
schema: "public",
|
||||
name: "add_one",
|
||||
return_type: "integer",
|
||||
argument_types: ["integer"],
|
||||
argument_names: ["x"],
|
||||
argument_modes: ["IN"],
|
||||
language: "plpgsql",
|
||||
source: "BEGIN RETURN x + 1; END",
|
||||
kind: "f",
|
||||
},
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
schema: "public",
|
||||
name: "add_one",
|
||||
is_procedure: false,
|
||||
action: {
|
||||
op: "create_or_replace",
|
||||
args: [{ mode: "IN", name: "x", type: "integer" }],
|
||||
return_type: "integer",
|
||||
language: "plpgsql",
|
||||
body: "BEGIN RETURN x + 1; END",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("procedure edit prefills source and zips multiple args", () => {
|
||||
const p = initialCrudParams(
|
||||
"procedure",
|
||||
{
|
||||
schema: "public",
|
||||
name: "do_thing",
|
||||
return_type: "void",
|
||||
argument_types: ["int", "text"],
|
||||
argument_names: ["a", "b"],
|
||||
argument_modes: ["IN", "OUT"],
|
||||
language: "plpgsql",
|
||||
source: "BEGIN PERFORM a; END",
|
||||
kind: "p",
|
||||
},
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
is_procedure: true,
|
||||
action: {
|
||||
op: "create_or_replace",
|
||||
args: [
|
||||
{ mode: "IN", name: "a", type: "int" },
|
||||
{ mode: "OUT", name: "b", type: "text" },
|
||||
],
|
||||
return_type: "void",
|
||||
body: "BEGIN PERFORM a; END",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("function create keeps empty args/body and the default language", () => {
|
||||
const p = initialCrudParams(
|
||||
"function",
|
||||
{ schema: "public", name: "add_one" },
|
||||
"create",
|
||||
);
|
||||
expect(p.action).toMatchObject({
|
||||
op: "create_or_replace",
|
||||
args: [],
|
||||
return_type: null,
|
||||
language: "plpgsql",
|
||||
body: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("sequence edit prefills increment/min/max/start/cycle from the item", () => {
|
||||
const p = initialCrudParams(
|
||||
"sequence",
|
||||
{
|
||||
schema: "public",
|
||||
name: "s",
|
||||
start_value: "5",
|
||||
min_value: "1",
|
||||
max_value: "999",
|
||||
increment: "2",
|
||||
cycle: true,
|
||||
},
|
||||
"edit",
|
||||
);
|
||||
expect(p.action).toMatchObject({
|
||||
op: "create",
|
||||
increment: "2",
|
||||
min_value: "1",
|
||||
max_value: "999",
|
||||
start: "5",
|
||||
cycle: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("trigger edit prefills table, timing, events, and orientation", () => {
|
||||
const p = initialCrudParams(
|
||||
"trigger",
|
||||
{
|
||||
schema: "public",
|
||||
name: "trg",
|
||||
table_name: "users",
|
||||
event_manipulation: "INSERT",
|
||||
action_timing: "AFTER",
|
||||
action_orientation: "row",
|
||||
},
|
||||
"edit",
|
||||
);
|
||||
expect(p.action).toMatchObject({
|
||||
op: "create",
|
||||
table: "users",
|
||||
timing: "AFTER",
|
||||
events: ["INSERT"],
|
||||
orientation: "ROW",
|
||||
});
|
||||
});
|
||||
|
||||
it("trigger edit splits OR-joined event_manipulation into separate events", () => {
|
||||
const p = initialCrudParams(
|
||||
"trigger",
|
||||
{
|
||||
schema: "public",
|
||||
name: "trg",
|
||||
table_name: "orders",
|
||||
event_manipulation: "INSERT OR UPDATE",
|
||||
action_timing: "BEFORE",
|
||||
action_orientation: "STATEMENT",
|
||||
},
|
||||
"edit",
|
||||
);
|
||||
expect(p.action).toMatchObject({
|
||||
events: ["INSERT", "UPDATE"],
|
||||
orientation: "STATEMENT",
|
||||
});
|
||||
});
|
||||
|
||||
it("index edit prefills name, unique, method, and columns", () => {
|
||||
const p = initialCrudParams(
|
||||
"index",
|
||||
{
|
||||
schema: "public",
|
||||
table: "users",
|
||||
name: "idx_users_email",
|
||||
is_unique: true,
|
||||
method: "btree",
|
||||
columns: ["email"],
|
||||
},
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
schema: "public",
|
||||
table: "users",
|
||||
name: "idx_users_email",
|
||||
action: {
|
||||
op: "create",
|
||||
unique: true,
|
||||
method: "btree",
|
||||
columns: ["email"],
|
||||
predicate: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("constraint edit maps CHECK contype to a check action with the definition", () => {
|
||||
const p = initialCrudParams(
|
||||
"constraint",
|
||||
{
|
||||
schema: "public",
|
||||
table: "users",
|
||||
name: "chk_age",
|
||||
contype: "CHECK",
|
||||
definition: "CHECK (age > 0)",
|
||||
columns: ["age"],
|
||||
},
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
schema: "public",
|
||||
table: "users",
|
||||
name: "chk_age",
|
||||
action: { op: "check", expression: "CHECK (age > 0)" },
|
||||
});
|
||||
});
|
||||
|
||||
it("constraint edit maps UNIQUE contype to a unique action with columns", () => {
|
||||
const p = initialCrudParams(
|
||||
"constraint",
|
||||
{
|
||||
schema: "public",
|
||||
table: "users",
|
||||
name: "uniq_email",
|
||||
contype: "UNIQUE",
|
||||
definition: "UNIQUE (email)",
|
||||
columns: ["email"],
|
||||
},
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
name: "uniq_email",
|
||||
action: { op: "unique", columns: ["email"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("extension edit prefills the version from the item", () => {
|
||||
const p = initialCrudParams(
|
||||
"extension",
|
||||
{ schema: "public", name: "pgcrypto", version: "1.3" },
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
name: "pgcrypto",
|
||||
action: { op: "create", version: "1.3" },
|
||||
});
|
||||
});
|
||||
|
||||
it("view edit marks materialized when the item is a materialized view", () => {
|
||||
const p = initialCrudParams(
|
||||
"view",
|
||||
{
|
||||
schema: "public",
|
||||
name: "mv",
|
||||
table_type: "MATERIALIZED VIEW",
|
||||
definition: "SELECT 1",
|
||||
},
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
name: "mv",
|
||||
materialized: true,
|
||||
action: { op: "create", definition: "SELECT 1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("dropCrudParams", () => {
|
||||
it("plain kinds build a base drop action", () => {
|
||||
expect(
|
||||
dropCrudParams("sequence", { schema: "public", name: "s" }),
|
||||
).toEqual({ schema: "public", name: "s", action: { op: "drop" } });
|
||||
});
|
||||
|
||||
it("index and constraint carry the owning table", () => {
|
||||
expect(
|
||||
dropCrudParams("index", { schema: "public", name: "i", table: "users" }),
|
||||
).toEqual({
|
||||
schema: "public",
|
||||
name: "i",
|
||||
table: "users",
|
||||
action: { op: "drop" },
|
||||
});
|
||||
});
|
||||
|
||||
it("trigger uses table_name as a fallback for the table", () => {
|
||||
expect(
|
||||
dropCrudParams("trigger", {
|
||||
schema: "public",
|
||||
name: "trg",
|
||||
table_name: "users",
|
||||
}),
|
||||
).toEqual({
|
||||
schema: "public",
|
||||
name: "trg",
|
||||
action: { op: "drop", table: "users" },
|
||||
});
|
||||
});
|
||||
|
||||
it("view marks materialized via table_type", () => {
|
||||
expect(
|
||||
dropCrudParams("view", { schema: "public", name: "m", table_type: "m" }),
|
||||
).toMatchObject({ materialized: true, action: { op: "drop" } });
|
||||
});
|
||||
|
||||
it("function carries argument types and is_procedure false", () => {
|
||||
expect(
|
||||
dropCrudParams("function", {
|
||||
schema: "public",
|
||||
name: "f",
|
||||
argument_types: ["int"],
|
||||
}),
|
||||
).toMatchObject({
|
||||
is_procedure: false,
|
||||
action: { op: "drop", arg_types: ["int"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("table/role crud params", () => {
|
||||
it("initialCrudParams(table, create) starts empty with columns", () => {
|
||||
const p = initialCrudParams("table", { schema: "public", name: "" }, "create");
|
||||
expect(p).toEqual({ schema: "public", name: "", action: { op: "create", columns: [] } });
|
||||
});
|
||||
it("initialCrudParams(table, edit) prefills columns", () => {
|
||||
const item: CrudItem = { schema: "public", name: "users", columns: ["id"], columnMeta: [
|
||||
{ name: "id", type: "integer", nullable: false, is_pk: true, default: null },
|
||||
] };
|
||||
const p = initialCrudParams("table", item, "edit");
|
||||
expect((p as any).action.op).toBe("edit");
|
||||
expect((p as any).action.old_columns).toEqual(item.columnMeta);
|
||||
});
|
||||
it("initialCrudParams(role, create) starts empty with options", () => {
|
||||
const p = initialCrudParams("role", { schema: "", name: "" }, "create");
|
||||
expect(p).toEqual({ schema: "", name: "", action: { op: "create", login: false, superuser: false, createdb: false, createrole: false, inherit: true, replication: false, bypassrls: false, connection_limit: -1, valid_until: "", password: "", members: [] } });
|
||||
});
|
||||
it("dropCrudParams(role) drops by name", () => {
|
||||
const p = dropCrudParams("role", { schema: "", name: "app" });
|
||||
expect(p).toEqual({ schema: "", name: "app", action: { op: "drop" } });
|
||||
});
|
||||
it("dropCrudParams(table) drops the table", () => {
|
||||
const p = dropCrudParams("table", { schema: "public", name: "users" });
|
||||
expect(p).toEqual({ schema: "public", name: "users", action: { op: "drop" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
// NOTE on argument key naming: Tauri v2 converts Rust snake_case command
|
||||
// parameter names to camelCase on the IPC boundary (connection_id ->
|
||||
// connectionId). Single-word params (kind, params) are unchanged.
|
||||
|
||||
/// The object kinds supported by the Rust build_ddl dispatcher.
|
||||
export type ObjectKind =
|
||||
| "sequence"
|
||||
| "enum"
|
||||
| "view"
|
||||
| "extension"
|
||||
| "index"
|
||||
| "constraint"
|
||||
| "function"
|
||||
| "procedure"
|
||||
| "trigger"
|
||||
| "table"
|
||||
| "role";
|
||||
|
||||
/// Opaque payload for a build: `{ schema, name, action: { op, ... } }`.
|
||||
/// The concrete shape is validated server-side by each kind's params struct.
|
||||
export type DdlParams = Record<string, unknown>;
|
||||
|
||||
/// A browsable object row — the per-kind detail fields the CRUD helpers read.
|
||||
/// All fields beyond `schema`/`name` are optional per object kind.
|
||||
/// (The extra fields mirror the real per-kind shapes in `src/lib/types.ts`
|
||||
/// and are consumed by `initialCrudParams` on edit.)
|
||||
export interface CrudItem {
|
||||
schema: string;
|
||||
name: string;
|
||||
table?: string;
|
||||
table_name?: string;
|
||||
table_type?: string;
|
||||
materialized?: boolean;
|
||||
labels?: string[];
|
||||
definition?: string;
|
||||
argument_types?: string[];
|
||||
// function / procedure (FunctionInfo)
|
||||
return_type?: string | null;
|
||||
argument_names?: string[];
|
||||
argument_modes?: string[];
|
||||
language?: string;
|
||||
source?: string | null;
|
||||
kind?: string;
|
||||
// sequence (SequenceInfo)
|
||||
start_value?: string;
|
||||
min_value?: string;
|
||||
max_value?: string;
|
||||
increment?: string;
|
||||
cycle?: boolean;
|
||||
// trigger (TriggerInfo)
|
||||
event_manipulation?: string;
|
||||
action_timing?: string;
|
||||
action_orientation?: string;
|
||||
enabled?: string;
|
||||
// extension (ExtensionInfo)
|
||||
version?: string | null;
|
||||
// index (IndexInfo)
|
||||
is_unique?: boolean;
|
||||
method?: string;
|
||||
columns?: string[];
|
||||
// constraint (ConstraintInfo)
|
||||
contype?: "CHECK" | "UNIQUE" | "EXCLUSION";
|
||||
// table (columnMeta carries the snapshot for edit diffs)
|
||||
columnMeta?: { name: string; type: string; nullable: boolean; is_pk: boolean; default: string | null }[];
|
||||
// role (RoleInfo shape used for prefill)
|
||||
superuser?: boolean;
|
||||
inherit?: boolean;
|
||||
create_db?: boolean;
|
||||
create_role?: boolean;
|
||||
can_login?: boolean;
|
||||
replication?: boolean;
|
||||
bypass_rls?: boolean;
|
||||
connection_limit?: number;
|
||||
valid_until?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial dialog params for a create/edit operation.
|
||||
* Create starts empty (schema prefilled); edit prefills from the item.
|
||||
*/
|
||||
export function initialCrudParams(
|
||||
kind: ObjectKind,
|
||||
item: CrudItem,
|
||||
mode: "create" | "edit",
|
||||
): DdlParams {
|
||||
const schema = item.schema;
|
||||
switch (kind) {
|
||||
case "sequence":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: "create",
|
||||
increment: mode === "edit" ? (item.increment ?? "1") : "1",
|
||||
min_value: mode === "edit" ? (item.min_value ?? "1") : "1",
|
||||
max_value:
|
||||
mode === "edit"
|
||||
? (item.max_value ?? "9223372036854775807")
|
||||
: "9223372036854775807",
|
||||
start: mode === "edit" ? (item.start_value ?? "1") : "1",
|
||||
cycle: mode === "edit" ? (item.cycle ?? false) : false,
|
||||
},
|
||||
};
|
||||
case "enum":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: "create",
|
||||
labels: mode === "edit" ? (item.labels ?? []) : [],
|
||||
},
|
||||
};
|
||||
case "extension":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: "create",
|
||||
version: mode === "edit" ? (item.version ?? null) : null,
|
||||
},
|
||||
};
|
||||
case "view":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
materialized:
|
||||
mode === "edit"
|
||||
? item.table_type === "MATERIALIZED VIEW" || item.materialized === true
|
||||
: false,
|
||||
action: {
|
||||
op: "create",
|
||||
definition: mode === "edit" ? (item.definition ?? "") : "",
|
||||
},
|
||||
};
|
||||
case "index":
|
||||
return {
|
||||
schema,
|
||||
table: item.table ?? "",
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: "create",
|
||||
unique: mode === "edit" ? (item.is_unique ?? false) : false,
|
||||
method: mode === "edit" ? (item.method ?? "") : "",
|
||||
columns: mode === "edit" ? (item.columns ?? []) : [],
|
||||
predicate: null,
|
||||
},
|
||||
};
|
||||
case "constraint":
|
||||
return {
|
||||
schema,
|
||||
table: item.table ?? "",
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action:
|
||||
mode === "edit"
|
||||
? item.contype === "UNIQUE"
|
||||
? { op: "unique", columns: item.columns ?? [] }
|
||||
: { op: "check", expression: item.definition ?? "" }
|
||||
: { op: "check", expression: "" },
|
||||
};
|
||||
case "function":
|
||||
case "procedure":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
is_procedure: kind === "procedure",
|
||||
action: {
|
||||
op: "create_or_replace",
|
||||
args:
|
||||
mode === "edit"
|
||||
? (item.argument_names ?? []).map((n, i) => ({
|
||||
mode: item.argument_modes?.[i] ?? "in",
|
||||
name: n,
|
||||
type: item.argument_types?.[i] ?? "",
|
||||
}))
|
||||
: [],
|
||||
return_type: mode === "edit" ? (item.return_type ?? null) : null,
|
||||
language: mode === "edit" ? (item.language ?? "plpgsql") : "plpgsql",
|
||||
body: mode === "edit" ? (item.source ?? "") : "",
|
||||
volatility: null,
|
||||
strict: false,
|
||||
},
|
||||
};
|
||||
case "trigger":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: "create",
|
||||
table: item.table_name ?? "",
|
||||
timing:
|
||||
mode === "edit" ? (item.action_timing ?? "BEFORE") : "BEFORE",
|
||||
events:
|
||||
mode === "edit"
|
||||
? (item.event_manipulation ?? "INSERT")
|
||||
.split(/[,|]|\s+OR\s+/i)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: ["INSERT"],
|
||||
orientation:
|
||||
mode === "edit"
|
||||
? (item.action_orientation ?? "ROW").toUpperCase()
|
||||
: "ROW",
|
||||
function_schema: schema,
|
||||
function_name: "",
|
||||
function_args: [],
|
||||
when: null,
|
||||
},
|
||||
};
|
||||
case "table":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: mode === "edit" ? "edit" : "create",
|
||||
columns: mode === "edit"
|
||||
? (item.columnMeta ?? []).map((c) => ({
|
||||
name: c.name, type: c.type, nullable: c.nullable,
|
||||
default: c.default ?? null, is_pk: c.is_pk,
|
||||
}))
|
||||
: [],
|
||||
old_columns: mode === "edit" ? (item.columnMeta ?? []) : undefined,
|
||||
},
|
||||
};
|
||||
case "role":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: "create",
|
||||
login: mode === "edit" ? (item.can_login ?? false) : false,
|
||||
superuser: mode === "edit" ? (item.superuser ?? false) : false,
|
||||
createdb: mode === "edit" ? (item.create_db ?? false) : false,
|
||||
createrole: mode === "edit" ? (item.create_role ?? false) : false,
|
||||
inherit: mode === "edit" ? (item.inherit ?? true) : true,
|
||||
replication: mode === "edit" ? (item.replication ?? false) : false,
|
||||
bypassrls: mode === "edit" ? (item.bypass_rls ?? false) : false,
|
||||
connection_limit: mode === "edit" ? (item.connection_limit ?? -1) : -1,
|
||||
valid_until: mode === "edit" ? (item.valid_until ?? "") : "",
|
||||
password: "",
|
||||
members: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal params that drop the object, carrying any per-kind context
|
||||
* (owning table, argument types, materialized flag, …).
|
||||
*/
|
||||
export function dropCrudParams(kind: ObjectKind, item: CrudItem): DdlParams {
|
||||
const base = { schema: item.schema, name: item.name };
|
||||
if (kind === "table" || kind === "role") {
|
||||
return { ...base, action: { op: "drop" } };
|
||||
}
|
||||
if (kind === "trigger") {
|
||||
return {
|
||||
...base,
|
||||
action: { op: "drop", table: item.table ?? item.table_name ?? "" },
|
||||
};
|
||||
}
|
||||
if (kind === "index" || kind === "constraint") {
|
||||
return {
|
||||
...base,
|
||||
schema: item.schema,
|
||||
table: item.table ?? "",
|
||||
name: item.name,
|
||||
action: { op: "drop" },
|
||||
};
|
||||
}
|
||||
if (kind === "view") {
|
||||
return {
|
||||
...base,
|
||||
materialized: item.table_type === "m" || item.materialized === true,
|
||||
action: { op: "drop" },
|
||||
};
|
||||
}
|
||||
if (kind === "function" || kind === "procedure") {
|
||||
return {
|
||||
...base,
|
||||
is_procedure: kind === "procedure",
|
||||
action: { op: "drop", arg_types: item.argument_types ?? [] },
|
||||
};
|
||||
}
|
||||
return { ...base, action: { op: "drop" } };
|
||||
}
|
||||
|
||||
/// An installable extension from pg_available_extensions.
|
||||
export interface AvailableExtension {
|
||||
name: string;
|
||||
version: string;
|
||||
comment: string | null;
|
||||
}
|
||||
|
||||
/** Build one or more SQL statements for an object CRUD operation. */
|
||||
export async function buildObjectDdl(
|
||||
connectionId: string,
|
||||
kind: ObjectKind,
|
||||
params: DdlParams,
|
||||
): Promise<string[]> {
|
||||
return invoke<string[]>("build_object_ddl", { connectionId, kind, params });
|
||||
}
|
||||
|
||||
/** List extensions available for install on the current server. */
|
||||
export async function getAvailableExtensions(
|
||||
connectionId: string,
|
||||
): Promise<AvailableExtension[]> {
|
||||
return invoke<AvailableExtension[]>("get_available_extensions", {
|
||||
connectionId,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { isMacOS } from "./platform";
|
||||
|
||||
describe("isMacOS", () => {
|
||||
afterEach(() => {
|
||||
vi.stubGlobal("navigator", undefined);
|
||||
});
|
||||
|
||||
it("true on MacIntel/Mac platform", () => {
|
||||
vi.stubGlobal("navigator", { platform: "MacIntel", userAgent: "Mozilla/5.0 (Macintosh; X)" });
|
||||
expect(isMacOS()).toBe(true);
|
||||
});
|
||||
|
||||
it("false on Win32", () => {
|
||||
vi.stubGlobal("navigator", { platform: "Win32", userAgent: "Mozilla/5.0 (Windows NT 10.0)" });
|
||||
expect(isMacOS()).toBe(false);
|
||||
});
|
||||
|
||||
it("false on Linux", () => {
|
||||
vi.stubGlobal("navigator", { platform: "Linux x86_64", userAgent: "Mozilla/5.0 (X11; Linux)" });
|
||||
expect(isMacOS()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/** True only on macOS (the macOS "Overlay" drag strip is gated on this). Uses
|
||||
* navigator.platform/userAgent (the established pattern in BackupPage.tsx);
|
||||
* @tauri-apps/plugin-os is not installed and getCurrentWindow() has no osLabel(). */
|
||||
export function isMacOS(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const p = (navigator.platform || "").toLowerCase();
|
||||
const u = (navigator.userAgent || "").toLowerCase();
|
||||
return p.includes("mac") || u.includes("macintosh") || u.includes("mac os");
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { PROVIDER_TABS, getProviderById, SETUP_GUIDES } from "./providers";
|
||||
|
||||
describe("providers", () => {
|
||||
it("defines exactly 6 provider tabs in order", () => {
|
||||
expect(PROVIDER_TABS.map((p) => p.id)).toEqual([
|
||||
"postgresql", "mysql", "sqlite", "redis", "supabase", "neon",
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks only supabase and neon as managed presets mapping to postgresql", () => {
|
||||
const pg = getProviderById("postgresql")!;
|
||||
expect(pg.dbType).toBe("postgresql");
|
||||
expect(pg.isManagedPreset).toBe(false);
|
||||
const supa = getProviderById("supabase")!;
|
||||
expect(supa.dbType).toBe("postgresql");
|
||||
expect(supa.isManagedPreset).toBe(true);
|
||||
const neon = getProviderById("neon")!;
|
||||
expect(neon.dbType).toBe("postgresql");
|
||||
expect(neon.isManagedPreset).toBe(true);
|
||||
const redis = getProviderById("redis")!;
|
||||
expect(redis.dbType).toBe("redis");
|
||||
expect(redis.isManagedPreset).toBe(false);
|
||||
});
|
||||
|
||||
it("getProviderById returns undefined for unknown id", () => {
|
||||
expect(getProviderById("nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ships setup guides for supabase and neon only, each with steps", () => {
|
||||
expect(SETUP_GUIDES.supabase.steps.length).toBeGreaterThan(0);
|
||||
expect(SETUP_GUIDES.neon.steps.length).toBeGreaterThan(0);
|
||||
expect(SETUP_GUIDES.supabase.sslRequired).toBe(true);
|
||||
expect(SETUP_GUIDES.neon.sslRequired).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { DbType } from "./types";
|
||||
|
||||
export type ProviderId = "postgresql" | "mysql" | "sqlite" | "redis" | "supabase" | "neon";
|
||||
|
||||
export interface ProviderTab {
|
||||
id: ProviderId;
|
||||
label: string;
|
||||
/** The db_type persisted for a connection made via this tab. Supabase/Neon
|
||||
* are managed PostgreSQL, so they persist as `postgresql`. */
|
||||
dbType: DbType;
|
||||
/** True for managed-PostgreSQL presets (Supabase/NeonDB). */
|
||||
isManagedPreset: boolean;
|
||||
}
|
||||
|
||||
export const PROVIDER_TABS: ProviderTab[] = [
|
||||
{ id: "postgresql", label: "PostgreSQL", dbType: "postgresql", isManagedPreset: false },
|
||||
{ id: "mysql", label: "MySQL", dbType: "mysql", 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 },
|
||||
];
|
||||
|
||||
export function getProviderById(id: string): ProviderTab | undefined {
|
||||
return PROVIDER_TABS.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
export interface SetupGuideStep {
|
||||
title: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface SetupGuide {
|
||||
/** Short tagline shown on the provider card. */
|
||||
blurb: string;
|
||||
sslRequired: boolean;
|
||||
steps: SetupGuideStep[];
|
||||
}
|
||||
|
||||
/** 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> = {
|
||||
supabase: {
|
||||
blurb: "Managed PostgreSQL with a pooled connection string.",
|
||||
sslRequired: true,
|
||||
steps: [
|
||||
{
|
||||
title: "Open the Supabase Dashboard",
|
||||
detail: "Open your project → click the Connect button at the top of the page.",
|
||||
},
|
||||
{
|
||||
title: "Copy a Postgres connection string",
|
||||
detail:
|
||||
"Direct: postgresql://postgres:[YOUR-PASSWORD]@db.[project-ref].supabase.co:5432/postgres " +
|
||||
"(needs IPv6, or the IPv4 add-on). For IPv4-only networks use the shared pooler session mode: " +
|
||||
"postgres://postgres.[project-ref]:[YOUR-PASSWORD]@aws-[REGION].pooler.supabase.com:5432/postgres.",
|
||||
},
|
||||
{
|
||||
title: "Paste it into Gridline's Connection URI",
|
||||
detail: "It auto-detects as PostgreSQL; the Supabase tab highlights. Save stores the password in your OS keychain.",
|
||||
},
|
||||
{
|
||||
title: "Enable SSL",
|
||||
detail: "Supabase requires SSL — under SSH / SSL set the mode to Require (or Verify Full with the project root cert).",
|
||||
},
|
||||
],
|
||||
},
|
||||
neon: {
|
||||
blurb: "Serverless Postgres with pooled or direct endpoints.",
|
||||
sslRequired: true,
|
||||
steps: [
|
||||
{
|
||||
title: "Open the Neon Console",
|
||||
detail: "On your Project Dashboard, click Connect to open the 'Connect to your database' modal.",
|
||||
},
|
||||
{
|
||||
title: "Pick branch, compute, database, role",
|
||||
detail: "A connection string is built for you. The default is the pooled endpoint (host ends in -pooler…neon.tech).",
|
||||
},
|
||||
{
|
||||
title: "Copy the connection string",
|
||||
detail:
|
||||
"postgresql://[role]:[password]@ep-[compute-id]-pooler.[region].aws.neon.tech/[dbname]?sslmode=require " +
|
||||
"— port 5432 for both pooled and direct. If your client can't parse user:pass@host, fill Host/Port/User/Password manually.",
|
||||
},
|
||||
{
|
||||
title: "Paste it into Gridline's Connection URI",
|
||||
detail: "Auto-detects as PostgreSQL; the NeonDB tab highlights. Save stores the password in your OS keychain.",
|
||||
},
|
||||
{
|
||||
title: "Enable SSL",
|
||||
detail: "Neon requires SSL/TLS (strings ship with sslmode=require) — confirm SSH / SSL → mode Require.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { pruneRecent, dedupeRecent } from "./recentConnections";
|
||||
import type { RecentConnection } from "./types";
|
||||
|
||||
const rc = (id: string, at: string): RecentConnection => ({ connection_id: id, opened_at: at });
|
||||
|
||||
describe("recentConnections helpers", () => {
|
||||
it("pruneRecent keeps the newest N", () => {
|
||||
const list = [rc("a", "1"), rc("b", "3"), rc("c", "2")];
|
||||
expect(pruneRecent(list, 2)).toEqual([rc("b", "3"), rc("c", "2")]);
|
||||
});
|
||||
it("dedupeRecent moves the latest occurrence of an id to the front", () => {
|
||||
const list = [rc("a", "1"), rc("b", "2"), rc("a", "3")];
|
||||
const out = dedupeRecent(list);
|
||||
expect(out[0].connection_id).toBe("a");
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { RecentConnection } from "./types";
|
||||
|
||||
/** Keep the newest N entries (sorted by opened_at DESC). */
|
||||
export function pruneRecent(list: RecentConnection[], n: number): RecentConnection[] {
|
||||
return [...list].sort((a, b) => (a.opened_at < b.opened_at ? 1 : -1)).slice(0, n);
|
||||
}
|
||||
|
||||
/** Remove duplicates, keeping the most recent occurrence per connection_id at the front. */
|
||||
export function dedupeRecent(list: RecentConnection[]): RecentConnection[] {
|
||||
const seen = new Set<string>();
|
||||
const out: RecentConnection[] = [];
|
||||
for (const item of [...list].sort((a, b) => (a.opened_at < b.opened_at ? 1 : -1))) {
|
||||
if (!seen.has(item.connection_id)) {
|
||||
seen.add(item.connection_id);
|
||||
out.push(item);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { validateSettingsExport } from "./settingsImport";
|
||||
import type { Settings } from "./types";
|
||||
|
||||
const good: Settings = {
|
||||
confirm_before_delete: true, default_folder_id: null, theme: "dark", font_size: "medium",
|
||||
default_ports: { postgresql: 5432 }, tag_order: null, table_refresh_rate: 5, table_page_size: 50,
|
||||
shortcuts: { open_command_palette: "Cmd+K" }, accent_color: "#2563EB",
|
||||
editor_font_size: 14, editor_font_family: "Menlo", editor_word_wrap: "off", editor_minimap: true, editor_tab_size: 2,
|
||||
};
|
||||
|
||||
describe("validateSettingsExport", () => {
|
||||
it("accepts a well-formed object", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 1, settings: good });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an invalid theme", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 1, settings: { ...good, theme: "purple" as never } });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.errors.some((e) => e.field === "theme")).toBe(true);
|
||||
});
|
||||
|
||||
it("clamps editor_font_size to [8,24]", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 1, settings: { ...good, editor_font_size: 999 } });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.settings.editor_font_size).toBe(24);
|
||||
});
|
||||
|
||||
it("tolerates unknown keys (version skew)", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 99, settings: { ...good, futureField: true } } as never);
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a non-negative table_refresh_rate", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 1, settings: { ...good, table_refresh_rate: -1 } });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Settings } from "./types";
|
||||
|
||||
export interface SettingsExport { schemaVersion: number; settings: Settings }
|
||||
export type ValidationOk = { ok: true; settings: Settings };
|
||||
export type ValidationErr = { ok: false; errors: { field: string; message: string }[] };
|
||||
export type ValidationResult = ValidationOk | ValidationErr;
|
||||
|
||||
const THEMES = ["dark", "light", "system"];
|
||||
const FONT_SIZES = ["small", "medium", "large"];
|
||||
const FONT_FAMILIES = ["Space Mono", "Fira Code", "Menlo", "Monaco", "Consolas", "JetBrains Mono", "monospace"];
|
||||
const WORD_WRAPS = ["off", "on"];
|
||||
|
||||
function clamp(n: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, n)); }
|
||||
|
||||
export function validateSettingsExport(input: unknown): ValidationResult {
|
||||
const errors: ValidationErr["errors"] = [];
|
||||
if (typeof input !== "object" || input === null || !("settings" in input)) {
|
||||
return { ok: false, errors: [{ field: "settings", message: "missing settings object" }] };
|
||||
}
|
||||
const s = (input as { settings: Record<string, unknown> }).settings;
|
||||
const out: Record<string, unknown> = {};
|
||||
|
||||
const enumCheck = (field: keyof Settings, allow: readonly string[], val: unknown) => {
|
||||
if (typeof val === "string" && allow.includes(val)) out[field] = val;
|
||||
else errors.push({ field, message: `invalid ${field}` });
|
||||
};
|
||||
enumCheck("theme", THEMES, s.theme);
|
||||
enumCheck("font_size", FONT_SIZES, s.font_size);
|
||||
enumCheck("editor_word_wrap", WORD_WRAPS, s.editor_word_wrap);
|
||||
|
||||
if (typeof s.accent_color === "string" && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(s.accent_color)) out.accent_color = s.accent_color;
|
||||
else errors.push({ field: "accent_color", message: "invalid hex color" });
|
||||
|
||||
if (typeof s.editor_font_size === "number") out.editor_font_size = clamp(Math.trunc(s.editor_font_size), 8, 24);
|
||||
else errors.push({ field: "editor_font_size", message: "must be a number" });
|
||||
if (typeof s.editor_tab_size === "number") out.editor_tab_size = clamp(Math.trunc(s.editor_tab_size), 2, 8);
|
||||
else errors.push({ field: "editor_tab_size", message: "must be a number" });
|
||||
enumCheck("editor_font_family", FONT_FAMILIES, s.editor_font_family);
|
||||
if (typeof s.editor_minimap === "boolean") out.editor_minimap = s.editor_minimap;
|
||||
else errors.push({ field: "editor_minimap", message: "must be boolean" });
|
||||
|
||||
if (typeof s.table_page_size === "number" && s.table_page_size > 0) out.table_page_size = Math.trunc(s.table_page_size);
|
||||
else errors.push({ field: "table_page_size", message: "must be positive" });
|
||||
if (typeof s.table_refresh_rate === "number" && s.table_refresh_rate >= 0) out.table_refresh_rate = s.table_refresh_rate;
|
||||
else errors.push({ field: "table_refresh_rate", message: "must be non-negative" });
|
||||
|
||||
out.confirm_before_delete = typeof s.confirm_before_delete === "boolean" ? s.confirm_before_delete : true;
|
||||
out.default_folder_id = typeof s.default_folder_id === "string" || s.default_folder_id === null ? s.default_folder_id : null;
|
||||
out.tag_order = typeof s.tag_order === "string" || s.tag_order === null ? s.tag_order : null;
|
||||
out.default_ports = s.default_ports && typeof s.default_ports === "object" ? s.default_ports : {};
|
||||
out.shortcuts = s.shortcuts && typeof s.shortcuts === "object" ? s.shortcuts : {};
|
||||
|
||||
if (errors.length) return { ok: false, errors };
|
||||
return { ok: true, settings: out as unknown as Settings };
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
buildSqlSuggestions,
|
||||
SQL_KEYWORDS,
|
||||
parseTableRef,
|
||||
buildColumnSuggestions,
|
||||
getColumnsForTable,
|
||||
getCachedColumns,
|
||||
} from "./sqlCompletion";
|
||||
import type { TableInfo } from "./types";
|
||||
import { getSchemaGraph } from "./commands";
|
||||
|
||||
vi.mock("./commands", () => ({
|
||||
getSchemaGraph: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGetSchemaGraph = vi.mocked(getSchemaGraph);
|
||||
|
||||
const tables: TableInfo[] = [
|
||||
{ name: "users", schema: "public", table_type: "TABLE" },
|
||||
{ name: "orders", schema: "public", table_type: "TABLE" },
|
||||
{ name: "audit_log", schema: "audit", table_type: "TABLE" },
|
||||
];
|
||||
|
||||
describe("parseTableRef", () => {
|
||||
it("parses a bare table before the cursor dot", () => {
|
||||
expect(parseTableRef("SELECT * FROM users.")).toEqual({
|
||||
schema: null,
|
||||
table: "users",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a schema-qualified table", () => {
|
||||
expect(parseTableRef("SELECT * FROM public.users.")).toEqual({
|
||||
schema: "public",
|
||||
table: "users",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when there is no trailing dot", () => {
|
||||
expect(parseTableRef("SELECT * FROM users WHERE id")).toBeNull();
|
||||
expect(parseTableRef("SELECT")).toBeNull();
|
||||
expect(parseTableRef("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildColumnSuggestions", () => {
|
||||
it("maps columns to column-kind suggestions", () => {
|
||||
const suggestions = buildColumnSuggestions([
|
||||
{ name: "id" },
|
||||
{ name: "email" },
|
||||
]);
|
||||
expect(suggestions).toEqual([
|
||||
{ label: "id", insertText: "id", kind: "column" },
|
||||
{ label: "email", insertText: "email", kind: "column" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getColumnsForTable", () => {
|
||||
beforeEach(() => {
|
||||
mockGetSchemaGraph.mockReset();
|
||||
});
|
||||
|
||||
it("fetches columns from the schema graph and caches them", async () => {
|
||||
mockGetSchemaGraph.mockResolvedValue({
|
||||
tables: [
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [{ name: "id" } as never],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
} as never);
|
||||
|
||||
const cols = await getColumnsForTable("c1", "public", "users");
|
||||
expect(cols.map((c) => c.name)).toEqual(["id"]);
|
||||
expect(mockGetSchemaGraph).toHaveBeenCalledTimes(1);
|
||||
|
||||
// cached: a second lookup does not refetch
|
||||
await getColumnsForTable("c1", "public", "users");
|
||||
expect(mockGetSchemaGraph).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns an empty list when the fetch fails", async () => {
|
||||
mockGetSchemaGraph.mockRejectedValue(new Error("boom"));
|
||||
const cols = await getColumnsForTable("c1", "public", "orders");
|
||||
expect(cols).toEqual([]);
|
||||
});
|
||||
|
||||
it("exposes cached columns synchronously", async () => {
|
||||
mockGetSchemaGraph.mockResolvedValue({
|
||||
tables: [
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [{ name: "id" } as never],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
} as never);
|
||||
await getColumnsForTable("c1", "public", "users");
|
||||
expect(getCachedColumns("public", "users")?.map((c) => c.name)).toEqual([
|
||||
"id",
|
||||
]);
|
||||
expect(getCachedColumns("public", "missing")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSqlSuggestions", () => {
|
||||
it("includes core SQL keywords", () => {
|
||||
const suggestions = buildSqlSuggestions([], null);
|
||||
const labels = suggestions.map((s) => s.label);
|
||||
for (const kw of ["SELECT", "FROM", "WHERE", "JOIN", "INSERT"]) {
|
||||
expect(labels).toContain(kw);
|
||||
}
|
||||
});
|
||||
|
||||
it("labels keywords as keyword kind", () => {
|
||||
const suggestions = buildSqlSuggestions([], null);
|
||||
const select = suggestions.find((s) => s.label === "SELECT");
|
||||
expect(select?.kind).toBe("keyword");
|
||||
expect(select?.insertText).toBe("SELECT");
|
||||
});
|
||||
|
||||
it("includes table names from the current schema", () => {
|
||||
const suggestions = buildSqlSuggestions(tables, "public");
|
||||
const labels = suggestions.map((s) => s.label);
|
||||
expect(labels).toContain("users");
|
||||
expect(labels).toContain("orders");
|
||||
expect(labels).not.toContain("audit_log");
|
||||
});
|
||||
|
||||
it("includes all tables when no schema is selected", () => {
|
||||
const suggestions = buildSqlSuggestions(tables, null);
|
||||
const labels = suggestions.map((s) => s.label);
|
||||
expect(labels).toContain("audit_log");
|
||||
});
|
||||
|
||||
it("labels tables as table kind", () => {
|
||||
const suggestions = buildSqlSuggestions(tables, "public");
|
||||
const users = suggestions.find((s) => s.label === "users");
|
||||
expect(users?.kind).toBe("table");
|
||||
});
|
||||
|
||||
it("exports a non-empty keyword list", () => {
|
||||
expect(SQL_KEYWORDS.length).toBeGreaterThan(20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { TableInfo, GraphColumn } from "./types";
|
||||
import { getSchemaGraph } from "./commands";
|
||||
|
||||
export const SQL_KEYWORDS = [
|
||||
"SELECT",
|
||||
"FROM",
|
||||
"WHERE",
|
||||
"INSERT",
|
||||
"INTO",
|
||||
"VALUES",
|
||||
"UPDATE",
|
||||
"SET",
|
||||
"DELETE",
|
||||
"CREATE",
|
||||
"TABLE",
|
||||
"DROP",
|
||||
"ALTER",
|
||||
"ADD",
|
||||
"COLUMN",
|
||||
"PRIMARY",
|
||||
"KEY",
|
||||
"FOREIGN",
|
||||
"REFERENCES",
|
||||
"INDEX",
|
||||
"UNIQUE",
|
||||
"JOIN",
|
||||
"INNER",
|
||||
"LEFT",
|
||||
"RIGHT",
|
||||
"OUTER",
|
||||
"FULL",
|
||||
"CROSS",
|
||||
"ON",
|
||||
"AS",
|
||||
"AND",
|
||||
"OR",
|
||||
"NOT",
|
||||
"NULL",
|
||||
"IS",
|
||||
"IN",
|
||||
"EXISTS",
|
||||
"BETWEEN",
|
||||
"LIKE",
|
||||
"ILIKE",
|
||||
"LIMIT",
|
||||
"OFFSET",
|
||||
"ORDER",
|
||||
"BY",
|
||||
"GROUP",
|
||||
"HAVING",
|
||||
"DISTINCT",
|
||||
"UNION",
|
||||
"ALL",
|
||||
"CASE",
|
||||
"WHEN",
|
||||
"THEN",
|
||||
"ELSE",
|
||||
"END",
|
||||
"ASC",
|
||||
"DESC",
|
||||
"WITH",
|
||||
"RETURNING",
|
||||
"BEGIN",
|
||||
"COMMIT",
|
||||
"EXPLAIN",
|
||||
"GRANT",
|
||||
"REVOKE",
|
||||
] as const;
|
||||
|
||||
export type SqlSuggestionKind = "keyword" | "table" | "column";
|
||||
|
||||
export interface SqlSuggestion {
|
||||
label: string;
|
||||
insertText: string;
|
||||
kind: SqlSuggestionKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the text before the cursor into a `table.` reference. Returns the
|
||||
* table (and optional schema qualifier) when the text ends in a dot right
|
||||
* after an identifier, e.g. `users.` or `public.users.`.
|
||||
*/
|
||||
export function parseTableRef(
|
||||
text: string,
|
||||
): { schema: string | null; table: string } | null {
|
||||
const match = text.match(/(?:(\w+)\.)?(\w+)\.\s*$/);
|
||||
if (!match) return null;
|
||||
return { schema: match[1] ?? null, table: match[2] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build column suggestions (used after a `table.` reference).
|
||||
*/
|
||||
export function buildColumnSuggestions(
|
||||
columns: { name: string }[],
|
||||
): SqlSuggestion[] {
|
||||
return columns.map((c) => ({
|
||||
label: c.name,
|
||||
insertText: c.name,
|
||||
kind: "column" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
// Cache of table columns, keyed by "schema.table".
|
||||
const columnCache = new Map<string, GraphColumn[]>();
|
||||
|
||||
/**
|
||||
* Synchronously read cached columns for a table, if the schema graph for its
|
||||
* schema has already been fetched.
|
||||
*/
|
||||
export function getCachedColumns(
|
||||
schema: string,
|
||||
table: string,
|
||||
): GraphColumn[] | undefined {
|
||||
return columnCache.get(`${schema}.${table}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch (and cache) the columns of a table via the schema-graph introspection
|
||||
* command. Caches the whole schema in one round trip. Returns [] on error so
|
||||
* autocomplete degrades gracefully.
|
||||
*/
|
||||
export async function getColumnsForTable(
|
||||
connectionId: string | null,
|
||||
schema: string | null,
|
||||
table: string,
|
||||
): Promise<GraphColumn[]> {
|
||||
const resolvedSchema = schema ?? "public";
|
||||
const key = `${resolvedSchema}.${table}`;
|
||||
const cached = columnCache.get(key);
|
||||
if (cached) return cached;
|
||||
if (!connectionId) return [];
|
||||
|
||||
try {
|
||||
const graph = await getSchemaGraph(connectionId, resolvedSchema);
|
||||
for (const t of graph.tables) {
|
||||
columnCache.set(`${resolvedSchema}.${t.name}`, t.columns);
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return columnCache.get(key) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build editor suggestions for the SQL language: reserved keywords plus the
|
||||
* table names in the currently selected schema.
|
||||
*/
|
||||
export function buildSqlSuggestions(
|
||||
tables: TableInfo[],
|
||||
schema: string | null,
|
||||
): SqlSuggestion[] {
|
||||
const keywords: SqlSuggestion[] = SQL_KEYWORDS.map((kw) => ({
|
||||
label: kw,
|
||||
insertText: kw,
|
||||
kind: "keyword",
|
||||
}));
|
||||
|
||||
const tableNames = new Set(
|
||||
tables
|
||||
.filter((t) => !schema || t.schema === schema)
|
||||
.map((t) => t.name),
|
||||
);
|
||||
const tableSuggestions: SqlSuggestion[] = [...tableNames].map((name) => ({
|
||||
label: name,
|
||||
insertText: name,
|
||||
kind: "table",
|
||||
}));
|
||||
|
||||
return [...keywords, ...tableSuggestions];
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
import { describe, it, expect, expectTypeOf } from "vitest";
|
||||
import type {
|
||||
Connection,
|
||||
ConnectionInput,
|
||||
ActiveView,
|
||||
TableInfo,
|
||||
ColumnInfo,
|
||||
QueryResult,
|
||||
ChangeItem,
|
||||
ChangeItemType,
|
||||
ChangeStatus,
|
||||
DbViewerTab,
|
||||
ConnectionTestResult,
|
||||
SchemaGraph,
|
||||
TableNode,
|
||||
GraphColumn,
|
||||
Relationship,
|
||||
Settings,
|
||||
IndexInfo,
|
||||
ConstraintInfo,
|
||||
RecentConnection,
|
||||
RoleInfo,
|
||||
PrivilegeEntry,
|
||||
RebuildReadiness,
|
||||
MaintenanceResult,
|
||||
ObjectType,
|
||||
} from "./types";
|
||||
|
||||
describe("ActiveView", () => {
|
||||
it("includes db-viewer", () => {
|
||||
const view: ActiveView = "db-viewer";
|
||||
expect(view).toBe("db-viewer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Connection", () => {
|
||||
it("accepts new SSH/SSL fields", () => {
|
||||
const conn: Connection = {
|
||||
id: "c1",
|
||||
name: "Test",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
username: "admin",
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
favorite: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
// new SSH/SSL fields
|
||||
database: "mydb",
|
||||
ssh_host: "bastion.example.com",
|
||||
ssh_port: 22,
|
||||
ssh_user: "tunnel",
|
||||
ssh_auth_method: "key",
|
||||
ssh_private_key_path: "/home/user/.ssh/id_rsa",
|
||||
ssl_mode: "require",
|
||||
ssl_ca_path: "/etc/ssl/certs/ca.pem",
|
||||
ssl_cert_path: "/etc/ssl/certs/client.crt",
|
||||
ssl_key_path: "/etc/ssl/certs/client.key",
|
||||
};
|
||||
expect(conn.ssh_host).toBe("bastion.example.com");
|
||||
expect(conn.database).toBe("mydb");
|
||||
expect(conn.ssl_mode).toBe("require");
|
||||
});
|
||||
|
||||
it("does not include sensitive SSH/SSL fields (password/ssh_passphrase)", () => {
|
||||
// TypeScript compile check: these should not be assignable
|
||||
const conn: Connection = {
|
||||
id: "c2",
|
||||
name: "Minimal",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: null,
|
||||
username: null,
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
favorite: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
// @ts-expect-error - password must NOT exist on Connection
|
||||
conn.password;
|
||||
// @ts-expect-error - ssh_passphrase must NOT exist on Connection
|
||||
conn.ssh_passphrase;
|
||||
expect(conn.host).toBe("localhost");
|
||||
});
|
||||
|
||||
it("accepts SSH fields as optional (minimal connection)", () => {
|
||||
const conn: Connection = {
|
||||
id: "c3",
|
||||
name: "Minimal",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: null,
|
||||
username: null,
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
favorite: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
expect(conn.ssh_host).toBeUndefined();
|
||||
expect(conn.database).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConnectionInput", () => {
|
||||
it("accepts ssh_password", () => {
|
||||
const input: ConnectionInput = {
|
||||
name: "x", db_type: "postgresql", host: "h", port: 5432, ssh_password: "ssh-pw",
|
||||
};
|
||||
expect(input.ssh_password).toBe("ssh-pw");
|
||||
});
|
||||
|
||||
it("accepts all SSH/SSL fields", () => {
|
||||
const input: ConnectionInput = {
|
||||
name: "Test",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
ssh_host: "bastion.example.com",
|
||||
ssh_port: 22,
|
||||
ssh_user: "tunnel",
|
||||
ssh_auth_method: "key",
|
||||
ssh_private_key_path: "/home/user/.ssh/id_rsa",
|
||||
ssh_passphrase: "s3cret",
|
||||
ssl_mode: "verify-full",
|
||||
ssl_ca_path: "/etc/ssl/certs/ca.pem",
|
||||
ssl_cert_path: "/etc/ssl/certs/client.crt",
|
||||
ssl_key_path: "/etc/ssl/certs/client.key",
|
||||
password: "dbpass",
|
||||
database: "mydb",
|
||||
};
|
||||
expect(input.ssh_auth_method).toBe("key");
|
||||
expect(input.ssl_mode).toBe("verify-full");
|
||||
expect(input.ssh_passphrase).toBe("s3cret");
|
||||
expect(input.database).toBe("mydb");
|
||||
});
|
||||
|
||||
it("accepts password auth method", () => {
|
||||
const input: ConnectionInput = {
|
||||
name: "PW SSH",
|
||||
db_type: "postgresql",
|
||||
host: "db.example.com",
|
||||
port: 5432,
|
||||
ssh_host: "gateway.example.com",
|
||||
ssh_port: 2222,
|
||||
ssh_user: "proxy",
|
||||
ssh_auth_method: "password",
|
||||
};
|
||||
expect(input.ssh_auth_method).toBe("password");
|
||||
});
|
||||
|
||||
it("allows all SSH/SSL fields to be omitted", () => {
|
||||
const input: ConnectionInput = {
|
||||
name: "Simple",
|
||||
db_type: "sqlite",
|
||||
host: "localhost",
|
||||
port: null,
|
||||
};
|
||||
expect(input.ssh_host).toBeUndefined();
|
||||
expect(input.ssl_mode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TableInfo", () => {
|
||||
it("has the correct shape", () => {
|
||||
const table: TableInfo = {
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
};
|
||||
expect(table.name).toBe("users");
|
||||
expect(table.schema).toBe("public");
|
||||
expect(table.table_type).toBe("TABLE");
|
||||
});
|
||||
|
||||
it("accepts view type", () => {
|
||||
const table: TableInfo = { name: "v1", schema: "public", table_type: "VIEW" };
|
||||
expect(table.table_type).toBe("VIEW");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ColumnInfo", () => {
|
||||
it("has the correct shape", () => {
|
||||
const col: ColumnInfo = {
|
||||
name: "id",
|
||||
data_type: "integer",
|
||||
is_nullable: false,
|
||||
is_pk: true,
|
||||
is_fk: false,
|
||||
fk_ref: null,
|
||||
default_value: null,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
};
|
||||
expect(col.name).toBe("id");
|
||||
expect(col.is_pk).toBe(true);
|
||||
});
|
||||
|
||||
it("supports foreign key references", () => {
|
||||
const col: ColumnInfo = {
|
||||
name: "user_id",
|
||||
data_type: "integer",
|
||||
is_nullable: true,
|
||||
is_pk: false,
|
||||
is_fk: true,
|
||||
fk_ref: ["users", "id"],
|
||||
default_value: null,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
};
|
||||
expect(col.fk_ref?.[0]).toBe("users");
|
||||
});
|
||||
});
|
||||
|
||||
describe("QueryResult", () => {
|
||||
it("is well-typed with columns and rows", () => {
|
||||
const result: QueryResult = {
|
||||
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"email",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
|
||||
rows: [
|
||||
[1, "Alice"],
|
||||
[2, "Bob"],
|
||||
],
|
||||
total_rows: 2, page: 1, page_size: 50,
|
||||
execution_time_ms: 12.5,
|
||||
};
|
||||
expect(result.columns.length).toBe(3);
|
||||
expect(result.total_rows).toBe(2);
|
||||
expect(result.execution_time_ms).toBe(12.5);
|
||||
});
|
||||
|
||||
it("allows error state", () => {
|
||||
const result: QueryResult = {
|
||||
columns: [],
|
||||
rows: [],
|
||||
total_rows: 0, page: 1, page_size: 50,
|
||||
error: "Syntax error near FROM",
|
||||
};
|
||||
expect(result.error).toBe("Syntax error near FROM");
|
||||
});
|
||||
|
||||
it("can have null execution_time", () => {
|
||||
const result: QueryResult = {
|
||||
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
|
||||
rows: [],
|
||||
total_rows: 0, page: 1, page_size: 50,
|
||||
execution_time_ms: null,
|
||||
};
|
||||
expect(result.execution_time_ms).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChangeItem", () => {
|
||||
it("has a discriminated type", () => {
|
||||
const createItem: ChangeItem = {
|
||||
type: "create_table",
|
||||
sql: "CREATE TABLE users (id INT)",
|
||||
status: "pending",
|
||||
id: "ch1",
|
||||
description: "Create users table",
|
||||
};
|
||||
expect(createItem.type).toBe("create_table");
|
||||
expect(createItem.status).toBe("pending");
|
||||
|
||||
const dropItem: ChangeItem = {
|
||||
type: "drop_table",
|
||||
sql: "DROP TABLE users",
|
||||
status: "applied",
|
||||
id: "ch2",
|
||||
};
|
||||
expect(dropItem.type).toBe("drop_table");
|
||||
expect(dropItem.status).toBe("applied");
|
||||
});
|
||||
|
||||
it("accepts error status with error message", () => {
|
||||
const item: ChangeItem = {
|
||||
type: "alter_table",
|
||||
sql: "ALTER TABLE users ADD COLUMN age INT",
|
||||
status: "error",
|
||||
error: "Column already exists",
|
||||
id: "ch3",
|
||||
};
|
||||
expect(item.status).toBe("error");
|
||||
expect(item.error).toBe("Column already exists");
|
||||
});
|
||||
|
||||
it("accepts all ChangeItemType values", () => {
|
||||
const types: ChangeItemType[] = [
|
||||
"create_table",
|
||||
"alter_table",
|
||||
"drop_table",
|
||||
"insert",
|
||||
"update",
|
||||
"delete",
|
||||
"create_index",
|
||||
"drop_index",
|
||||
];
|
||||
const item: ChangeItem = {
|
||||
type: types[0],
|
||||
sql: "test",
|
||||
status: "pending",
|
||||
id: "ch4",
|
||||
};
|
||||
expect(types).toContain(item.type);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChangeStatus", () => {
|
||||
it("accepts all status values", () => {
|
||||
const statuses: ChangeStatus[] = ["pending", "applied", "error"];
|
||||
expect(statuses).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DbViewerTab", () => {
|
||||
it("can be created with required fields", () => {
|
||||
const tab: DbViewerTab = {
|
||||
id: "tab1",
|
||||
connection_id: "c1",
|
||||
title: "Query 1",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
expect(tab.title).toBe("Query 1");
|
||||
expect(tab.query).toBeUndefined();
|
||||
expect(tab.result).toBeUndefined();
|
||||
expect(tab.changes).toBeUndefined();
|
||||
});
|
||||
|
||||
it("can include query and result", () => {
|
||||
const tab: DbViewerTab = {
|
||||
id: "tab2",
|
||||
connection_id: "c1",
|
||||
title: "SELECT * FROM users",
|
||||
query: "SELECT * FROM users",
|
||||
result: {
|
||||
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
|
||||
rows: [],
|
||||
total_rows: 0, page: 1, page_size: 50,
|
||||
},
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
expect(tab.query).toBe("SELECT * FROM users");
|
||||
expect(tab.result?.total_rows).toBe(0);
|
||||
});
|
||||
|
||||
it("can include changes array", () => {
|
||||
const tab: DbViewerTab = {
|
||||
id: "tab3",
|
||||
connection_id: "c1",
|
||||
title: "Migration",
|
||||
changes: [
|
||||
{ type: "create_table", sql: "CREATE TABLE t (id INT)", status: "pending", id: "ch1" },
|
||||
],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
expect(tab.changes).toHaveLength(1);
|
||||
expect(tab.changes![0].type).toBe("create_table");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConnectionTestResult", () => {
|
||||
it("can represent a successful test", () => {
|
||||
const result: ConnectionTestResult = {
|
||||
ok: true,
|
||||
server_version: "16.2",
|
||||
latency_ms: 5,
|
||||
};
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.server_version).toBe("16.2");
|
||||
});
|
||||
|
||||
it("can represent a failed test with error", () => {
|
||||
const result: ConnectionTestResult = {
|
||||
ok: false,
|
||||
error: "Connection refused",
|
||||
};
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("Connection refused");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Schema graph types", () => {
|
||||
it("GraphColumn has correct shape", () => {
|
||||
const col: GraphColumn = {
|
||||
name: "user_id",
|
||||
data_type: "integer",
|
||||
is_pk: false,
|
||||
is_fk: true,
|
||||
is_unique: false,
|
||||
is_nullable: false,
|
||||
fk_ref: ["public", "users", "id"],
|
||||
};
|
||||
expect(col.name).toBe("user_id");
|
||||
expect(col.is_fk).toBe(true);
|
||||
expect(col.fk_ref).toEqual(["public", "users", "id"]);
|
||||
});
|
||||
|
||||
it("TableNode has correct shape", () => {
|
||||
const node: TableNode = {
|
||||
name: "orders",
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "integer", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null },
|
||||
{ name: "user_id", data_type: "integer", is_pk: false, is_fk: true, is_unique: false, is_nullable: false, fk_ref: ["public", "users", "id"] },
|
||||
],
|
||||
};
|
||||
expect(node.name).toBe("orders");
|
||||
expect(node.columns).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("Relationship has correct shape", () => {
|
||||
const rel: Relationship = {
|
||||
source_schema: "public",
|
||||
source_table: "orders",
|
||||
source_column: "user_id",
|
||||
target_schema: "public",
|
||||
target_table: "users",
|
||||
target_column: "id",
|
||||
cardinality: "1:N",
|
||||
};
|
||||
expect(rel.cardinality).toBe("1:N");
|
||||
});
|
||||
|
||||
it("SchemaGraph has correct shape", () => {
|
||||
const graph: SchemaGraph = {
|
||||
tables: [
|
||||
{ name: "users", schema: "public", table_type: "TABLE", columns: [] },
|
||||
],
|
||||
relationships: [],
|
||||
};
|
||||
expect(graph.tables).toHaveLength(1);
|
||||
expect(graph.relationships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fk_ref can be null for non-FK columns", () => {
|
||||
const col: GraphColumn = {
|
||||
name: "name",
|
||||
data_type: "text",
|
||||
is_pk: false,
|
||||
is_fk: false,
|
||||
is_unique: false,
|
||||
is_nullable: true,
|
||||
fk_ref: null,
|
||||
};
|
||||
expect(col.fk_ref).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Settings", () => {
|
||||
it("includes the five editor option fields", () => {
|
||||
const s: Settings = {
|
||||
confirm_before_delete: true, default_folder_id: null, theme: "dark",
|
||||
font_size: "medium", default_ports: {}, tag_order: null, table_refresh_rate: 0,
|
||||
table_page_size: 50, shortcuts: {}, accent_color: "#2563EB",
|
||||
editor_font_size: 13, editor_font_family: "Space Mono",
|
||||
editor_word_wrap: "off", editor_minimap: false, editor_tab_size: 4,
|
||||
};
|
||||
expect(s.editor_font_size).toBe(13);
|
||||
expect(s.editor_word_wrap).toBe("off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("v0.5.0 types", () => {
|
||||
it("ColumnInfo carries editability metadata", () => {
|
||||
const c: ColumnInfo = {
|
||||
name: "id", data_type: "integer", is_nullable: false, is_pk: true,
|
||||
is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false,
|
||||
};
|
||||
expectTypeOf(c.editable).toEqualTypeOf<boolean>();
|
||||
expectTypeOf(c.is_generated).toEqualTypeOf<boolean>();
|
||||
});
|
||||
it("IndexInfo has the documented fields", () => {
|
||||
const i: IndexInfo = {
|
||||
name: "idx", schema: "public", table: "users", definition: "CREATE INDEX ...",
|
||||
is_unique: true, method: "btree", columns: ["id"], size_bytes: 4096, tablespace: null,
|
||||
};
|
||||
expectTypeOf(i).toMatchTypeOf<IndexInfo>();
|
||||
});
|
||||
it("ConstraintInfo has the documented fields", () => {
|
||||
const c: ConstraintInfo = {
|
||||
name: "ck", schema: "public", table: "users", contype: "CHECK",
|
||||
definition: "CHECK (x > 0)", deferrable: false, validated: true, columns: ["x"],
|
||||
};
|
||||
expectTypeOf(c).toMatchTypeOf<ConstraintInfo>();
|
||||
});
|
||||
it("RecentConnection pairs a connection id with an opened_at timestamp", () => {
|
||||
const r: RecentConnection = { connection_id: "c1", opened_at: "2026-08-02T00:00:00Z" };
|
||||
expectTypeOf(r.connection_id).toEqualTypeOf<string>();
|
||||
});
|
||||
it("Connection carries favorite", () => {
|
||||
const c = { favorite: true } as Connection;
|
||||
expectTypeOf(c.favorite).toEqualTypeOf<boolean>();
|
||||
});
|
||||
});
|
||||
|
||||
describe("v0.7.7 types", () => {
|
||||
it("ObjectType includes roles", () => {
|
||||
const t: ObjectType = "roles";
|
||||
expect(t).toBe("roles");
|
||||
});
|
||||
it("ChangeItemType includes rebuild_table", () => {
|
||||
const t: ChangeItemType = "rebuild_table";
|
||||
expect(t).toBe("rebuild_table");
|
||||
});
|
||||
it("RoleInfo shape", () => {
|
||||
const r: RoleInfo = {
|
||||
name: "app", superuser: false, inherit: true, create_db: false, create_role: false,
|
||||
can_login: true, replication: false, bypass_rls: false, connection_limit: -1,
|
||||
valid_until: null, memberships: [],
|
||||
};
|
||||
expect(r.name).toBe("app");
|
||||
});
|
||||
it("PrivilegeEntry/RebuildReadiness/MaintenanceResult shapes", () => {
|
||||
const pe: PrivilegeEntry = { object_class: "table", schema: "public", name: "users", privileges: ["SELECT"], grantable: false };
|
||||
const rr: RebuildReadiness = { ok: true, reasons: [] };
|
||||
const mr: MaintenanceResult = { duration_ms: 1, message: "ok" };
|
||||
expect(pe.object_class).toBe("table");
|
||||
expect(rr.ok).toBe(true);
|
||||
expect(mr.duration_ms).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,480 @@
|
||||
export type DbType = "postgresql" | "mysql" | "sqlite" | "redis";
|
||||
|
||||
export type Theme = "dark" | "light" | "system";
|
||||
export type FontSize = "small" | "medium" | "large";
|
||||
|
||||
export interface Folder {
|
||||
id: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
tag_ids: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
id: string;
|
||||
name: string;
|
||||
db_type: DbType;
|
||||
host: string;
|
||||
port: number | null;
|
||||
username: string | null;
|
||||
database?: string | null;
|
||||
folder_id: string | null;
|
||||
keychain_ref: string | null;
|
||||
tag_ids: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// SSH/SSL fields (persisted, excluding secrets)
|
||||
ssh_host?: string | null;
|
||||
ssh_port?: number | null;
|
||||
ssh_user?: string | null;
|
||||
ssh_auth_method?: string | null;
|
||||
ssh_private_key_path?: string | null;
|
||||
ssl_mode?: string | null;
|
||||
ssl_ca_path?: string | null;
|
||||
ssl_cert_path?: string | null;
|
||||
ssl_key_path?: string | null;
|
||||
// Environment label (production, staging, development, etc.)
|
||||
environment?: string | null;
|
||||
// Favorite flag (v0.5.0 — pinned connection)
|
||||
favorite: boolean;
|
||||
// Whether to save the password to the OS keychain (opt-out, default ON)
|
||||
use_keychain?: boolean;
|
||||
}
|
||||
|
||||
export type NewConnectionMode = "simple" | "detailed";
|
||||
|
||||
export interface ConnectionInput {
|
||||
name: string;
|
||||
db_type: DbType;
|
||||
host: string;
|
||||
port: number | null;
|
||||
username?: string | null;
|
||||
folder_id?: string | null;
|
||||
tag_ids?: string[];
|
||||
// Form-only fields; backend may ignore them until persisted.
|
||||
connection_string?: string | null;
|
||||
environment?: string | null;
|
||||
password?: string | null;
|
||||
database?: string | null;
|
||||
use_keychain?: boolean;
|
||||
// SSH tunnel fields
|
||||
ssh_host?: string | null;
|
||||
ssh_port?: number | null;
|
||||
ssh_user?: string | null;
|
||||
ssh_auth_method?: "password" | "key" | null;
|
||||
ssh_private_key_path?: string | null;
|
||||
ssh_password?: string | null;
|
||||
ssh_passphrase?: string | null;
|
||||
// SSL/TLS fields
|
||||
ssl_mode?: "disable" | "require" | "verify-ca" | "verify-full" | null;
|
||||
ssl_ca_path?: string | null;
|
||||
ssl_cert_path?: string | null;
|
||||
ssl_key_path?: string | null;
|
||||
}
|
||||
|
||||
export interface FolderInput {
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
tag_ids?: string[];
|
||||
}
|
||||
|
||||
export interface TagInput {
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
confirm_before_delete: boolean;
|
||||
default_folder_id: string | null;
|
||||
theme: Theme;
|
||||
font_size: FontSize;
|
||||
default_ports: Record<string, number | null>;
|
||||
tag_order: string | null;
|
||||
table_refresh_rate: number;
|
||||
table_page_size: number;
|
||||
shortcuts: Record<string, string>;
|
||||
accent_color: string;
|
||||
editor_font_size: number;
|
||||
editor_font_family: string;
|
||||
editor_word_wrap: "off" | "on";
|
||||
editor_minimap: boolean;
|
||||
editor_tab_size: number;
|
||||
}
|
||||
|
||||
export type ActiveView = "home" | "settings" | "new-connection" | "db-viewer";
|
||||
|
||||
export interface FilterState {
|
||||
query: string;
|
||||
activeFolderId: string | null;
|
||||
activeTagIds: string[];
|
||||
activeDbTypes: DbType[];
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
ok: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
skippedRecords: { index: number; reason: string }[];
|
||||
}
|
||||
|
||||
// ─── DB Viewer Types ────────────────────────────────────────────
|
||||
|
||||
export interface TableInfo {
|
||||
name: string;
|
||||
schema: string;
|
||||
table_type: "TABLE" | "VIEW" | "MATERIALIZED VIEW";
|
||||
columns?: ColumnInfo[];
|
||||
}
|
||||
|
||||
export interface ColumnInfo {
|
||||
name: string;
|
||||
data_type: string;
|
||||
is_nullable: boolean;
|
||||
is_pk: boolean;
|
||||
is_fk: boolean;
|
||||
fk_ref: [string, string] | null;
|
||||
default_value: string | null;
|
||||
editable: boolean;
|
||||
is_generated: boolean;
|
||||
}
|
||||
|
||||
export interface IndexInfo {
|
||||
name: string;
|
||||
schema: string;
|
||||
table: string;
|
||||
definition: string;
|
||||
is_unique: boolean;
|
||||
method: string;
|
||||
columns: string[];
|
||||
size_bytes: number | null;
|
||||
tablespace: string | null;
|
||||
}
|
||||
|
||||
export interface ConstraintInfo {
|
||||
name: string;
|
||||
schema: string;
|
||||
table: string;
|
||||
contype: "CHECK" | "UNIQUE" | "EXCLUSION";
|
||||
definition: string;
|
||||
deferrable: boolean;
|
||||
validated: boolean;
|
||||
columns: string[];
|
||||
}
|
||||
|
||||
export interface RecentConnection {
|
||||
connection_id: string;
|
||||
opened_at: string;
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
columns: ColumnInfo[];
|
||||
rows: unknown[][];
|
||||
total_rows: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
execution_time_ms?: number | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export type ChangeStatus = "pending" | "applied" | "error";
|
||||
|
||||
export type ChangeItemType =
|
||||
| "create_table"
|
||||
| "alter_table"
|
||||
| "drop_table"
|
||||
| "insert"
|
||||
| "update"
|
||||
| "delete"
|
||||
| "create_index"
|
||||
| "drop_index"
|
||||
| "bulk_insert"
|
||||
| "empty_table"
|
||||
| "rebuild_table"
|
||||
| "ddl";
|
||||
|
||||
export interface ChangeItem {
|
||||
type: ChangeItemType;
|
||||
sql: string;
|
||||
status: ChangeStatus;
|
||||
error?: string | null;
|
||||
id: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface DbViewerTab {
|
||||
id: string;
|
||||
connection_id: string;
|
||||
title: string;
|
||||
query?: string | null;
|
||||
result?: QueryResult | null;
|
||||
changes?: ChangeItem[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface FunctionInfo {
|
||||
name: string;
|
||||
schema: string;
|
||||
return_type: string;
|
||||
argument_types: string[];
|
||||
argument_names: string[];
|
||||
argument_modes: string[];
|
||||
language: string;
|
||||
source: string | null;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
export interface TriggerInfo {
|
||||
name: string;
|
||||
schema: string;
|
||||
table_schema: string;
|
||||
table_name: string;
|
||||
event_manipulation: string;
|
||||
action_timing: string;
|
||||
action_orientation: string;
|
||||
action_statement: string;
|
||||
enabled: string;
|
||||
}
|
||||
|
||||
export interface SequenceInfo {
|
||||
name: string;
|
||||
schema: string;
|
||||
start_value: string;
|
||||
min_value: string;
|
||||
max_value: string;
|
||||
increment: string;
|
||||
current_value: string;
|
||||
cycle: boolean;
|
||||
}
|
||||
|
||||
export interface EnumInfo {
|
||||
name: string;
|
||||
schema: string;
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
export interface ExtensionInfo {
|
||||
name: string;
|
||||
schema: string;
|
||||
version: string;
|
||||
comment: string | null;
|
||||
}
|
||||
|
||||
export interface ConnectionTestResult {
|
||||
ok: boolean;
|
||||
error?: string | null;
|
||||
server_version?: string | null;
|
||||
latency_ms?: number | null;
|
||||
}
|
||||
|
||||
// ─── Backup Types ────────────────────────────────────────────────
|
||||
|
||||
export interface BackupOptions {
|
||||
format: "plain" | "custom" | "tar" | "directory";
|
||||
filePath: string;
|
||||
schema?: string;
|
||||
tables?: string[];
|
||||
noOwner: boolean;
|
||||
}
|
||||
|
||||
export interface RestoreOptions {
|
||||
format: string;
|
||||
filePath: string;
|
||||
clean: boolean;
|
||||
schema?: string;
|
||||
}
|
||||
|
||||
export interface SyncOptions {
|
||||
sourceConnectionId: string;
|
||||
targetConnectionId: string;
|
||||
schema?: string;
|
||||
tables?: string[];
|
||||
dbType: DbType;
|
||||
}
|
||||
|
||||
export interface PgToolStatus {
|
||||
pg_dump_found: boolean;
|
||||
pg_restore_found: boolean;
|
||||
pg_dump_version: string | null;
|
||||
pg_restore_version: string | null;
|
||||
pg_dump_source: string | null;
|
||||
pg_restore_source: string | null;
|
||||
}
|
||||
|
||||
// ─── Backup Types: MySQL / SQLite / Settings (v0.7.8) ───────────
|
||||
// NOTE: the Rust `MySqlToolStatus` model is `#[serde(rename_all = "camelCase")]`,
|
||||
// so these interfaces use camelCase keys to match the actual IPC payloads.
|
||||
|
||||
export interface MySqlToolStatus {
|
||||
mysqldumpFound: boolean;
|
||||
mysqlFound: boolean;
|
||||
mysqldumpVersion: string | null;
|
||||
mysqlVersion: string | null;
|
||||
mysqldumpSource: string | null;
|
||||
mysqlSource: string | null;
|
||||
}
|
||||
|
||||
export interface MySqlBackupOptions {
|
||||
database: string;
|
||||
filePath: string;
|
||||
singleTransaction: boolean;
|
||||
noData: boolean;
|
||||
routines: boolean;
|
||||
triggers: boolean;
|
||||
events: boolean;
|
||||
}
|
||||
|
||||
export interface MySqlRestoreOptions {
|
||||
database: string;
|
||||
filePath: string;
|
||||
clean: boolean;
|
||||
}
|
||||
|
||||
export interface SqliteBackupOptions {
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface SqliteRestoreOptions {
|
||||
filePath: string;
|
||||
clean: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsExport {
|
||||
schemaVersion: number;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
export type PgObjectType =
|
||||
| "table" | "view" | "materialized view" | "function" | "procedure"
|
||||
| "trigger" | "sequence" | "enum" | "extension" | "index" | "constraint";
|
||||
|
||||
export type ObjectType =
|
||||
| "functions"
|
||||
| "triggers"
|
||||
| "sequences"
|
||||
| "enums"
|
||||
| "extensions"
|
||||
| "indexes"
|
||||
| "constraints"
|
||||
| "procedures"
|
||||
| "roles";
|
||||
|
||||
export interface ObjectSearchHit {
|
||||
name: string;
|
||||
schema: string;
|
||||
object_type: string; // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT
|
||||
}
|
||||
|
||||
export interface DependencyInfo {
|
||||
deptype: string;
|
||||
class: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface BackupJob {
|
||||
id: string;
|
||||
connection_id: string;
|
||||
type: "dump" | "restore" | "sync";
|
||||
format: string | null;
|
||||
file_path: string | null;
|
||||
source_connection_id: string | null;
|
||||
status: "running" | "completed" | "failed" | "cancelled";
|
||||
error_message: string | null;
|
||||
size_bytes: number | null;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
// ─── Roles / Privileges / Maintenance (v0.7.7) ──────────────────
|
||||
|
||||
export interface RoleMembership {
|
||||
role: string;
|
||||
member: string;
|
||||
grantor: string;
|
||||
admin_option: boolean;
|
||||
}
|
||||
|
||||
export interface RoleInfo {
|
||||
name: string;
|
||||
superuser: boolean;
|
||||
inherit: boolean;
|
||||
create_db: boolean;
|
||||
create_role: boolean;
|
||||
can_login: boolean;
|
||||
replication: boolean;
|
||||
bypass_rls: boolean;
|
||||
connection_limit: number;
|
||||
valid_until: string | null;
|
||||
memberships: RoleMembership[];
|
||||
}
|
||||
|
||||
export interface PrivilegeEntry {
|
||||
object_class: "table" | "sequence" | "routine" | "schema" | "database";
|
||||
schema: string | null;
|
||||
name: string;
|
||||
privileges: string[];
|
||||
grantable: boolean;
|
||||
}
|
||||
|
||||
export interface RebuildReadiness {
|
||||
ok: boolean;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface MaintenanceResult {
|
||||
duration_ms: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TablespaceInfo {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ─── Schema Visualizer Types ────────────────────────────────────
|
||||
|
||||
export interface GraphColumn {
|
||||
name: string;
|
||||
data_type: string;
|
||||
is_pk: boolean;
|
||||
is_fk: boolean;
|
||||
is_unique: boolean;
|
||||
is_nullable: boolean;
|
||||
/** [referenced_schema, referenced_table, referenced_column] */
|
||||
fk_ref: [string, string, string] | null;
|
||||
}
|
||||
|
||||
export interface TableNode {
|
||||
name: string;
|
||||
schema: string;
|
||||
table_type: string;
|
||||
columns: GraphColumn[];
|
||||
}
|
||||
|
||||
export interface Relationship {
|
||||
source_schema: string;
|
||||
source_table: string;
|
||||
source_column: string;
|
||||
target_schema: string;
|
||||
target_table: string;
|
||||
target_column: string;
|
||||
/** Inferred cardinality: "1:1" | "1:N" | "N:M" */
|
||||
cardinality: string;
|
||||
}
|
||||
|
||||
export interface SchemaGraph {
|
||||
tables: TableNode[];
|
||||
relationships: Relationship[];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { INPUT_ROUNDING } from "./uiConstants";
|
||||
|
||||
describe("uiConstants", () => {
|
||||
it("exposes a rounded (non-pill) radius class for inputs", () => {
|
||||
expect(INPUT_ROUNDING).toBe("rounded-lg");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Shared Tailwind radius class for all form controls (inputs, selects, textareas).
|
||||
* Pill-shaped (`rounded-full`) controls were retired in v0.7.0 in favor of a
|
||||
* rounded rectangle. Import this constant so the radius is defined once. */
|
||||
export const INPUT_ROUNDING = "rounded-lg";
|
||||
@@ -0,0 +1,440 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
validateConnectionInput,
|
||||
validateFolderInput,
|
||||
validateTagInput,
|
||||
filterConnections,
|
||||
getDescendantFolderIds,
|
||||
getFolderPathLabel,
|
||||
isDestructiveQuery,
|
||||
isSchemaModifyingQuery,
|
||||
pickDefaultSchema,
|
||||
} from "./utils";
|
||||
import type { Connection, Folder, Tag } from "./types";
|
||||
|
||||
const makeConnection = (over: Partial<Connection> = {}): Connection => ({
|
||||
id: "c1",
|
||||
name: "Prod DB",
|
||||
db_type: "postgresql",
|
||||
host: "prod.example.com",
|
||||
port: 5432,
|
||||
username: "admin",
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
environment: null,
|
||||
favorite: false,
|
||||
created_at: "2026-07-26T00:00:00Z",
|
||||
updated_at: "2026-07-26T00:00:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("validateConnectionInput", () => {
|
||||
it("accepts a valid postgresql connection", () => {
|
||||
const result = validateConnectionInput({
|
||||
name: "Prod DB",
|
||||
db_type: "postgresql",
|
||||
host: "prod.example.com",
|
||||
port: 5432,
|
||||
username: "admin",
|
||||
folder_id: null,
|
||||
tag_ids: [],
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty name", () => {
|
||||
const result = validateConnectionInput({
|
||||
name: "",
|
||||
db_type: "postgresql",
|
||||
host: "h",
|
||||
port: 5432,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("name");
|
||||
});
|
||||
|
||||
it("rejects name longer than 100 chars", () => {
|
||||
const result = validateConnectionInput({
|
||||
name: "x".repeat(101),
|
||||
db_type: "postgresql",
|
||||
host: "h",
|
||||
port: 5432,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid db_type", () => {
|
||||
const result = validateConnectionInput({
|
||||
name: "X",
|
||||
db_type: "mongodb" as any,
|
||||
host: "h",
|
||||
port: 5432,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("db_type");
|
||||
});
|
||||
|
||||
it("rejects port out of range", () => {
|
||||
const result = validateConnectionInput({
|
||||
name: "X",
|
||||
db_type: "postgresql",
|
||||
host: "h",
|
||||
port: 99999,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("allows null port for sqlite", () => {
|
||||
const result = validateConnectionInput({
|
||||
name: "Local",
|
||||
db_type: "sqlite",
|
||||
host: "/data/analytics.db",
|
||||
port: null,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects host longer than 255 chars", () => {
|
||||
const result = validateConnectionInput({
|
||||
name: "X",
|
||||
db_type: "postgresql",
|
||||
host: "h".repeat(256),
|
||||
port: 5432,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
describe("SSH/SSL validation", () => {
|
||||
it("rejects SSH host longer than 255 chars", () => {
|
||||
const result = validateConnectionInput({
|
||||
name: "Test",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
ssh_host: "x".repeat(256),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("SSH host");
|
||||
});
|
||||
|
||||
it("rejects SSH port below 1", () => {
|
||||
const result = validateConnectionInput({
|
||||
name: "Test",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
ssh_host: "bastion.example.com",
|
||||
ssh_port: 0,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("SSH port");
|
||||
});
|
||||
|
||||
it("rejects SSH port above 65535", () => {
|
||||
const result = validateConnectionInput({
|
||||
name: "Test",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
ssh_host: "bastion.example.com",
|
||||
ssh_port: 70000,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("SSH port");
|
||||
});
|
||||
|
||||
it("accepts valid SSH port", () => {
|
||||
const result = validateConnectionInput({
|
||||
name: "Test",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
ssh_host: "bastion.example.com",
|
||||
ssh_port: 2222,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts null SSH port", () => {
|
||||
const result = validateConnectionInput({
|
||||
name: "Test",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
ssh_host: "bastion.example.com",
|
||||
ssh_port: null,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects SSH user longer than 100 chars", () => {
|
||||
const result = validateConnectionInput({
|
||||
name: "Test",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
ssh_host: "bastion.example.com",
|
||||
ssh_user: "u".repeat(101),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("SSH user");
|
||||
});
|
||||
|
||||
it("rejects invalid ssl_mode", () => {
|
||||
const result = validateConnectionInput({
|
||||
name: "Test",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
ssl_mode: "invalid" as any,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("SSL mode");
|
||||
});
|
||||
|
||||
it("accepts valid ssl_mode values", () => {
|
||||
const validModes = ["disable", "require", "verify-ca", "verify-full"];
|
||||
for (const mode of validModes) {
|
||||
const result = validateConnectionInput({
|
||||
name: "Test",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
ssl_mode: mode as any,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts input without SSH fields", () => {
|
||||
const result = validateConnectionInput({
|
||||
name: "Test",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateFolderInput", () => {
|
||||
it("accepts valid folder", () => {
|
||||
expect(validateFolderInput({ name: "Work", parent_id: null }).ok).toBe(true);
|
||||
});
|
||||
it("rejects empty name", () => {
|
||||
expect(validateFolderInput({ name: "", parent_id: null }).ok).toBe(false);
|
||||
});
|
||||
it("rejects name longer than 100 chars", () => {
|
||||
expect(validateFolderInput({ name: "x".repeat(101), parent_id: null }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTagInput", () => {
|
||||
it("accepts valid tag", () => {
|
||||
expect(validateTagInput({ name: "production", color: "#ef4444" }).ok).toBe(true);
|
||||
});
|
||||
it("rejects empty name", () => {
|
||||
expect(validateTagInput({ name: "", color: "#ef4444" }).ok).toBe(false);
|
||||
});
|
||||
it("rejects name longer than 50 chars", () => {
|
||||
expect(validateTagInput({ name: "x".repeat(51), color: "#ef4444" }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDescendantFolderIds", () => {
|
||||
const folders: Folder[] = [
|
||||
{ id: "f1", name: "root", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
|
||||
{ id: "f2", name: "child", parent_id: "f1", tag_ids: [], created_at: "", updated_at: "" },
|
||||
{ id: "f3", name: "grandchild", parent_id: "f2", tag_ids: [], created_at: "", updated_at: "" },
|
||||
{ id: "f4", name: "other", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
|
||||
];
|
||||
it("returns all descendant ids including self", () => {
|
||||
expect(getDescendantFolderIds(folders, "f1").sort()).toEqual(["f1", "f2", "f3"]);
|
||||
});
|
||||
it("returns only self for leaf folder", () => {
|
||||
expect(getDescendantFolderIds(folders, "f3")).toEqual(["f3"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterConnections", () => {
|
||||
const tags: Tag[] = [
|
||||
{ id: "t1", name: "production", color: "#ef4444", created_at: "" },
|
||||
{ id: "t2", name: "cache", color: "#3b82f6", created_at: "" },
|
||||
];
|
||||
const conns: Connection[] = [
|
||||
makeConnection({ id: "c1", name: "Prod", host: "prod.example.com", db_type: "postgresql", tag_ids: ["t1"], folder_id: "f1" }),
|
||||
makeConnection({ id: "c2", name: "Redis", host: "redis.internal", db_type: "redis", tag_ids: ["t2"], folder_id: "f2" }),
|
||||
];
|
||||
it("filters by search query on name", () => {
|
||||
expect(filterConnections(conns, tags, { query: "prod" })).toEqual([conns[0]]);
|
||||
});
|
||||
it("filters by search query on host", () => {
|
||||
expect(filterConnections(conns, tags, { query: "redis.internal" })).toEqual([conns[1]]);
|
||||
});
|
||||
it("search is case-insensitive", () => {
|
||||
expect(filterConnections(conns, tags, { query: "PROD" })).toEqual([conns[0]]);
|
||||
});
|
||||
it("filters by tag id", () => {
|
||||
expect(filterConnections(conns, tags, { query: "", activeTagIds: ["t1"] })).toEqual([conns[0]]);
|
||||
});
|
||||
it("filters by db_type", () => {
|
||||
expect(filterConnections(conns, tags, { query: "", activeDbTypes: ["redis"] })).toEqual([conns[1]]);
|
||||
});
|
||||
it("returns all when no filters", () => {
|
||||
expect(filterConnections(conns, tags, { query: "" })).toEqual(conns);
|
||||
});
|
||||
it("search matches tag name", () => {
|
||||
expect(filterConnections(conns, tags, { query: "cache" })).toEqual([conns[1]]);
|
||||
});
|
||||
|
||||
it("matches ANY selected tag (OR semantics)", () => {
|
||||
// c1 has t1, c2 has t2. Selecting both t1+t2 should return BOTH connections.
|
||||
const result = filterConnections(conns, tags, { query: "", activeTagIds: ["t1", "t2"] });
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("matches when connection has only one of multiple selected tags", () => {
|
||||
const c3 = makeConnection({ id: "c3", name: "Cache", host: "cache.local", db_type: "postgresql", tag_ids: ["t1"], folder_id: "f1", environment: "production" });
|
||||
const result = filterConnections([...conns, c3], tags, { query: "", activeTagIds: ["t1", "t2"] });
|
||||
// c3 only has t1 but should still show
|
||||
expect(result.map((c) => c.id)).toContain("c3");
|
||||
});
|
||||
|
||||
it("filters by environment", () => {
|
||||
const c1 = makeConnection({ id: "c1", name: "Prod", db_type: "postgresql", tag_ids: [], environment: "production" });
|
||||
const c2 = makeConnection({ id: "c2", name: "Dev", db_type: "postgresql", tag_ids: [], environment: "development" });
|
||||
const result = filterConnections([c1, c2], tags, { query: "", activeEnvironment: "production" });
|
||||
expect(result).toEqual([c1]);
|
||||
});
|
||||
|
||||
it("activeEnvironment 'none' filters to connections without environment", () => {
|
||||
const c1 = makeConnection({ id: "c1", name: "Prod", db_type: "postgresql", tag_ids: [], environment: "production" });
|
||||
const c2 = makeConnection({ id: "c2", name: "NoEnv", db_type: "postgresql", tag_ids: [], environment: null });
|
||||
const result = filterConnections([c1, c2], tags, { query: "", activeEnvironment: "none" });
|
||||
expect(result).toEqual([c2]);
|
||||
});
|
||||
|
||||
it("environment null/undefined means no filtering", () => {
|
||||
const c1 = makeConnection({ id: "c1", name: "Prod", db_type: "postgresql", tag_ids: [], environment: "production" });
|
||||
const result = filterConnections([c1], tags, { query: "" });
|
||||
expect(result).toEqual([c1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFolderPathLabel", () => {
|
||||
const folders: Folder[] = [
|
||||
{ id: "a", name: "FolderA", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
|
||||
{ id: "b", name: "Folder1", parent_id: "a", tag_ids: [], created_at: "", updated_at: "" },
|
||||
];
|
||||
|
||||
it("returns root label for null", () => {
|
||||
expect(getFolderPathLabel(folders, null)).toBe("Root");
|
||||
});
|
||||
|
||||
it("returns nested path", () => {
|
||||
expect(getFolderPathLabel(folders, "b")).toBe("FolderA → Folder1");
|
||||
});
|
||||
|
||||
it("returns root label for non-existent folder", () => {
|
||||
expect(getFolderPathLabel(folders, "missing")).toBe("Root");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDestructiveQuery", () => {
|
||||
it("returns true for INSERT", () => {
|
||||
expect(isDestructiveQuery("INSERT INTO users VALUES (1)")).toBe(true);
|
||||
});
|
||||
it("returns true for UPDATE", () => {
|
||||
expect(isDestructiveQuery("UPDATE users SET name = 'x'")).toBe(true);
|
||||
});
|
||||
it("returns true for DELETE", () => {
|
||||
expect(isDestructiveQuery("DELETE FROM users")).toBe(true);
|
||||
});
|
||||
it("returns true for DROP", () => {
|
||||
expect(isDestructiveQuery("DROP TABLE users")).toBe(true);
|
||||
});
|
||||
it("returns true for ALTER", () => {
|
||||
expect(isDestructiveQuery("ALTER TABLE users ADD COLUMN age int")).toBe(true);
|
||||
});
|
||||
it("returns true for TRUNCATE", () => {
|
||||
expect(isDestructiveQuery("TRUNCATE TABLE users")).toBe(true);
|
||||
});
|
||||
it("returns true for CREATE", () => {
|
||||
expect(isDestructiveQuery("CREATE TABLE t (id int)")).toBe(true);
|
||||
});
|
||||
it("returns true for REPLACE", () => {
|
||||
expect(isDestructiveQuery("REPLACE INTO users VALUES (1)")).toBe(true);
|
||||
});
|
||||
it("returns false for SELECT", () => {
|
||||
expect(isDestructiveQuery("SELECT * FROM users")).toBe(false);
|
||||
});
|
||||
it("returns false for EXPLAIN", () => {
|
||||
expect(isDestructiveQuery("EXPLAIN SELECT * FROM users")).toBe(false);
|
||||
});
|
||||
it("returns false for WITH (CTE SELECT)", () => {
|
||||
expect(isDestructiveQuery("WITH cte AS (SELECT 1) SELECT * FROM cte")).toBe(false);
|
||||
});
|
||||
it("returns false for SHOW", () => {
|
||||
expect(isDestructiveQuery("SHOW search_path")).toBe(false);
|
||||
});
|
||||
it("strips line comments before checking", () => {
|
||||
expect(isDestructiveQuery("-- harmless comment\nDROP TABLE users")).toBe(true);
|
||||
});
|
||||
it("strips block comments before checking", () => {
|
||||
expect(isDestructiveQuery("/* harmless */ DROP TABLE users")).toBe(true);
|
||||
});
|
||||
it("returns false for empty string", () => {
|
||||
expect(isDestructiveQuery("")).toBe(false);
|
||||
});
|
||||
it("returns false for whitespace only", () => {
|
||||
expect(isDestructiveQuery(" \n\t ")).toBe(false);
|
||||
});
|
||||
it("is case-insensitive", () => {
|
||||
expect(isDestructiveQuery("drop table users")).toBe(true);
|
||||
expect(isDestructiveQuery("Drop Table users")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickDefaultSchema", () => {
|
||||
it("prefers the public schema when available", () => {
|
||||
expect(pickDefaultSchema(["app", "public"])).toBe("public");
|
||||
expect(pickDefaultSchema(["public"])).toBe("public");
|
||||
});
|
||||
|
||||
it("prefers the main schema (SQLite) when available", () => {
|
||||
expect(pickDefaultSchema(["main"])).toBe("main");
|
||||
expect(pickDefaultSchema(["other", "main"])).toBe("main");
|
||||
});
|
||||
|
||||
it("falls back to the first schema when no conventional one exists", () => {
|
||||
expect(pickDefaultSchema(["analytics", "app"])).toBe("analytics");
|
||||
expect(pickDefaultSchema(["zzz"])).toBe("zzz");
|
||||
});
|
||||
|
||||
it("returns null for an empty list", () => {
|
||||
expect(pickDefaultSchema([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSchemaModifyingQuery", () => {
|
||||
it("returns true for CREATE / DROP / ALTER / TRUNCATE", () => {
|
||||
expect(isSchemaModifyingQuery("CREATE TABLE t (id int)")).toBe(true);
|
||||
expect(isSchemaModifyingQuery("DROP TABLE t")).toBe(true);
|
||||
expect(isSchemaModifyingQuery("ALTER TABLE t ADD COLUMN c int")).toBe(true);
|
||||
expect(isSchemaModifyingQuery("TRUNCATE TABLE t")).toBe(true);
|
||||
});
|
||||
it("returns false for data-only and read statements", () => {
|
||||
expect(isSchemaModifyingQuery("SELECT * FROM t")).toBe(false);
|
||||
expect(isSchemaModifyingQuery("INSERT INTO t VALUES (1)")).toBe(false);
|
||||
expect(isSchemaModifyingQuery("UPDATE t SET c = 1")).toBe(false);
|
||||
expect(isSchemaModifyingQuery("DELETE FROM t")).toBe(false);
|
||||
expect(isSchemaModifyingQuery("REPLACE INTO t VALUES (1)")).toBe(false);
|
||||
expect(isSchemaModifyingQuery("WITH cte AS (SELECT 1) SELECT * FROM cte")).toBe(false);
|
||||
});
|
||||
it("strips comments before checking", () => {
|
||||
expect(isSchemaModifyingQuery("-- note\nCREATE TABLE t (id int)")).toBe(true);
|
||||
expect(isSchemaModifyingQuery("/* x */ SELECT 1")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import type {
|
||||
Connection,
|
||||
DbType,
|
||||
Folder,
|
||||
Tag,
|
||||
ConnectionInput,
|
||||
FolderInput,
|
||||
TagInput,
|
||||
ValidationResult,
|
||||
} from "./types";
|
||||
|
||||
// ─── Data type abbreviations ─────────────────────────────
|
||||
|
||||
const TYPE_ABBREV: Record<string, string> = {
|
||||
"integer": "int",
|
||||
"bigint": "int8",
|
||||
"smallint": "int2",
|
||||
"character varying": "varchar",
|
||||
"character": "char",
|
||||
"timestamp with time zone": "timestamptz",
|
||||
"timestamp without time zone": "timestamp",
|
||||
"time with time zone": "timetz",
|
||||
"time without time zone": "time",
|
||||
"boolean": "bool",
|
||||
"double precision": "float8",
|
||||
"real": "float4",
|
||||
};
|
||||
|
||||
export function abbreviateType(dataType: string): string {
|
||||
const lower = dataType.toLowerCase();
|
||||
return TYPE_ABBREV[lower] ?? dataType;
|
||||
}
|
||||
|
||||
const VALID_DB_TYPES: DbType[] = ["postgresql", "mysql", "sqlite", "redis"];
|
||||
|
||||
export function validateConnectionInput(input: ConnectionInput): ValidationResult {
|
||||
if (!input.name || input.name.length === 0) return { ok: false, error: "name is required" };
|
||||
if (input.name.length > 100) return { ok: false, error: "name must be 100 chars or fewer" };
|
||||
if (!VALID_DB_TYPES.includes(input.db_type))
|
||||
return { ok: false, error: `db_type must be one of: ${VALID_DB_TYPES.join(", ")}` };
|
||||
if (!input.host || input.host.length === 0) return { ok: false, error: "host is required" };
|
||||
if (input.host.length > 255) return { ok: false, error: "host must be 255 chars or fewer" };
|
||||
if (input.db_type !== "sqlite") {
|
||||
if (input.port === null || input.port === undefined)
|
||||
return { ok: false, error: "port is required for this db_type" };
|
||||
if (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535)
|
||||
return { ok: false, error: "port must be an integer between 1 and 65535" };
|
||||
}
|
||||
if (input.username && input.username.length > 100)
|
||||
return { ok: false, error: "username must be 100 chars or fewer" };
|
||||
|
||||
// SSH tunnel validation
|
||||
if (input.ssh_host && input.ssh_host.length > 255)
|
||||
return { ok: false, error: "SSH host must be 255 chars or fewer" };
|
||||
if (input.ssh_port !== undefined && input.ssh_port !== null) {
|
||||
if (!Number.isInteger(input.ssh_port) || input.ssh_port < 1 || input.ssh_port > 65535)
|
||||
return { ok: false, error: "SSH port must be between 1 and 65535" };
|
||||
}
|
||||
if (input.ssh_user && input.ssh_user.length > 100)
|
||||
return { ok: false, error: "SSH user must be 100 chars or fewer" };
|
||||
|
||||
// SSL/TLS validation
|
||||
if (input.ssl_mode && !["disable", "require", "verify-ca", "verify-full"].includes(input.ssl_mode))
|
||||
return { ok: false, error: "SSL mode must be one of: disable, require, verify-ca, verify-full" };
|
||||
|
||||
return { ok: true, error: "" };
|
||||
}
|
||||
|
||||
export function validateFolderInput(input: FolderInput): ValidationResult {
|
||||
if (!input.name || input.name.length === 0) return { ok: false, error: "name is required" };
|
||||
if (input.name.length > 100) return { ok: false, error: "name must be 100 chars or fewer" };
|
||||
return { ok: true, error: "" };
|
||||
}
|
||||
|
||||
export function validateTagInput(input: TagInput): ValidationResult {
|
||||
if (!input.name || input.name.length === 0) return { ok: false, error: "name is required" };
|
||||
if (input.name.length > 50) return { ok: false, error: "name must be 50 chars or fewer" };
|
||||
return { ok: true, error: "" };
|
||||
}
|
||||
|
||||
export function getDescendantFolderIds(folders: Folder[], rootId: string): string[] {
|
||||
const result = [rootId];
|
||||
const children = folders.filter((f) => f.parent_id === rootId);
|
||||
for (const child of children) {
|
||||
result.push(...getDescendantFolderIds(folders, child.id));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getFolderPath(folders: Folder[], folderId: string | null): Folder[] {
|
||||
const folderMap = new Map(folders.map((f) => [f.id, f]));
|
||||
const path: Folder[] = [];
|
||||
let current: Folder | undefined = folderId ? folderMap.get(folderId) : undefined;
|
||||
while (current) {
|
||||
path.unshift(current);
|
||||
current = current.parent_id ? folderMap.get(current.parent_id) : undefined;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function getFolderPathLabel(folders: Folder[], folderId: string | null): string {
|
||||
if (folderId === null) return "Root";
|
||||
const path = getFolderPath(folders, folderId);
|
||||
if (path.length === 0) return "Root";
|
||||
return path.map((f) => f.name).join(" → ");
|
||||
}
|
||||
|
||||
export function getChildFolders(folders: Folder[], parentId: string | null): Folder[] {
|
||||
return folders.filter((f) => f.parent_id === parentId);
|
||||
}
|
||||
|
||||
export function filterConnections(
|
||||
connections: Connection[],
|
||||
tags: Tag[],
|
||||
filter: {
|
||||
query: string;
|
||||
activeTagIds?: string[];
|
||||
activeDbTypes?: DbType[];
|
||||
activeEnvironment?: string | null;
|
||||
},
|
||||
): Connection[] {
|
||||
const q = filter.query.trim().toLowerCase();
|
||||
const tagIds = filter.activeTagIds ?? [];
|
||||
const dbTypes = filter.activeDbTypes ?? [];
|
||||
const activeEnvironment = filter.activeEnvironment;
|
||||
const tagNameById = new Map(tags.map((t) => [t.id, t.name.toLowerCase()]));
|
||||
|
||||
return connections.filter((c) => {
|
||||
if (dbTypes.length > 0 && !dbTypes.includes(c.db_type)) return false;
|
||||
if (tagIds.length > 0 && !tagIds.some((id) => c.tag_ids.includes(id))) return false;
|
||||
if (activeEnvironment !== undefined && activeEnvironment !== null && activeEnvironment !== "") {
|
||||
if (activeEnvironment === "none") {
|
||||
if (c.environment) return false;
|
||||
} else if (c.environment !== activeEnvironment) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (q.length > 0) {
|
||||
const tagNames = c.tag_ids.map((id) => tagNameById.get(id) ?? "").join(" ");
|
||||
const haystack = `${c.name} ${c.host} ${c.db_type} ${tagNames}`.toLowerCase();
|
||||
if (!haystack.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const DESTRUCTIVE_KEYWORDS = new Set([
|
||||
"INSERT", "UPDATE", "DELETE", "DROP", "ALTER",
|
||||
"TRUNCATE", "CREATE", "REPLACE",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Return the first significant keyword of `sql` (uppercased) after stripping
|
||||
* comments and collapsing whitespace, or null when there is none.
|
||||
*/
|
||||
export function firstSignificantKeyword(sql: string): string | null {
|
||||
// Strip block comments /* ... */
|
||||
let stripped = sql.replace(/\/\*[\s\S]*?\*\//g, " ");
|
||||
// Strip line comments -- ...
|
||||
stripped = stripped.replace(/--[^\n]*/g, " ");
|
||||
// Collapse whitespace
|
||||
const tokens = stripped.trim().split(/\s+/);
|
||||
if (tokens.length === 0 || tokens[0].length === 0) return null;
|
||||
return tokens[0].toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether `sql` is a data-modifying statement by checking the
|
||||
* first significant keyword after stripping comments and whitespace.
|
||||
*
|
||||
* This is a UX safety net, not a security boundary. The user is already
|
||||
* authenticated to their own database — the confirmation dialog prevents
|
||||
* accidental data loss, not malicious access.
|
||||
*/
|
||||
export function isDestructiveQuery(sql: string): boolean {
|
||||
const first = firstSignificantKeyword(sql);
|
||||
return first !== null && DESTRUCTIVE_KEYWORDS.has(first);
|
||||
}
|
||||
|
||||
const SCHEMA_MODIFYING_KEYWORDS = new Set([
|
||||
"CREATE", "DROP", "ALTER", "TRUNCATE",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Detect whether `sql` changes the database schema (DDL) by checking the
|
||||
* first significant keyword. Used to auto-refresh the schema tree after a
|
||||
* successful query run.
|
||||
*/
|
||||
export function isSchemaModifyingQuery(sql: string): boolean {
|
||||
const k = firstSignificantKeyword(sql);
|
||||
return k !== null && SCHEMA_MODIFYING_KEYWORDS.has(k);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the smart default schema for a freshly loaded database.
|
||||
*
|
||||
* Prefers conventional schemas (`public` for PostgreSQL, `main` for SQLite)
|
||||
* and otherwise falls back to the first schema returned by the backend
|
||||
* (which already excludes system schemas and is ordered alphabetically).
|
||||
*/
|
||||
export function pickDefaultSchema(schemas: string[]): string | null {
|
||||
if (schemas.length === 0) return null;
|
||||
for (const preferred of ["public", "main"]) {
|
||||
if (schemas.includes(preferred)) return preferred;
|
||||
}
|
||||
return schemas[0];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
describe("version", () => {
|
||||
it("declares v0.7.10 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.10");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { unzipSync } from "fflate";
|
||||
import { buildXlsx } from "./xlsx";
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
const cols: ColumnInfo[] = [
|
||||
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
|
||||
function sheetXML(out: Uint8Array): string {
|
||||
const files = unzipSync(out);
|
||||
return new TextDecoder().decode(files["xl/worksheets/sheet1.xml"]);
|
||||
}
|
||||
|
||||
describe("buildXlsx", () => {
|
||||
it("emits headers + rows as inline strings", () => {
|
||||
const out = buildXlsx([[1, "Alice"], [2, "Bob"]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain('t="inlineStr"');
|
||||
expect(xml).toContain("id");
|
||||
expect(xml).toContain("Alice");
|
||||
expect(xml).toContain("Bob");
|
||||
});
|
||||
|
||||
it("escapes XML-special characters in cell text", () => {
|
||||
const out = buildXlsx([[1, "a<b>&c"]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain("a<b>&c");
|
||||
expect(xml).not.toContain("a<b>&c");
|
||||
});
|
||||
|
||||
it("emits formula-triggering values as inline strings (no formula evaluation)", () => {
|
||||
const out = buildXlsx([[1, "=1+1"], [2, "+5"], [3, "@SUM"]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain("=1+1");
|
||||
expect(xml).not.toMatch(/<c[^>]*?><f>/);
|
||||
});
|
||||
|
||||
it("declares a column width per header", () => {
|
||||
const out = buildXlsx([[1, "x"]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain("<cols>");
|
||||
expect(xml).toContain("width=");
|
||||
});
|
||||
|
||||
it("renders null cells as empty inline strings", () => {
|
||||
const out = buildXlsx([[1, null]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain("inlineStr");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { zipSync, strToU8 } from "fflate";
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
/** Minimal OOXML spreadsheet: one sheet, every cell as an inline string
|
||||
* (t="inlineStr") so Excel never evaluates a cell as a formula — the
|
||||
* formula-injection mitigation required by the spec. */
|
||||
const XML_ESCAPES: [RegExp, string][] = [
|
||||
[/&/g, "&"],
|
||||
[/</g, "<"],
|
||||
[/>/g, ">"],
|
||||
[/"/g, """],
|
||||
];
|
||||
function esc(s: string): string {
|
||||
for (const [re, w] of XML_ESCAPES) s = s.replace(re, w);
|
||||
return s;
|
||||
}
|
||||
function colLetter(n: number): string {
|
||||
let s = "";
|
||||
for (let i = n; i > 0; i = Math.floor((i - 1) / 26)) s = String.fromCharCode(65 + ((i - 1) % 26)) + s;
|
||||
return s;
|
||||
}
|
||||
|
||||
function buildColsXML(columns: ColumnInfo[]): string {
|
||||
const maxW = columns.map((c) => Math.min(60, Math.max(8, c.name.length + 2)));
|
||||
return `<cols>${maxW.map((w, i) => `\n <col min="${i + 1}" max="${i + 1}" width="${w}" customWidth="1"/>`).join("")}\n</cols>`;
|
||||
}
|
||||
|
||||
function buildCellsXML(rows: unknown[][], columns: ColumnInfo[]): string {
|
||||
let cells = "";
|
||||
cells += `<row r="1">` + columns
|
||||
.map((c, i) => `<c r="${colLetter(i + 1)}1" t="inlineStr"><is><t>${esc(c.name)}</t></is></c>`)
|
||||
.join("") + `</row>`;
|
||||
rows.forEach((row, rIdx) => {
|
||||
const r = rIdx + 2;
|
||||
cells += `<row r="${r}">` + row
|
||||
.map((v, i) => {
|
||||
const ref = `${colLetter(i + 1)}${r}`;
|
||||
const t = v === null || v === undefined ? "" : String(v);
|
||||
return `<c r="${ref}" t="inlineStr"><is><t>${esc(t)}</t></is></c>`;
|
||||
})
|
||||
.join("") + `</row>`;
|
||||
});
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function buildXlsx(rows: unknown[][], columns: ColumnInfo[]): Uint8Array {
|
||||
const colsXML = buildColsXML(columns);
|
||||
const cells = buildCellsXML(rows, columns);
|
||||
|
||||
const sheet1 = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">${colsXML}<sheetData>${cells}</sheetData></worksheet>`;
|
||||
const workbook = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>`;
|
||||
const ct = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>`;
|
||||
const rels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>`;
|
||||
const rootRels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`;
|
||||
|
||||
return zipSync({
|
||||
"[Content_Types].xml": strToU8(ct),
|
||||
"_rels/.rels": strToU8(rootRels),
|
||||
"xl/workbook.xml": strToU8(workbook),
|
||||
"xl/_rels/workbook.xml.rels": strToU8(rels),
|
||||
"xl/worksheets/sheet1.xml": strToU8(sheet1),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user