From 3449d5901cbb82447c49a66de44ab77d382eee55 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 04:46:16 +0000 Subject: [PATCH 1/6] fix(ui): keep selected connections across page refresh After refresh, connections briefly load as [] which cleared the SQL schema-explorer selection, and sync source/target IDs were never persisted. Wait for store rehydrate, persist selection IDs only, and skip wiping explorer state during the empty-list window. Co-authored-by: huy.phan9 --- apps/web/src/frontend/App.tsx | 31 +++++++++-- .../sql-editor/ConnectionChecklist.tsx | 5 +- .../sql-editor/SqlSchemaExplorer.tsx | 17 ++++-- apps/web/src/frontend/store/sync-types.ts | 2 + apps/web/src/frontend/store/useSyncStore.ts | 52 +++++++++++++++++-- 5 files changed, 94 insertions(+), 13 deletions(-) diff --git a/apps/web/src/frontend/App.tsx b/apps/web/src/frontend/App.tsx index 5ba4859..cc935a9 100644 --- a/apps/web/src/frontend/App.tsx +++ b/apps/web/src/frontend/App.tsx @@ -74,14 +74,37 @@ const App: React.FC = () => { init(); }, [init, apply]); - // Once signed in, load the user's saved connections and appearance + // Once signed in, load the user's saved connections and appearance. + // Wait for sync-store persist rehydrate so selected connection IDs are + // restored before loadConnections reapplies source/target configs. useEffect(() => { - if (status === 'ready') { - useSyncStore.getState().loadConnections(); + if (status !== 'ready') return; + + let cancelled = false; + const load = () => { + if (cancelled) return; + void useSyncStore.getState().loadConnections(); apiGetPreferences() - .then((p) => hydrateFromServer(p.theme)) + .then((p) => { + if (!cancelled) hydrateFromServer(p.theme); + }) .catch(() => undefined); + }; + + if (useSyncStore.persist.hasHydrated()) { + load(); + return () => { + cancelled = true; + }; } + + const unsub = useSyncStore.persist.onFinishHydration(() => { + load(); + }); + return () => { + cancelled = true; + unsub(); + }; }, [status, hydrateFromServer]); if (status === 'loading') { diff --git a/apps/web/src/frontend/components/sql-editor/ConnectionChecklist.tsx b/apps/web/src/frontend/components/sql-editor/ConnectionChecklist.tsx index 0f71fcb..c852cc8 100644 --- a/apps/web/src/frontend/components/sql-editor/ConnectionChecklist.tsx +++ b/apps/web/src/frontend/components/sql-editor/ConnectionChecklist.tsx @@ -12,6 +12,7 @@ import { SQL_ICON_STROKE } from './sqlIconStyle'; */ export const ConnectionChecklist: React.FC = () => { const connections = useSyncStore((s) => s.connections); + const connectionsLoaded = useSyncStore((s) => s.connectionsLoaded); const tabs = useSqlEditorStore((s) => s.tabs); const activeTabId = useSqlEditorStore((s) => s.activeTabId); const shareDestinations = useSqlEditorStore((s) => s.shareDestinations); @@ -59,7 +60,9 @@ export const ConnectionChecklist: React.FC = () => { - {connections.length === 0 ? ( + {!connectionsLoaded ? ( +

Loading connections…

