import { create } from "zustand"; import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo } from "../lib/types"; import { getDatabases, getSchemas, getTables } from "../lib/commands"; import type { ObjectKind, DdlParams } from "../lib/objectCrud"; // ─── Local types ──────────────────────────────────────────────── export type QueueStatus = "pending" | "cancelled" | "committed" | "failed"; export type FilterOperator = "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull"; export interface FilterRule { id: string; column: string; operator: FilterOperator; value: string; } export interface SortRule { id: string; column: string; order: "asc" | "desc"; } export interface QueueItem { id: string; type: ChangeItemType; sql: string; schema?: string; table?: string; primaryKey?: Record; oldData?: Record | null; newData?: Record | null; columns?: string[]; rows?: unknown[][]; status: QueueStatus; error?: string | null; description?: string | null; createdAt: number; } /** Payload carried by an "objectForm" tab — the kind + params for the * object create/edit form and the SQL toggle it renders. */ export interface ViewerFormTabPayload { kind: ObjectKind; params: DdlParams; title: string; description: string; mode: "create" | "edit"; } export interface ViewerTab { id: string; schema: string; table: string; page: number; pageSize: number; loading: boolean; error: string | null; data: QueryResult | null; columnFilter?: { column: string; value: string }; filterRules: FilterRule[]; sortRules: SortRule[]; hiddenColumns: string[]; smartSortApplied: boolean; tabType: "table" | "query" | "object" | "objectForm"; query?: string; objectType?: ObjectType | null; objectItem?: unknown; form?: ViewerFormTabPayload; } // ─── Auto-increment counters ─────────────────────────────────── let tabCounter = 0; let changeCounter = 0; const initialTab = (schema: string, table: string, defaultPageSize?: number): ViewerTab => ({ id: `tab-${++tabCounter}`, schema, table, page: 1, pageSize: defaultPageSize ?? 50, loading: true, error: null, data: null, filterRules: [], sortRules: [], hiddenColumns: [], smartSortApplied: false, tabType: "table", query: undefined, }); // ─── State interface ──────────────────────────────────────────── interface DbViewerState { tabs: ViewerTab[]; activeTabId: string | null; defaultPageSize: number; changesQueue: QueueItem[]; changesPanelExpanded: boolean; databases: string[]; schemas: string[]; tables: TableInfo[]; schemaTreeLoading: boolean; currentDatabase: string | null; currentSchema: string | null; objectSearchOpen: boolean; selectedObjectType: ObjectType | null; /** View the DB viewer should switch to ("db-viewer" | "objects" | ...). * Set by the search palette before closing; consumed (and cleared) by * DbViewerScreen's navigation effect. */ requestedView: string | null; functions: FunctionInfo[] | null; triggers: TriggerInfo[] | null; sequences: SequenceInfo[] | null; enums: EnumInfo[] | null; extensions: ExtensionInfo[] | null; indexes: IndexInfo[] | null; setSchemaTreeLoading: (loading: boolean) => void; constraints: ConstraintInfo[] | null; roles: RoleInfo[]; // Actions openTab: (schema: string, table: string, forceNew?: boolean) => void; openQueryTab: () => void; openObjectTab: (objectType: ObjectType, schema: string, name: string, item?: unknown) => void; openFormTab: (opts: { kind: ObjectKind; schema: string; name: string; title: string; description: string; mode: "create" | "edit"; params: DdlParams; }) => void; updateFormTabParams: (tabId: string, params: DdlParams) => void; setDefaultPageSize: (size: number) => void; closeTab: (tabId: string) => void; reorderTab: (fromIndex: number, toIndex: number) => void; closeTabsForTable: (schema: string, table: string) => void; setActiveTab: (tabId: string) => void; setPage: (tabId: string, page: number) => void; setPageSize: (tabId: string, pageSize: number) => void; setTabData: (tabId: string, data: QueryResult) => void; setTabLoading: (tabId: string, loading: boolean) => void; setTabError: (tabId: string, error: string) => void; setColumnFilter: (tabId: string, column: string, value: string) => void; clearColumnFilter: (tabId: string) => void; setFilterRules: (tabId: string, rules: FilterRule[]) => void; setSortRules: (tabId: string, rules: SortRule[]) => void; setHiddenColumns: (tabId: string, columns: string[]) => void; toggleHiddenColumn: (tabId: string, column: string) => void; setSmartSortApplied: (tabId: string) => void; addChange: (input: { type: ChangeItemType; sql?: string; schema?: string; table?: string; primaryKey?: Record; oldData?: Record | null; newData?: Record | null; columns?: string[]; rows?: unknown[][]; description?: string | null; }) => void; cancelChange: (changeId: string) => void; removeChange: (changeId: string) => void; clearChanges: () => void; markChangeCommitted: (changeId: string) => void; markChangeFailed: (changeId: string, error: string) => void; toggleChangesPanel: () => void; setCurrentDatabase: (db: string | null) => void; setCurrentSchema: (schema: string | null) => void; setObjectSearchOpen: (open: boolean) => void; setSelectedObjectType: (t: ObjectType | null) => void; setRequestedView: (view: string | null) => void; setFunctions: (functions: FunctionInfo[]) => void; setTriggers: (triggers: TriggerInfo[]) => void; setSequences: (sequences: SequenceInfo[]) => void; setEnums: (enums: EnumInfo[]) => void; setExtensions: (extensions: ExtensionInfo[]) => void; setIndexes: (indexes: IndexInfo[]) => void; setConstraints: (constraints: ConstraintInfo[]) => void; setRoles: (roles: RoleInfo[]) => void; stageCellEdit: (input: { tabId: string; schema: string; table: string; primaryKey: Record; oldData: Record; newData: Record; description?: string; }) => void; populate: ( databases: string[], schemas: string[], tables: TableInfo[], ) => void; refreshTree: (connectionId: string, schema?: string) => Promise; reset: () => void; } // ─── Initial state ────────────────────────────────────────────── const initialState = { tabs: [] as ViewerTab[], activeTabId: null as string | null, defaultPageSize: 50, changesQueue: [] as QueueItem[], changesPanelExpanded: true, databases: [] as string[], schemas: [] as string[], tables: [] as TableInfo[], currentDatabase: null as string | null, currentSchema: null as string | null, objectSearchOpen: false, selectedObjectType: null as ObjectType | null, requestedView: null as string | null, functions: null as FunctionInfo[] | null, triggers: null as TriggerInfo[] | null, sequences: null as SequenceInfo[] | null, enums: null as EnumInfo[] | null, extensions: null as ExtensionInfo[] | null, indexes: null as IndexInfo[] | null, constraints: null as ConstraintInfo[] | null, roles: [] as RoleInfo[], schemaTreeLoading: false, }; // ─── Store ────────────────────────────────────────────────────── export const useDbViewerStore = create((set, get) => ({ ...initialState, openTab: (schema, table, forceNew = false) => { const { tabs } = get(); // Dedup: if not forceNew and an identical tab exists, just activate it if (!forceNew) { const existing = tabs.find( (t) => t.schema === schema && t.table === table, ); if (existing) { set({ activeTabId: existing.id }); return; } } const tab = initialTab(schema, table, get().defaultPageSize); set({ tabs: [...tabs, tab], activeTabId: tab.id }); }, openQueryTab: () => { const { tabs, currentSchema } = get(); const queryCount = tabs.filter((t) => t.tabType === "query").length; const tab: ViewerTab = { id: `tab-${++tabCounter}`, schema: currentSchema ?? "public", table: queryCount === 0 ? "Query" : `Query ${queryCount + 1}`, page: 1, pageSize: get().defaultPageSize, loading: false, error: null, data: null, filterRules: [], sortRules: [], hiddenColumns: [], smartSortApplied: false, tabType: "query", query: "", }; set({ tabs: [...tabs, tab], activeTabId: tab.id }); }, openObjectTab: (objectType, schema, name, item) => { const { tabs } = get(); // Dedup on the object's identity (objectType + schema + name); an object // tab is distinct from a table tab of the same name. const existing = tabs.find( (t) => t.tabType === "object" && t.objectType === objectType && t.schema === schema && t.table === name, ); if (existing) { set({ activeTabId: existing.id }); return; } const tab: ViewerTab = { id: `tab-${++tabCounter}`, schema, table: name, page: 1, pageSize: get().defaultPageSize, loading: false, error: null, data: null, filterRules: [], sortRules: [], hiddenColumns: [], smartSortApplied: false, tabType: "object", objectType, objectItem: item, }; set({ tabs: [...tabs, tab], activeTabId: tab.id }); }, openFormTab: ({ kind, schema, name, title, description, mode, params }) => { const { tabs } = get(); // Resolve the effective schema before building the tab: an explicit schema // wins, otherwise fall back to the viewer's current schema, then "public". // The form payload (params) is left untouched — the tab schema is used for // dedup/identity only. const effSchema = schema || get().currentSchema || "public"; // Dedup key semantics: // create -> `create:` (one create form per kind; // schema/name excluded because the user may rename while typing) // edit -> `edit:::` // The key is derived from the stored form payload (params carries // { schema, name } per the objectCrud contract), so no extra field is // needed on ViewerTab. const existing = tabs.find( (t) => t.tabType === "objectForm" && t.form?.kind === kind && t.form?.mode === mode && (mode === "create" || (t.schema === schema && t.form?.params?.name === name)), ); if (existing) { // Re-clicking the same form focuses the tab and never clobbers its // in-progress params. set({ activeTabId: existing.id }); return; } const tab: ViewerTab = { id: `tab-${++tabCounter}`, schema: effSchema, table: title, page: 1, pageSize: get().defaultPageSize, loading: false, error: null, data: null, filterRules: [], sortRules: [], hiddenColumns: [], smartSortApplied: false, tabType: "objectForm", objectType: null, form: { kind, params, title, description, mode }, }; set({ tabs: [...tabs, tab], activeTabId: tab.id }); }, updateFormTabParams: (tabId, params) => set((state) => ({ tabs: state.tabs.map((t) => t.id === tabId && t.tabType === "objectForm" && t.form ? { ...t, form: { ...t.form, params } } : t, ), })), setDefaultPageSize: (size) => set({ defaultPageSize: size }), closeTab: (tabId) => { const { tabs, activeTabId } = get(); const remaining = tabs.filter((t) => t.id !== tabId); const newActiveId = activeTabId === tabId ? remaining.length > 0 ? remaining[remaining.length - 1].id : null : activeTabId; set({ tabs: remaining, activeTabId: newActiveId }); }, // Move a tab to a new index (drag-to-reorder). The active tab is tracked by // id, so it follows the moved tab automatically. reorderTab: (fromIndex, toIndex) => { const { tabs } = get(); if ( fromIndex < 0 || fromIndex >= tabs.length || toIndex < 0 || toIndex >= tabs.length || fromIndex === toIndex ) { return; } const next = [...tabs]; const [moved] = next.splice(fromIndex, 1); next.splice(toIndex, 0, moved); set({ tabs: next }); }, closeTabsForTable: (schema, table) => { const { tabs, activeTabId } = get(); const remaining = tabs.filter( (t) => !(t.tabType === "table" && t.schema === schema && t.table === table), ); if (remaining.length === tabs.length) return; const newActiveId = activeTabId !== null && !remaining.some((t) => t.id === activeTabId) ? remaining.length > 0 ? remaining[remaining.length - 1].id : null : activeTabId; set({ tabs: remaining, activeTabId: newActiveId }); }, setActiveTab: (tabId) => set({ activeTabId: tabId }), setPage: (tabId, page) => set((state) => ({ tabs: state.tabs.map((t) => t.id === tabId ? { ...t, page, loading: true, error: null } : t, ), })), setPageSize: (tabId, pageSize) => set((state) => ({ tabs: state.tabs.map((t) => t.id === tabId ? { ...t, pageSize, page: 1, loading: true, error: null } : t, ), })), setTabData: (tabId, data) => set((state) => ({ tabs: state.tabs.map((t) => t.id === tabId ? { ...t, data, loading: false, error: null } : t, ), })), setTabLoading: (tabId, loading) => set((state) => ({ tabs: state.tabs.map((t) => t.id === tabId ? { ...t, loading } : t, ), })), setTabError: (tabId, error) => set((state) => ({ tabs: state.tabs.map((t) => t.id === tabId ? { ...t, error, loading: false } : t, ), })), setColumnFilter: (tabId, column, value) => set((state) => ({ tabs: state.tabs.map((t) => t.id === tabId ? { ...t, columnFilter: { column, value } } : t, ), })), clearColumnFilter: (tabId) => set((state) => ({ tabs: state.tabs.map((t) => t.id === tabId ? { ...t, columnFilter: undefined } : t, ), })), setFilterRules: (tabId, rules) => set((state) => ({ tabs: state.tabs.map((t) => t.id === tabId ? { ...t, filterRules: rules, page: 1, loading: true, error: null } : t, ), })), setSortRules: (tabId, rules) => set((state) => ({ tabs: state.tabs.map((t) => t.id === tabId ? { ...t, sortRules: rules, page: 1, loading: true, error: null } : t, ), })), setHiddenColumns: (tabId, columns) => set((state) => ({ tabs: state.tabs.map((t) => t.id === tabId ? { ...t, hiddenColumns: columns } : t, ), })), toggleHiddenColumn: (tabId, column) => set((state) => ({ tabs: state.tabs.map((t) => { if (t.id !== tabId) return t; const exists = t.hiddenColumns.includes(column); return { ...t, hiddenColumns: exists ? t.hiddenColumns.filter((c) => c !== column) : [...t.hiddenColumns, column], }; }), })), setSmartSortApplied: (tabId) => set((state) => ({ tabs: state.tabs.map((t) => t.id === tabId ? { ...t, smartSortApplied: true } : t, ), })), addChange: (input) => { const item: QueueItem = { id: `ch-${++changeCounter}`, type: input.type, sql: input.sql ?? "", schema: input.schema, table: input.table, primaryKey: input.primaryKey, oldData: input.oldData ?? null, newData: input.newData ?? null, columns: input.columns, rows: input.rows, status: "pending", description: input.description ?? null, createdAt: Date.now(), }; set((state) => ({ changesQueue: [...state.changesQueue, item] })); }, cancelChange: (changeId) => set((state) => ({ changesQueue: state.changesQueue.map((c) => c.id === changeId ? { ...c, status: "cancelled" as const } : c, ), })), removeChange: (changeId) => set((state) => ({ changesQueue: state.changesQueue.filter((c) => c.id !== changeId), })), clearChanges: () => set({ changesQueue: [] }), markChangeCommitted: (changeId) => set((state) => ({ changesQueue: state.changesQueue.map((c) => c.id === changeId ? { ...c, status: "committed" as const } : c, ), })), markChangeFailed: (changeId, error) => set((state) => ({ changesQueue: state.changesQueue.map((c) => c.id === changeId ? { ...c, status: "failed" as const, error } : c, ), })), toggleChangesPanel: () => set((state) => ({ changesPanelExpanded: !state.changesPanelExpanded })), setCurrentDatabase: (db) => set({ currentDatabase: db }), setCurrentSchema: (schema) => set({ currentSchema: schema }), setObjectSearchOpen: (open) => set({ objectSearchOpen: open }), setSelectedObjectType: (t) => set({ selectedObjectType: t }), setRequestedView: (view) => set({ requestedView: view }), setFunctions: (functions) => set({ functions }), setTriggers: (triggers) => set({ triggers }), setSequences: (sequences) => set({ sequences }), setEnums: (enums) => set({ enums }), setExtensions: (extensions) => set({ extensions }), setIndexes: (indexes) => set({ indexes }), setConstraints: (constraints) => set({ constraints }), setRoles: (roles) => set({ roles }), setSchemaTreeLoading: (loading) => set({ schemaTreeLoading: loading }), stageCellEdit: (input) => { // Re-staging the same cell replaces the existing pending entry (keeps the // original oldData so revert restores the DB value) instead of stacking // a second queue item. const colName = Object.keys(input.newData)[0]; const existing = get().changesQueue.find( (c) => c.status === "pending" && c.type === "update" && c.schema === input.schema && c.table === input.table && c.primaryKey && JSON.stringify(c.primaryKey) === JSON.stringify(input.primaryKey) && Object.keys(c.newData ?? {})[0] === colName, ); if (existing) { set((state) => ({ changesQueue: state.changesQueue.map((c) => c.id === existing.id ? { ...c, newData: input.newData, description: input.description ?? c.description, } : c, ), })); return; } get().addChange({ type: "update", schema: input.schema, table: input.table, primaryKey: input.primaryKey, oldData: input.oldData, newData: input.newData, description: input.description ?? `Edit ${input.table}`, }); }, populate: (databases, schemas, tables) => set({ databases, schemas, tables }), // Best-effort re-fetch of the schema tree (databases/schemas/tables) so // newly created/dropped objects show up without a manual refresh. A // failure must never surface to the user. refreshTree: async (connectionId, schema) => { try { const [dbs, scs, tbls] = await Promise.all([ getDatabases(connectionId), getSchemas(connectionId), getTables(connectionId, schema ?? get().currentSchema ?? undefined), ]); get().populate(dbs, scs, tbls); } catch { // Best-effort refresh; a failure must not surface to the user. } }, reset: () => { tabCounter = 0; changeCounter = 0; set({ ...initialState, tabs: [], changesQueue: [] }); }, }));