From b5529a763d7916dcea4ec87b2be2e48b99da6c30 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 09:28:40 +0000 Subject: [PATCH 1/3] fix(sql-editor): prevent keyset paging from skipping joined rows Co-authored-by: huy.phan9 --- .../sql-editor/lib/resultSeek.test.ts | 50 ++++++++++++++++++- .../features/sql-editor/lib/resultSeek.ts | 31 +++++++++++- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/apps/web/src/frontend/features/sql-editor/lib/resultSeek.test.ts b/apps/web/src/frontend/features/sql-editor/lib/resultSeek.test.ts index dc6a0936..ebb0918c 100644 --- a/apps/web/src/frontend/features/sql-editor/lib/resultSeek.test.ts +++ b/apps/web/src/frontend/features/sql-editor/lib/resultSeek.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect, it } from 'vitest'; -import { seekFromLastRow } from './resultSeek'; +import { seekFromLastRow, tableForOrderBy } from './resultSeek'; import type { TableSchema } from '@/shared/lib/types'; const TABLE: TableSchema = { @@ -41,4 +41,52 @@ describe('seekFromLastRow', () => { }) ).toBeNull(); }); + + it('does not use one side of a join to claim the result order is unique', () => { + expect( + tableForOrderBy( + 'SELECT o.ID, i.ID AS ITEM_ID FROM ORDERS o JOIN ITEMS i ON i.ORDER_ID = o.ID ORDER BY o.ID', + [ + TABLE, + { + ...TABLE, + name: 'ITEMS', + columns: [ + { name: 'ID', type: 'int', nullable: false, primaryKey: true }, + { name: 'ORDER_ID', type: 'int', nullable: false, primaryKey: false }, + ], + }, + ] + ) + ).toBeUndefined(); + }); + + it('matches a FROM table exactly instead of borrowing a substring table PK', () => { + const orderItems: TableSchema = { + ...TABLE, + name: 'ORDER_ITEMS', + columns: [ + { name: 'ORDER_ID', type: 'int', nullable: false, primaryKey: false }, + { name: 'LINE', type: 'int', nullable: false, primaryKey: false }, + ], + primaryKey: undefined, + }; + expect( + tableForOrderBy( + 'SELECT ORDER_ID, LINE FROM ORDER_ITEMS ORDER BY ORDER_ID', + [TABLE, orderItems] + ) + ).toBe(orderItems); + expect( + seekFromLastRow({ + sql: 'SELECT ORDER_ID, LINE FROM ORDER_ITEMS ORDER BY ORDER_ID', + table: tableForOrderBy( + 'SELECT ORDER_ID, LINE FROM ORDER_ITEMS ORDER BY ORDER_ID', + [TABLE, orderItems] + ), + resultColumns: ['ORDER_ID', 'LINE'], + lastRow: [1, 3], + }) + ).toBeNull(); + }); }); diff --git a/apps/web/src/frontend/features/sql-editor/lib/resultSeek.ts b/apps/web/src/frontend/features/sql-editor/lib/resultSeek.ts index 6cdb5547..1ccdf8a8 100644 --- a/apps/web/src/frontend/features/sql-editor/lib/resultSeek.ts +++ b/apps/web/src/frontend/features/sql-editor/lib/resultSeek.ts @@ -13,6 +13,11 @@ import { uniqueKeysFromTable, } from '@foxschema/sql'; import type { TableSchema } from '@/shared/lib/types'; +import { + fromClauseIsMultiTable, + sqlHasSetOperation, + tableNamesFromSql, +} from '@/shared/lib/tablePreview'; export type ResultSeek = { columns: string[]; @@ -25,8 +30,30 @@ export function tableForOrderBy( tables: readonly TableSchema[] | undefined ): TableSchema | undefined { if (!tables?.length) return undefined; - const names = sql.toLowerCase(); - return tables.find((t) => names.includes(t.name.toLowerCase())); + // A unique key is only unique in the result while one source row can produce + // at most one output row. JOIN/APPLY/comma-FROM and set operations can repeat + // a primary key, so keyset paging would skip the remaining rows for that key. + if ( + fromClauseIsMultiTable(sql) || + sqlHasSetOperation(sql) || + /\b(?:CROSS|OUTER)\s+APPLY\b/i.test(sql) + ) { + return undefined; + } + // Match parsed FROM references, not substrings. With tables `order` and + // `order_items`, searching the SQL text returned whichever cache entry came + // first and could borrow the wrong table's uniqueness metadata. + const names = tableNamesFromSql(sql); + if (names.length !== 1) return undefined; + const wanted = names[0]!.toLowerCase(); + if (wanted.includes('.')) { + return tables.find((table) => table.name.toLowerCase() === wanted); + } + const matched = tables.filter((table) => { + const name = table.name.toLowerCase(); + return (name.includes('.') ? name.slice(name.lastIndexOf('.') + 1) : name) === wanted; + }); + return matched.length === 1 ? matched[0] : undefined; } export function seekFromLastRow(opts: { From 5856cfb6ac832b80c22ba5304e73c2a85ae8c3f5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 09:30:37 +0000 Subject: [PATCH 2/3] fix(sql-editor): restore destinations for recent runs Co-authored-by: huy.phan9 --- .../frontend/app/store/sqlEditorCache.test.ts | 28 ++++++++++++ .../frontend/app/store/useSqlEditorStore.ts | 43 ++++++++++++++++--- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/apps/web/src/frontend/app/store/sqlEditorCache.test.ts b/apps/web/src/frontend/app/store/sqlEditorCache.test.ts index c0e2fc1d..2ee295f2 100644 --- a/apps/web/src/frontend/app/store/sqlEditorCache.test.ts +++ b/apps/web/src/frontend/app/store/sqlEditorCache.test.ts @@ -4,6 +4,7 @@ import { truncatePersistedSql, pruneSchemaCache, MAX_PERSISTED_SQL_CHARS, + useSqlEditorStore, } from './useSqlEditorStore'; import type { SqlStatementResult } from '@/shared/api/sqlApi'; @@ -53,3 +54,30 @@ describe('pruneSchemaCache', () => { expect(pruned.fresh).toBeDefined(); }); }); + +describe('recent query destinations', () => { + it('restores the destinations used by the run instead of the current shared selection', () => { + const current = useSqlEditorStore.getState().tabs[0]!; + useSqlEditorStore.setState({ + tabs: [{ ...current, id: 'current', selectedConnectionIds: ['staging'] }], + activeTabId: 'current', + shareDestinations: true, + sharedConnectionIds: ['staging'], + recentQueries: [ + { + id: 'recent-prod', + sql: 'UPDATE orders SET status = 1 WHERE id = 42', + title: 'Production update', + selectedConnectionIds: ['production'], + ranAt: 1, + }, + ], + }); + + useSqlEditorStore.getState().openRecentQuery('recent-prod'); + + const state = useSqlEditorStore.getState(); + expect(state.sharedConnectionIds).toEqual(['production']); + expect(state.activeTab().selectedConnectionIds).toEqual(['production']); + }); +}); diff --git a/apps/web/src/frontend/app/store/useSqlEditorStore.ts b/apps/web/src/frontend/app/store/useSqlEditorStore.ts index 856366f0..87f4d159 100644 --- a/apps/web/src/frontend/app/store/useSqlEditorStore.ts +++ b/apps/web/src/frontend/app/store/useSqlEditorStore.ts @@ -129,6 +129,8 @@ export interface RecentQuery { sql: string; /** Tab title at run time (optional label). */ title: string; + /** Exact destinations used by this run; reopening must not follow today's selection. */ + selectedConnectionIds: string[]; ranAt: number; } @@ -836,17 +838,23 @@ export const useSqlEditorStore = create()( }), openRecentQuery: (id) => { - const { recentQueries, tabs, shareDestinations, sharedConnectionIds } = get(); + const { recentQueries, tabs, shareDestinations } = get(); const entry = recentQueries.find((r) => r.id === id); if (!entry) return; + const selectedConnectionIds = [...entry.selectedConnectionIds]; const tab = createTab({ title: entry.title?.trim() || 'Recent query', sql: entry.sql, - selectedConnectionIds: shareDestinations - ? [...sharedConnectionIds] - : (tabs.find((t) => t.id === get().activeTabId)?.selectedConnectionIds ?? []), + selectedConnectionIds, + }); + set({ + tabs: [...tabs, tab], + activeTabId: tab.id, + // Shared mode drives execution from this global list, so restore it + // as well as the tab-local copy. Otherwise the reopened SQL silently + // follows whichever database happens to be selected now. + ...(shareDestinations ? { sharedConnectionIds: selectedConnectionIds } : {}), }); - set({ tabs: [...tabs, tab], activeTabId: tab.id }); }, clearRecentQueries: () => set({ recentQueries: [] }), @@ -1521,6 +1529,9 @@ export const useSqlEditorStore = create()( id: newTabId(), sql: truncatePersistedSql(sqlSnippet), title: tab.title || 'Query', + selectedConnectionIds: connections + .map((connection) => connection.id) + .filter((id) => id !== LOCAL_CODE_RUN_TARGET.id), ranAt: Date.now(), }; recentQueries = [ @@ -2281,7 +2292,7 @@ export const useSqlEditorStore = create()( }), { name: 'foxschema-sql-editor', - version: 7, + version: 8, // Persist tabs + destinations mode + bookmarks + recent + variables. Never passwords/results. // Secret variable payloads are stripped (session-only values). partialize: (state) => { @@ -2386,6 +2397,23 @@ export const useSqlEditorStore = create()( : 3, }; } + // v8: recent runs remember the exact database destinations they used. + // Old entries fail closed with none selected instead of inheriting the + // current destination and risking a write to the wrong database. + if (fromVersion < 8) { + const recent = Array.isArray(p.recentQueries) + ? (p.recentQueries as Array>) + : []; + return { + ...p, + recentQueries: recent.map((entry) => ({ + ...entry, + selectedConnectionIds: Array.isArray(entry.selectedConnectionIds) + ? entry.selectedConnectionIds.filter((id) => typeof id === 'string') + : [], + })), + }; + } return p; }, // Always rehydrate checkedStatements (not persisted) and drop malformed tabs. @@ -2462,6 +2490,9 @@ export const useSqlEditorStore = create()( id: r.id, sql: truncatePersistedSql(r.sql), title: typeof r.title === 'string' ? r.title : 'Query', + selectedConnectionIds: Array.isArray(r.selectedConnectionIds) + ? r.selectedConnectionIds.filter((id) => typeof id === 'string') + : [], ranAt: r.ranAt, })) .sort((a, b) => b.ranAt - a.ranAt) From 70694734755aa729e89c654ba4ddb4ea9dd13efc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 09:31:34 +0000 Subject: [PATCH 3/3] fix(sql-editor): merge concurrent schema scope loads Co-authored-by: huy.phan9 --- .../app/store/sqlEditorSchemaCache.test.ts | 90 +++++++++++++++++++ .../frontend/app/store/useSqlEditorStore.ts | 33 ++++--- 2 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/frontend/app/store/sqlEditorSchemaCache.test.ts diff --git a/apps/web/src/frontend/app/store/sqlEditorSchemaCache.test.ts b/apps/web/src/frontend/app/store/sqlEditorSchemaCache.test.ts new file mode 100644 index 00000000..9dc543eb --- /dev/null +++ b/apps/web/src/frontend/app/store/sqlEditorSchemaCache.test.ts @@ -0,0 +1,90 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DbObjectType, TableSchema } from '@/shared/lib/types'; + +const { loadSchemaMock } = vi.hoisted(() => ({ + loadSchemaMock: vi.fn(), +})); + +vi.mock('@/shared/api/schemaApi', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadSchema: loadSchemaMock }; +}); + +import { useSqlEditorStore } from './useSqlEditorStore'; +import { useSyncStore } from './useSyncStore'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function schemaObject(name: string, objectType: DbObjectType): TableSchema { + return { + name, + objectType, + columns: [], + indices: [], + foreignKeys: [], + }; +} + +describe('SQL Editor scoped schema cache', () => { + beforeEach(() => { + loadSchemaMock.mockReset(); + useSyncStore.setState({ + connections: [ + { + id: 'schema-race', + name: 'Test database', + dialect: 'postgres', + database: 'app', + schema: 'public', + hasPassword: true, + }, + ], + } as never); + useSqlEditorStore.setState({ + schemaCache: {}, + pendingPassword: null, + sessionPasswords: {}, + }); + }); + + it('keeps both scopes when the slower concurrent load finishes last', async () => { + const warm = deferred<{ tables: TableSchema[] }>(); + const routines = deferred<{ tables: TableSchema[] }>(); + loadSchemaMock.mockImplementation( + (_ref: unknown, scope: DbObjectType[]) => + (scope.includes('TABLE') ? warm.promise : routines.promise) + ); + + const warmLoad = useSqlEditorStore + .getState() + .ensureSchema('schema-race', { scope: ['TABLE', 'VIEW', 'MQT'] }); + const routineLoad = useSqlEditorStore + .getState() + .ensureSchema('schema-race', { scope: ['PROCEDURE', 'FUNCTION'] }); + + warm.resolve({ tables: [schemaObject('ORDERS', 'TABLE')] }); + await warmLoad; + routines.resolve({ tables: [schemaObject('REFRESH_ORDERS', 'PROCEDURE')] }); + await routineLoad; + + const cached = useSqlEditorStore.getState().schemaCache['schema-race']; + expect(cached?.tables?.map((table) => table.name).sort()).toEqual([ + 'ORDERS', + 'REFRESH_ORDERS', + ]); + expect(new Set(cached?.scope)).toEqual( + new Set(['TABLE', 'VIEW', 'MQT', 'PROCEDURE', 'FUNCTION']) + ); + }); +}); diff --git a/apps/web/src/frontend/app/store/useSqlEditorStore.ts b/apps/web/src/frontend/app/store/useSqlEditorStore.ts index 87f4d159..e57d2671 100644 --- a/apps/web/src/frontend/app/store/useSqlEditorStore.ts +++ b/apps/web/src/frontend/app/store/useSqlEditorStore.ts @@ -932,18 +932,27 @@ export const useSqlEditorStore = create()( wanted as DbObjectType[] ); const incoming = new Set(wanted); - const kept = (existing?.tables ?? []).filter((t) => !incoming.has(t.objectType)); - const mergedScope = [...new Set([...(existing?.scope ?? []), ...wanted])]; - set({ - schemaCache: pruneSchemaCache({ - ...get().schemaCache, - [connectionId]: { - status: 'ready', - tables: [...kept, ...loaded], - scope: mergedScope, - loadedAt: Date.now(), - }, - }), + // Merge against the cache at completion time. Different scope loads + // intentionally use different in-flight keys and can overlap; using + // the snapshot from before either request awaited lets the slower + // response erase objects written by the faster one. + set((state) => { + const current = state.schemaCache[connectionId]; + const kept = (current?.tables ?? []).filter( + (table) => !incoming.has(table.objectType) + ); + const mergedScope = [...new Set([...(current?.scope ?? []), ...wanted])]; + return { + schemaCache: pruneSchemaCache({ + ...state.schemaCache, + [connectionId]: { + status: 'ready', + tables: [...kept, ...loaded], + scope: mergedScope, + loadedAt: Date.now(), + }, + }), + }; }); } catch (error: unknown) { set({