+ ) : connections.length === 0 ? (

No saved connections yet — add one via the Credentials button in the toolbar.

diff --git a/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx b/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx index ad04550..3943486 100644 --- a/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx +++ b/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx @@ -43,6 +43,7 @@ function writeStoredExplorerId(id: string): void { */ export const SqlSchemaExplorer: React.FC = () => { const connections = useSyncStore((s) => s.connections); + const connectionsLoaded = useSyncStore((s) => s.connectionsLoaded); const tabs = useSqlEditorStore((s) => s.tabs); const activeTabId = useSqlEditorStore((s) => s.activeTabId); const schemaCache = useSqlEditorStore((s) => s.schemaCache); @@ -51,8 +52,12 @@ export const SqlSchemaExplorer: React.FC = () => { const sharedConnectionIds = useSqlEditorStore((s) => s.sharedConnectionIds); const tab = tabs.find((t) => t.id === activeTabId) ?? tabs[0]!; - const preferredIds = effectiveConnectionIds(tab, shareDestinations, sharedConnectionIds).filter( - (id) => connections.some((c) => c.id === id) + const preferredIds = useMemo( + () => + effectiveConnectionIds(tab, shareDestinations, sharedConnectionIds).filter((id) => + connections.some((c) => c.id === id) + ), + [tab, shareDestinations, sharedConnectionIds, connections] ); const [explorerId, setExplorerId] = useState(() => readStoredExplorerId()); @@ -73,6 +78,10 @@ export const SqlSchemaExplorer: React.FC = () => { }; useEffect(() => { + // After refresh, connections starts [] until loadConnections finishes. + // Do not clear a persisted explorerId during that empty-list window. + if (connections.length === 0) return; + if (explorerId && connections.some((c) => c.id === explorerId)) { // Keep a valid selection in storage (e.g. after first hydrate). writeStoredExplorerId(explorerId); @@ -117,7 +126,9 @@ export const SqlSchemaExplorer: React.FC = () => { return (
- {connections.length === 0 ? ( + {!connectionsLoaded ? ( +

Loading connections…

+ ) : connections.length === 0 ? (

Save a connection to browse its tables.

) : ( <> diff --git a/apps/web/src/frontend/store/sync-types.ts b/apps/web/src/frontend/store/sync-types.ts index 2c4d6db..5a61011 100644 --- a/apps/web/src/frontend/store/sync-types.ts +++ b/apps/web/src/frontend/store/sync-types.ts @@ -32,6 +32,8 @@ export interface SyncState { targetConfig: ConnectionConfig; connections: SavedConnectionSummary[]; + /** False until the first loadConnections() attempt finishes (refresh race guard). */ + connectionsLoaded: boolean; selectedSourceConnectionId: string | null; selectedTargetConnectionId: string | null; diff --git a/apps/web/src/frontend/store/useSyncStore.ts b/apps/web/src/frontend/store/useSyncStore.ts index ca76d02..45d8a14 100644 --- a/apps/web/src/frontend/store/useSyncStore.ts +++ b/apps/web/src/frontend/store/useSyncStore.ts @@ -41,6 +41,7 @@ export const useSyncStore = create()( }, connections: [], + connectionsLoaded: false, selectedSourceConnectionId: null, selectedTargetConnectionId: null, showConnectionModal: false, @@ -83,9 +84,48 @@ export const useSyncStore = create()( // --- Actions --- loadConnections: async () => { try { - set({ connections: await apiListConnections() }); + const list = await apiListConnections(); + const { selectedSourceConnectionId, selectedTargetConnectionId } = get(); + const patch: Partial = { connections: list, connectionsLoaded: true }; + + // Restore persisted source/target picks (IDs only — never passwords). + // Skip auto-test: session passwords are gone after refresh. + const restore = (side: 'source' | 'target', id: string | null) => { + if (!id) return; + const conn = list.find((c) => c.id === id); + if (!conn) { + if (side === 'source') patch.selectedSourceConnectionId = null; + else patch.selectedTargetConnectionId = null; + return; + } + const config: ConnectionConfig = { + dialect: conn.dialect as ConnectionConfig['dialect'], + option: { + host: conn.host, + port: conn.port, + database: conn.database, + username: conn.username, + schema: conn.schema, + }, + schema: conn.schema ?? '', + connectionId: id, + }; + if (side === 'source') { + patch.selectedSourceConnectionId = id; + patch.sourceConfig = config; + patch.sourceConnected = false; + } else { + patch.selectedTargetConnectionId = id; + patch.targetConfig = config; + patch.targetConnected = false; + } + }; + restore('source', selectedSourceConnectionId); + restore('target', selectedTargetConnectionId); + set(patch); } catch (e) { console.error('Failed to load saved connections:', e); + set({ connectionsLoaded: true }); } }, @@ -504,13 +544,14 @@ export const useSyncStore = create()( }), { name: 'schema-sync-storage', - version: 4, - // Persist only the object-type scope. Connections live server-side now, - // and we deliberately do NOT persist configs — credentials must never - // touch localStorage (saved connections reload from the server on sign-in). + version: 5, + // Persist object-type scope + selected connection IDs only. Never persist + // configs/passwords — credentials reload from the server on sign-in. partialize: (state) => ({ selectedObjectTypes: state.selectedObjectTypes, nonDestructive: state.nonDestructive, + selectedSourceConnectionId: state.selectedSourceConnectionId, + selectedTargetConnectionId: state.selectedTargetConnectionId, }), migrate: (persisted: any, version) => { const ensure = (type: string) => { @@ -523,6 +564,7 @@ export const useSyncStore = create()( if (version < 2) ensure('TRIGGER'); if (version < 3) { ensure('SEQUENCE'); ensure('TYPE'); } if (version < 4) { ensure('MQT'); ensure('ROLE'); } + // v5: selected source/target connection ids (no credentials). return persisted; }, } From 50a2bb44ed62882248a0f947af737c648d86b847 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 04:07:39 +0000 Subject: [PATCH 2/6] fix(db2): restore Windows login path so tables/browse work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IBM DB2 on Windows often connected without Authentication=SERVER, lost passwords containing ';', or skipped clidriver install scripts — then schema load returned zero tables and table edit looked missing. - Always rebuild DB2 strings with Authentication=SERVER; ODBC-brace PWD - Strip IBM GSKit from PATH; clear error when clidriver is missing - Require schema on /schema/load for schemaRequired dialects - foxschema drivers install db2 uses --foreground-scripts like the UI Co-authored-by: huy.phan9 --- apps/cli/src/commands/drivers.ts | 11 ++- apps/web/src/backend/api/routes.ts | 8 ++ .../web/src/frontend/lib/provider-settings.ts | 11 ++- .../core/src/providers/db2/db2.adapter.ts | 14 +++- .../src/providers/db2/db2.connection.test.ts | 71 ++++++++++++++++ .../core/src/providers/db2/db2.connection.ts | 81 +++++++++++++++---- .../core/src/providers/db2/db2.env.test.ts | 19 +++++ packages/core/src/providers/db2/db2.env.ts | 27 ++++++- 8 files changed, 216 insertions(+), 26 deletions(-) create mode 100644 packages/core/src/providers/db2/db2.connection.test.ts create mode 100644 packages/core/src/providers/db2/db2.env.test.ts diff --git a/apps/cli/src/commands/drivers.ts b/apps/cli/src/commands/drivers.ts index 379698f..f481e86 100644 --- a/apps/cli/src/commands/drivers.ts +++ b/apps/cli/src/commands/drivers.ts @@ -84,11 +84,18 @@ export async function runDriversInstall(name: string): Promise { const cwd = webWorkspaceRoot(); // Prefer installing into the @foxschema/web package directory. + // ibm_db must run install scripts (--foreground-scripts) or clidriver never + // downloads and Windows connects fail with SQL1042C / missing native binding. + const npmArgs = + entry.pkg === 'ibm_db' + ? ['install', 'ibm_db@4.0.1', '--foreground-scripts', '--prefix', cwd] + : ['install', entry.pkg, '--foreground-scripts', '--prefix', cwd]; + await new Promise((resolve, reject) => { - const child = spawn('npm', ['install', entry.pkg, '--prefix', cwd], { + const child = spawn('npm', npmArgs, { stdio: 'inherit', shell: process.platform === 'win32', - env: process.env, + env: { ...process.env, npm_config_ignore_scripts: '' }, }); child.on('error', reject); child.on('exit', (code) => { diff --git a/apps/web/src/backend/api/routes.ts b/apps/web/src/backend/api/routes.ts index 4da1dd5..dd56ae3 100644 --- a/apps/web/src/backend/api/routes.ts +++ b/apps/web/src/backend/api/routes.ts @@ -10,6 +10,7 @@ import { DriverDetector, buildConnectionString, normalizeTableSchemas, + getProviderSettings, type MigrationStep, type ConnectionOptions, type DbObjectType, @@ -358,6 +359,13 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt const { scope, ...ref } = req.body as ConnectionRef & { scope: DbObjectType[] }; try { const { dialect, option, schema } = await resolveRef((req as AuthedRequest).userId, ref); + const settings = getProviderSettings(dialect); + if (settings.schemaRequired && !schema?.trim()) { + res.status(400).json({ + error: `${settings.label} requires a schema. Load schemas for the connection, then pick one before browsing or editing tables.`, + }); + return; + } const { tables, warnings } = await loadScopedTables(dialect, option, schema, scope); res.json(warnings.length ? { tables, warnings } : { tables }); } catch (error: unknown) { diff --git a/apps/web/src/frontend/lib/provider-settings.ts b/apps/web/src/frontend/lib/provider-settings.ts index e243a05..96d23be 100644 --- a/apps/web/src/frontend/lib/provider-settings.ts +++ b/apps/web/src/frontend/lib/provider-settings.ts @@ -1,3 +1,5 @@ +import { buildConnectionString as coreBuildConnectionString } from '@foxschema/core'; + export type Dialect = 'postgres' | 'mysql' | 'mariadb' | 'db2' | 'sqlserver' | 'oracle' | 'sqlite' | 'redshift' | 'clickhouse' | 'azuresql' | 'cockroachdb' | 'yugabytedb' | 'tidb' | 'duckdb'; export interface ConnectionOptions { @@ -87,13 +89,10 @@ const db2Settings: ProviderSettings = { defaultPort: 50000, defaultSchema: '', schemaRequired: true, + // Delegate to core so pasted strings always get Authentication=SERVER / + // CurrentSchema (verbatim early-return caused SQL1042C / empty browse on Windows). buildConnectionString(o) { - if (o.connectionString?.trim()) return o.connectionString.trim(); - const host = o.host || 'localhost'; - const port = o.port || this.defaultPort; - let cs = `DATABASE=${o.database || ''};HOSTNAME=${host};PORT=${port};PROTOCOL=TCPIP;UID=${o.username || ''};PWD=${o.password || ''};Authentication=SERVER;`; - if (o.schema) cs += `CurrentSchema=${o.schema.toUpperCase()};`; - return cs; + return coreBuildConnectionString('db2', o); }, }; diff --git a/packages/core/src/providers/db2/db2.adapter.ts b/packages/core/src/providers/db2/db2.adapter.ts index 9c85d16..bbd657b 100644 --- a/packages/core/src/providers/db2/db2.adapter.ts +++ b/packages/core/src/providers/db2/db2.adapter.ts @@ -2,7 +2,7 @@ import { createRequire } from 'node:module'; import { ConnectionOptions, DriverAdapter } from '../../interfaces/schema-provider.interface'; import { assertSafeIdentifier } from '../../cores/sql-identifier'; import { BoundedPoolCache, disposePoolEndOrClose } from '../../cores/pool-cache'; -import { setupDb2ClientEnv } from './db2.env'; +import { setupDb2ClientEnv, hasDb2Clidriver } from './db2.env'; const nodeRequire = createRequire(import.meta.url); @@ -20,12 +20,22 @@ class Db2Adapter implements DriverAdapter { private load(): any { if (this.driver) return this.driver; setupDb2ClientEnv(); // point at the bundled clidriver before native load + if (!hasDb2Clidriver()) { + throw new Error( + 'ibm_db is installed but its CLI driver (clidriver) is missing. ' + + 'On Windows this usually means scripts were skipped — reinstall with: ' + + 'npm install ibm_db@4.0.1 --foreground-scripts (or: foxschema drivers install db2)' + ); + } try { const mod = nodeRequire(this.packageName); this.driver = mod.default ?? mod; } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); - throw new Error(`Database driver "${this.packageName}" is not installed for db2. Install it with: npm install ${this.packageName} — ${message}`); + throw new Error( + `Database driver "${this.packageName}" is not installed for db2. ` + + `Install it with: npm install ${this.packageName} --foreground-scripts — ${message}` + ); } return this.driver; } diff --git a/packages/core/src/providers/db2/db2.connection.test.ts b/packages/core/src/providers/db2/db2.connection.test.ts new file mode 100644 index 0000000..7b2c594 --- /dev/null +++ b/packages/core/src/providers/db2/db2.connection.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { + buildDb2ConnectionString, + odbcEscape, + parseDb2SemicolonMap, +} from './db2.connection'; + +describe('buildDb2ConnectionString', () => { + it('always injects Authentication=SERVER', () => { + const cs = buildDb2ConnectionString({ + host: 'db.example', + port: 50000, + database: 'SAMPLE', + username: 'db2inst1', + password: 'secret', + }); + expect(cs).toContain('Authentication=SERVER'); + expect(cs).toContain('DATABASE=SAMPLE'); + expect(cs).toContain('HOSTNAME=db.example'); + expect(cs).toContain('UID=db2inst1'); + expect(cs).toContain('PWD=secret'); + }); + + it('normalizes a pasted semicolon string and still forces Authentication=SERVER', () => { + const cs = buildDb2ConnectionString({ + connectionString: + 'DATABASE=SAMPLE;HOSTNAME=h;PORT=50000;PROTOCOL=TCPIP;UID=u;PWD=p;', + }); + expect(cs).toMatch(/Authentication=SERVER/); + expect(cs).toContain('DATABASE=SAMPLE'); + expect(cs).toContain('UID=u'); + }); + + it('brace-escapes passwords that contain semicolons so they round-trip', () => { + const password = 'a;b}c'; + const cs = buildDb2ConnectionString({ + host: 'h', + database: 'D', + username: 'u', + password, + }); + expect(cs).toContain(`PWD=${odbcEscape(password)}`); + const map = parseDb2SemicolonMap(cs); + expect(map.get('PWD')).toBe(password); + }); + + it('parses braced PWD from a pasted string', () => { + const cs = buildDb2ConnectionString({ + connectionString: 'DATABASE=D;HOSTNAME=h;PORT=50000;UID=u;PWD={p;a;s;s};', + }); + expect(parseDb2SemicolonMap(cs).get('PWD')).toBe('p;a;s;s'); + }); + + it('adds CurrentSchema when schema is provided', () => { + const cs = buildDb2ConnectionString( + { host: 'h', database: 'D', username: 'u', password: 'p', schema: 'myschema' }, + 'myschema' + ); + expect(cs).toContain('CurrentSchema=MYSCHEMA'); + }); +}); + +describe('odbcEscape', () => { + it('leaves plain values alone', () => { + expect(odbcEscape('plain')).toBe('plain'); + }); + + it('escapes braces inside braced values', () => { + expect(odbcEscape('a}b')).toBe('{a}}b}'); + }); +}); diff --git a/packages/core/src/providers/db2/db2.connection.ts b/packages/core/src/providers/db2/db2.connection.ts index 6a09cb9..59a63c2 100644 --- a/packages/core/src/providers/db2/db2.connection.ts +++ b/packages/core/src/providers/db2/db2.connection.ts @@ -2,20 +2,21 @@ import type { ConnectionOptions } from '../../interfaces/schema-provider.interfa /** * Build a DB2 CLI connection string (semicolon-delimited key=value pairs). - * ibm_db2 does not accept db2:// URLs — those must be converted first. + * ibm_db does not accept db2:// URLs — those must be converted first. * - * Authentication=SERVER is required by IBM to avoid SQL1042C on many setups. + * Authentication=SERVER is required by IBM to avoid SQL1042C on many setups + * (especially Windows when GSKit / system CLI libraries pollute PATH). */ export function buildDb2ConnectionString(options: ConnectionOptions, schema?: string): string { const parsed = parseDb2ConnectionInput(options.connectionString, options); const parts: string[] = [ - `DATABASE=${parsed.database}`, - `HOSTNAME=${parsed.host}`, + `DATABASE=${odbcEscape(parsed.database)}`, + `HOSTNAME=${odbcEscape(parsed.host)}`, `PORT=${parsed.port}`, 'PROTOCOL=TCPIP', - `UID=${parsed.username}`, - `PWD=${parsed.password}`, + `UID=${odbcEscape(parsed.username)}`, + `PWD=${odbcEscape(parsed.password)}`, 'Authentication=SERVER', ]; @@ -25,12 +26,18 @@ export function buildDb2ConnectionString(options: ConnectionOptions, schema?: st const schemaName = schema?.trim() || options.schema?.trim(); if (schemaName) { - parts.push(`CurrentSchema=${schemaName.toUpperCase()}`); + parts.push(`CurrentSchema=${odbcEscape(schemaName.toUpperCase())}`); } return parts.join(';') + ';'; } +/** ODBC brace-escape so values containing `;` or `}` do not truncate the string. */ +export function odbcEscape(value: string): string { + if (!/[;{}]/.test(value) && value === value.trim()) return value; + return `{${value.replace(/}/g, '}}')}}`; +} + function parseDb2ConnectionInput( connectionString: string | undefined, options: ConnectionOptions @@ -81,6 +88,57 @@ function parseDb2Url(url: string): { }; } +/** + * Parse `KEY=value;KEY2={value;with;semicolons};` with ODBC brace rules. + * A plain `split(';')` would truncate passwords that contain `;`. + */ +export function parseDb2SemicolonMap(connStr: string): Map { + const map = new Map(); + let i = 0; + const s = connStr; + + while (i < s.length) { + while (i < s.length && (s[i] === ';' || /\s/.test(s[i]!))) i++; + if (i >= s.length) break; + + const eq = s.indexOf('=', i); + if (eq === -1) break; + const key = s.slice(i, eq).trim().toUpperCase(); + let j = eq + 1; + while (j < s.length && s[j] === ' ') j++; + + let value: string; + if (s[j] === '{') { + j++; + let out = ''; + while (j < s.length) { + if (s[j] === '}' && s[j + 1] === '}') { + out += '}'; + j += 2; + continue; + } + if (s[j] === '}') { + j++; + break; + } + out += s[j]!; + j++; + } + value = out; + while (j < s.length && s[j] !== ';') j++; + } else { + const semi = s.indexOf(';', j); + value = (semi === -1 ? s.slice(j) : s.slice(j, semi)).trim(); + j = semi === -1 ? s.length : semi; + } + + if (key) map.set(key, value); + i = j + 1; + } + + return map; +} + function parseDb2Semicolon( connStr: string, fallback: ConnectionOptions @@ -91,14 +149,7 @@ function parseDb2Semicolon( username: string; password: string; } { - const map = new Map(); - for (const segment of connStr.split(';')) { - const eq = segment.indexOf('='); - if (eq === -1) continue; - const key = segment.slice(0, eq).trim().toUpperCase(); - const value = segment.slice(eq + 1).trim(); - if (key) map.set(key, value); - } + const map = parseDb2SemicolonMap(connStr); return { database: map.get('DATABASE') ?? fallback.database ?? '', diff --git a/packages/core/src/providers/db2/db2.env.test.ts b/packages/core/src/providers/db2/db2.env.test.ts new file mode 100644 index 0000000..685507a --- /dev/null +++ b/packages/core/src/providers/db2/db2.env.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { stripIbmGskPath } from './db2.env'; + +describe('stripIbmGskPath', () => { + it('removes IBM GSKit directories that conflict with bundled clidriver', () => { + const sep = process.platform === 'win32' ? ';' : ':'; + const input = ['C:\\Tools', 'C:\\Program Files\\IBM\\gsk8\\lib', 'C:\\clidriver\\bin'].join( + sep + ); + const out = stripIbmGskPath(input) ?? ''; + expect(out.toLowerCase()).not.toContain('gsk8'); + expect(out).toContain('Tools'); + expect(out).toContain('clidriver'); + }); + + it('returns undefined for undefined input', () => { + expect(stripIbmGskPath(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/providers/db2/db2.env.ts b/packages/core/src/providers/db2/db2.env.ts index ac35b97..acf6167 100644 --- a/packages/core/src/providers/db2/db2.env.ts +++ b/packages/core/src/providers/db2/db2.env.ts @@ -9,6 +9,9 @@ let configured = false; /** * Point the process at ibm_db's bundled clidriver before loading native bindings. * Helps avoid SQL1042C caused by missing/wrong CLI library paths. + * + * On Windows, IBM GSKit (`…\IBM\gsk8\…`) on PATH is a known conflict with + * ibm_db's bundled CLI — strip those entries when we install our clidriver PATH. */ export function setupDb2ClientEnv(): void { if (configured) return; @@ -20,6 +23,8 @@ export function setupDb2ClientEnv(): void { const binDir = path.join(clidriverDir, 'bin'); if (!fs.existsSync(clidriverDir)) { + // Leave configured=false so a later successful `npm install ibm_db + // --foreground-scripts` can retry setup on the next connect. return; } @@ -32,7 +37,7 @@ export function setupDb2ClientEnv(): void { process.env.LD_LIBRARY_PATH = prependPath(process.env.LD_LIBRARY_PATH, libDir); process.env.PATH = prependPath(process.env.PATH, binDir); } else if (process.platform === 'win32') { - process.env.PATH = prependPath(process.env.PATH, binDir, libDir); + process.env.PATH = prependPath(stripIbmGskPath(process.env.PATH), binDir, libDir); process.env.LIB = prependPath(process.env.LIB, libDir, binDir); } @@ -42,6 +47,26 @@ export function setupDb2ClientEnv(): void { } } +/** True when ibm_db's installer/clidriver directory is present on disk. */ +export function hasDb2Clidriver(): boolean { + try { + const pkgPath = nodeRequire.resolve('ibm_db/package.json'); + const clidriverDir = path.join(path.dirname(pkgPath), 'installer', 'clidriver'); + return fs.existsSync(clidriverDir); + } catch { + return false; + } +} + +/** Exported for tests — remove IBM GSKit dirs that conflict with bundled CLI. */ +export function stripIbmGskPath(current: string | undefined): string | undefined { + if (!current) return current; + const kept = current + .split(path.delimiter) + .filter((p) => p && !/IBM[/\\]gsk8/i.test(p)); + return kept.join(path.delimiter); +} + function prependPath(current: string | undefined, ...dirs: string[]): string { const existing = current?.split(path.delimiter).filter(Boolean) ?? []; const merged = [...dirs, ...existing.filter((d) => !dirs.includes(d))]; From d09f9c5404037e16255812493e10fd2f15adbb92 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 04:52:50 +0000 Subject: [PATCH 3/6] fix(sql-editor): make table blueprint entry points always visible The Edit/open-blueprint control only appeared on loaded table rows, so an empty schema (or missing DB2 schema) looked like the feature was deleted. Label New/Edit, add empty-state Create table, surface schema-required hints, and keep the DB2 Windows login path so tables can load again. Co-authored-by: huy.phan9 --- .../sql-editor/SqlSchemaExplorer.tsx | 69 +++++++++++++++---- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx b/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx index 3943486..36f0c2a 100644 --- a/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx +++ b/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx @@ -3,6 +3,7 @@ import { ChevronDown, ChevronRight, Columns3, Loader2, Plus, RefreshCw } from 'l import { useSyncStore } from '../../store/useSyncStore'; import { useSqlEditorStore } from '../../store/useSqlEditorStore'; import { effectiveConnectionIds } from '../../store/sqlEditorTabLogic'; +import { getProviderSettings } from '../../lib/provider-settings'; import { TYPE_META } from '../SchemaTreePanel'; import { filterCallParameters, insertAtCursor } from './sqlEditorBridge'; import type { DbObjectType, TableSchema } from '../../lib/types'; @@ -77,6 +78,11 @@ export const SqlSchemaExplorer: React.FC = () => { writeStoredExplorerId(id); }; + const openCreateTable = () => { + setBlueprintMode('create'); + setBlueprintTable(null); + }; + useEffect(() => { // After refresh, connections starts [] until loadConnections finishes. // Do not clear a persisted explorerId during that empty-list window. @@ -123,6 +129,17 @@ export const SqlSchemaExplorer: React.FC = () => { ); const conn = connections.find((c) => c.id === explorerId); + let schemaMissingHint: string | null = null; + if (conn) { + try { + const settings = getProviderSettings(conn.dialect); + if (settings.schemaRequired && !conn.schema?.trim()) { + schemaMissingHint = `${settings.label} needs a schema on the connection. Edit it under Credentials, pick a schema, then reload.`; + } + } catch { + /* unknown dialect */ + } + } return (
@@ -147,16 +164,14 @@ export const SqlSchemaExplorer: React.FC = () => {
+ {schemaMissingHint && ( +

+ {schemaMissingHint} +

+ )} {entry?.status === 'error' && (

{entry.error}

)} @@ -181,10 +201,22 @@ export const SqlSchemaExplorer: React.FC = () => { Loading…

)} - {entry?.status === 'ready' && totalCount === 0 && ( -

- No tables, views, procedures, or functions in this schema. -

+ {entry?.status === 'ready' && totalCount === 0 && !schemaMissingHint && ( +
+

+ No tables in this schema yet. Use New to open the table blueprint and add columns. +

+ +
)}
@@ -310,9 +342,17 @@ const ObjectNode: React.FC<{ title={ isRoutine ? `Insert ${table.name}(${params.map((p) => `${p.mode} ${p.name}`).join(', ')})` - : `Insert ${table.name}` + : onOpenBlueprint + ? `Insert ${table.name} (double-click to edit table)` + : `Insert ${table.name}` } onClick={insertObject} + onDoubleClick={(e) => { + if (!onOpenBlueprint) return; + e.preventDefault(); + e.stopPropagation(); + onOpenBlueprint(); + }} className="flex-1 flex items-center gap-1.5 min-w-0 text-left text-[13px] font-semibold text-slate-200 hover:text-cyan-300 py-1 truncate" > {meta.icon} @@ -326,15 +366,16 @@ const ObjectNode: React.FC<{ {onOpenBlueprint && ( )}
From a3eed08fb151b993b8cc146f99ffb69cd70c2be6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 05:03:58 +0000 Subject: [PATCH 4/6] fix(sql-editor): surface New table and Edit table where they cannot hide Your Schema screenshot matched a build where the only controls were the connection dropdown + refresh. Put New table in the Schema section header and render Edit table under each table name so add-column stays reachable in a narrow sidebar. Secrets + blueprint code remain in the tree. Co-authored-by: huy.phan9 --- .../components/sql-editor/SqlEditorView.tsx | 18 +++++- .../sql-editor/SqlSchemaExplorer.tsx | 62 ++++++++++--------- 2 files changed, 48 insertions(+), 32 deletions(-) diff --git a/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx b/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx index e7b85f9..465db50 100644 --- a/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx +++ b/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx @@ -16,6 +16,7 @@ import { KeyRound, PanelLeftClose, PanelLeftOpen, + Plus, } from 'lucide-react'; import { useSyncStore } from '../../store/useSyncStore'; import { useSqlEditorStore } from '../../store/useSqlEditorStore'; @@ -31,7 +32,7 @@ import { SqlBookmarksPanel } from './SqlBookmarksPanel'; import { SqlVariablesPanel } from './SqlVariablesPanel'; import { SqlSecretsPanel, type SqlSecretsPanelHandle } from './SqlSecretsPanel'; import { SQL_ICON_STROKE } from './sqlIconStyle'; -import { SqlSchemaExplorer } from './SqlSchemaExplorer'; +import { SqlSchemaExplorer, type SqlSchemaExplorerHandle } from './SqlSchemaExplorer'; import { SqlSidebarSection, useSidebarSectionHeights, @@ -129,6 +130,7 @@ export const SqlEditorView: React.FC = () => { const [sidebarOpen, toggleSidebar] = useSidebarSectionsOpen(); const [sectionHeights, setSectionHeight] = useSidebarSectionHeights(); const secretsPanelRef = useRef(null); + const schemaExplorerRef = useRef(null); const [secretsRefreshing, setSecretsRefreshing] = useState(false); const onSecretsRefresh = useCallback(async () => { @@ -377,8 +379,20 @@ export const SqlEditorView: React.FC = () => { grow height={sectionHeights.schema} onResizeHeight={(h) => setSectionHeight('schema', h)} + actions={ + + } > - +
void; +} + /** Categories shown in the SQL Editor schema browser (order = display order). */ const EXPLORER_GROUPS: { type: DbObjectType; title: string }[] = [ { type: 'TABLE', title: 'Tables' }, @@ -42,7 +47,10 @@ function writeStoredExplorerId(id: string): void { * Slim schema tree for the SQL Editor. Categorized TABLE / VIEW / MQT / * PROCEDURE / FUNCTION — click a name to insert at the Monaco cursor. */ -export const SqlSchemaExplorer: React.FC = () => { +export const SqlSchemaExplorer = forwardRef(function SqlSchemaExplorer( + _props, + ref +) { const connections = useSyncStore((s) => s.connections); const connectionsLoaded = useSyncStore((s) => s.connectionsLoaded); const tabs = useSqlEditorStore((s) => s.tabs); @@ -83,6 +91,8 @@ export const SqlSchemaExplorer: React.FC = () => { setBlueprintTable(null); }; + useImperativeHandle(ref, () => ({ openCreateTable }), []); + useEffect(() => { // After refresh, connections starts [] until loadConnections finishes. // Do not clear a persisted explorerId during that empty-list window. @@ -293,7 +303,7 @@ export const SqlSchemaExplorer: React.FC = () => { )}
); -}; +}); const ObjectNode: React.FC<{ table: TableSchema; @@ -323,12 +333,12 @@ const ObjectNode: React.FC<{ }; return ( -
-
+
+
- {onOpenBlueprint && ( - - )}
+ {onOpenBlueprint && ( + + )} {open && isRoutine && params.length === 0 && (

No parameters

)} From a47c77494246d690b0302179adbad42e1de1bf84 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 05:12:14 +0000 Subject: [PATCH 5/6] test(e2e): open Edit table without hover group class Edit table is always visible under each table name now; the old page object looked for a removed Tailwind group ancestor and timed out. Co-authored-by: huy.phan9 --- apps/e2e/src/pages/SqlEditorPage.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/e2e/src/pages/SqlEditorPage.ts b/apps/e2e/src/pages/SqlEditorPage.ts index 0d99292..ec45f27 100644 --- a/apps/e2e/src/pages/SqlEditorPage.ts +++ b/apps/e2e/src/pages/SqlEditorPage.ts @@ -192,18 +192,23 @@ export class SqlEditorPage { tableName, { timeout: 45_000 } ); - // Prefer the blueprint button nearest the table name label. + // Edit table sits under the name (always visible — no hover / group class). const nameLabel = explorer.getByText(tableName, { exact: true }).first(); await nameLabel.scrollIntoViewIfNeeded(); - const row = nameLabel.locator('xpath=ancestor::div[contains(@class,"group")][1]'); - await row.hover(); + const row = nameLabel.locator('xpath=ancestor::div[./button[@data-testid="sql-open-blueprint"] or .//button[@data-testid="sql-open-blueprint"]][1]'); await row.locator('[data-testid="sql-open-blueprint"]').click({ force: true }); await waitFor(this.page, '[data-testid="table-blueprint-modal"]', 15_000); } async openNewTableBlueprint(): Promise { await this.dismissOverlays(); - await clickWhen(this.page, '[data-testid="sql-new-table"]'); + // Prefer Schema header action; fall back to explorer New control. + const header = this.page.locator('[data-testid="sql-schema-new-table"]'); + if (await header.count()) { + await header.click(); + } else { + await clickWhen(this.page, '[data-testid="sql-new-table"]'); + } await waitFor(this.page, '[data-testid="table-blueprint-modal"]', 15_000); } From 68c8ad83af998877a37d6fdfae5dba15469a29e6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 05:34:41 +0000 Subject: [PATCH 6/6] feat(blueprint): expose Indexes section testids and e2e Add-index coverage Indexes (add/edit/drop, unique, ASC/DESC, optional WHERE) already live in the table blueprint modal; add stable data-testids and a Playwright flow that adds a UNIQUE index and inserts CREATE INDEX SQL. Co-authored-by: huy.phan9 --- .../src/tests/sql-editor-blueprint.test.ts | 50 +++++++++++++++++++ .../sql-editor/TableBlueprintModal.tsx | 10 +++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/apps/e2e/src/tests/sql-editor-blueprint.test.ts b/apps/e2e/src/tests/sql-editor-blueprint.test.ts index 119a4a9..6ddac0d 100644 --- a/apps/e2e/src/tests/sql-editor-blueprint.test.ts +++ b/apps/e2e/src/tests/sql-editor-blueprint.test.ts @@ -146,6 +146,56 @@ describe.skipIf(!ready)('SQL Editor · blueprint + paging (SQLite)', () => { expect(editorText.toLowerCase()).toMatch(/create\s*table/); }); + it('Indexes section can add a UNIQUE index on a column', async () => { + await sql.openNewTableBlueprint(); + const modal = driver.locator('[data-testid="table-blueprint-modal"]'); + expect(await modal.isVisible()).toBe(true); + await driver.locator('[data-testid="blueprint-table-name"]').fill(`t_idx_${RUN}`); + + await modal.getByRole('button', { name: /add column/i }).click(); + await waitForColumnForm(driver); + await modal.locator('[data-testid="blueprint-column-form"] input').first().fill('email'); + await modal.getByRole('button', { name: /^save$/i }).click(); + await driver.waitForFunction(() => { + const m = document.querySelector('[data-testid="table-blueprint-modal"]'); + return !!m && /email/i.test(m.textContent ?? '') && !m.querySelector('[data-testid="blueprint-column-form"]'); + }); + + const indexes = modal.locator('[data-testid="blueprint-indexes"]'); + expect(await indexes.isVisible()).toBe(true); + await indexes.locator('[data-testid="blueprint-add-index"]').click(); + await driver.waitForSelector('[data-testid="blueprint-index-form"]', { timeout: 5_000 }); + + // openAddIndex already selects the first column — only toggle email on if missing. + const form = modal.locator('[data-testid="blueprint-index-form"]'); + const formText = await form.innerText(); + if (!/\(email/i.test(formText)) { + await form.getByRole('button', { name: /^email$/i }).click(); + } + const unique = form.locator('input[type="checkbox"]').first(); + if (await unique.count()) { + if (!(await unique.isChecked())) await unique.check(); + } + await modal.locator('[data-testid="blueprint-index-save"]').click(); + + await driver.waitForFunction(() => { + const root = document.querySelector('[data-testid="blueprint-indexes"]'); + if (!root) return false; + if (root.querySelector('[data-testid="blueprint-index-form"]')) return false; + return /email/i.test(root.textContent ?? ''); + }, { timeout: 5_000 }); + + await sql.blueprintInsertSql(); + await sql.closeBlueprint().catch(() => undefined); + await driver.waitForTimeout(500); + const editorText = await driver.evaluate(() => { + const lines = document.querySelector('.monaco-editor .view-lines'); + const ta = document.querySelector('.monaco-editor textarea') as HTMLTextAreaElement | null; + return `${lines?.textContent ?? ''}\n${ta?.value ?? ''}`; + }); + expect(editorText.toLowerCase()).toMatch(/create\s+unique\s+index|create\s+index/); + }); + it('pages SELECT results with Next/Prev', async () => { await driver.locator('[data-testid="sql-max-rows"]').fill('2'); await sql.setSql('SELECT id1, id2, label FROM parent ORDER BY id1;'); diff --git a/apps/web/src/frontend/components/sql-editor/TableBlueprintModal.tsx b/apps/web/src/frontend/components/sql-editor/TableBlueprintModal.tsx index d049d3e..56bae68 100644 --- a/apps/web/src/frontend/components/sql-editor/TableBlueprintModal.tsx +++ b/apps/web/src/frontend/components/sql-editor/TableBlueprintModal.tsx @@ -1133,7 +1133,7 @@ export const TableBlueprintModal: React.FC = ({ {/* Indexes */} -
+

@@ -1145,6 +1145,7 @@ export const TableBlueprintModal: React.FC = ({ {!indexFormOpen && indexSupport.create && (