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:
2026-08-16 19:31:53 +08:00
parent d752642b4d
commit 6854d889c5
400 changed files with 857 additions and 149 deletions
+11
View File
@@ -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");
});
});
+97
View File
@@ -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')");
});
});
+98
View File
@@ -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 };
}
}
+435
View File
@@ -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");
}
});
});
+396
View File
@@ -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 });
}
+243
View File
@@ -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",
});
});
});
+137
View File
@@ -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}`;
}
}
+36
View File
@@ -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", ""]] });
});
});
+43
View File
@@ -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 };
}
+116
View File
@@ -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);
});
});
+46
View File
@@ -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;
}
+97
View File
@@ -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>
);
}
+72
View File
@@ -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");
});
});
+13
View File
@@ -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",
};
+51
View File
@@ -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;
}
});
});
+84
View File
@@ -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);
}
+55
View File
@@ -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");
});
});
+24
View File
@@ -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;
}
+65
View File
@@ -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");
});
});
+51
View File
@@ -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;
}
+17
View File
@@ -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 */ }
}
}
+80
View File
@@ -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),
};
},
});
+491
View File
@@ -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" } });
});
});
+312
View File
@@ -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,
});
}
+23
View File
@@ -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);
});
});
+9
View File
@@ -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");
}
+36
View File
@@ -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);
});
});
+96
View File
@@ -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.",
},
],
},
};
+18
View File
@@ -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);
});
});
+19
View File
@@ -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;
}
+39
View File
@@ -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);
});
});
+55
View File
@@ -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 };
}
+152
View File
@@ -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);
});
});
+172
View File
@@ -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];
}
+530
View File
@@ -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);
});
});
+480
View File
@@ -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[];
}
+8
View File
@@ -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");
});
});
+4
View File
@@ -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";
+440
View File
@@ -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);
});
});
+207
View File
@@ -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];
}
+8
View File
@@ -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");
});
});
+52
View File
@@ -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&lt;b&gt;&amp;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");
});
});
+63
View File
@@ -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, "&amp;"],
[/</g, "&lt;"],
[/>/g, "&gt;"],
[/"/g, "&quot;"],
];
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),
});
}