import type { Connection, DbType, Folder, Tag, ConnectionInput, FolderInput, TagInput, ValidationResult, } from "./types"; // ─── Data type abbreviations ───────────────────────────── const TYPE_ABBREV: Record = { "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]; }