* feat: editor settings model + typed clamped defaults (Task 1) * feat: carry SSH config + ssh_password to backend DbConfig (Task 2) * feat: Change enum bulk/drop/empty + type-specific change payload builder (Task 3) * feat: csv parser + shared export util (Task 4) * feat: rustls TLS connector factory with modes + client auth (Task 5) * feat: real SSH tunnel manager (testable backend) + pool eviction hook (Task 6) * feat: table DDL fetch (sqlite + pg_dump arg builder) (Task 7) * feat: keychain SSH secrets + connection delete purge + tunnel lifecycle (Task 8) * feat: real SSH tunnel + TLS connect path for postgres/mysql (Task 9) * feat: execute_change bulk/drop/empty + get_table_ddl command (Task 10) * feat: fetch SSH secrets into dbConnect + save on connection form (Task 11) * feat: Editor settings tab UI (Task 12) * feat: QueryEditor applies editor settings live (Task 13) * feat: ImportDialog with CSV/JSON preview + column mapping (Task 14) * feat: table-menu export/empty/delete/import + queue labels + payload builder (Task 15) * fix: error sanitization, encrypted-key guard, row-indexed import errors, caps (Task 16) * docs: mark Editor Settings, SSH/SSL runtime, Data Import, table-menu loose ends shipped (Task 17) * feat: auto-refresh schema tree after schema-modifying SQL (query + queue drop) * fix: use theme-consistent red classes for danger menu items (text-error was undefined) * feat: changes queue as tab-bar popover + amber pending border * feat: redesign changes popover (visual/SQL toggle, cards, footer actions, Cmd+S) * refactor: drop per-card status label from changes popover cards * feat: green completion indicator on committed cards + auto-close tabs of dropped tables * docs: changes queue popover UX + auto schema refresh statuses
207 lines
7.7 KiB
TypeScript
207 lines
7.7 KiB
TypeScript
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];
|
|
} |