From 0205c4fe4aeab438f254ec66a87ba0d0e4c2da48 Mon Sep 17 00:00:00 2001 From: huyphan Date: Mon, 13 Jul 2026 13:51:10 -0600 Subject: [PATCH] feat(migrate): index changes are opt-in, excluded from deploy by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Index CREATE/DROP/ALTER changes are no longer auto-included just because their table is selected for deploy — they now require an explicit, per-index opt-in, and default to excluded. Web app (Schema Diff tab, Table Indexes section): - New per-index checkbox ("Include this index change in the deploy script"), unchecked by default, plus a "Deploy all indexes" master toggle in the section header — mirrors the existing role-member opt-out pattern (memberSelection) but with reversed polarity (opt-IN instead of opt-OUT), since the whole point is index changes shouldn't ship silently. - New indexSelection store state + toggleIndexSelection/ setAllIndexSelection actions; buildIncludedDiffs/regenerateSql now filter each table's indexDiffs through it before SQL generation. CLI (fox compare --ddl, fox migrate): - New --include-indexes flag (default false) with the same semantics; shared filterIndexDiffs() helper in runtime/engine.ts. Verified live in the browser against two SQLite files differing only by one index: unchecked -> "No schema changes detected"; checking the per-index box -> SQL regenerates to include CREATE INDEX. 260 unit tests pass (+6 new); both apps/web and apps/cli typecheck clean. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/__tests__/compare.test.ts | 44 +++++++++++++++++++ .../src/commands/__tests__/migrate.test.ts | 37 ++++++++++++++++ apps/cli/src/commands/compare.ts | 5 ++- apps/cli/src/commands/migrate.ts | 5 ++- apps/cli/src/index.ts | 2 + apps/cli/src/runtime/__tests__/engine.test.ts | 44 +++++++++++++++++++ apps/cli/src/runtime/engine.ts | 13 +++++- .../frontend/components/ObjectDetailPanel.tsx | 42 ++++++++++++++++-- apps/web/src/frontend/store/sync-helpers.ts | 22 ++++++---- apps/web/src/frontend/store/sync-types.ts | 9 ++++ apps/web/src/frontend/store/useSyncStore.ts | 44 ++++++++++++++++--- 11 files changed, 245 insertions(+), 22 deletions(-) create mode 100644 apps/cli/src/runtime/__tests__/engine.test.ts diff --git a/apps/cli/src/commands/__tests__/compare.test.ts b/apps/cli/src/commands/__tests__/compare.test.ts index eee0844..11216e5 100644 --- a/apps/cli/src/commands/__tests__/compare.test.ts +++ b/apps/cli/src/commands/__tests__/compare.test.ts @@ -169,6 +169,50 @@ describe('CLI: compare command', () => { ); }); + it('excludes index diffs from --ddl output by default, includes them with --include-indexes', async () => { + const mockResult = { + summary: { added: 0, removed: 0, modified: 1, unchanged: 5 }, + tables: [ + { + tableName: 'orders', + status: 'MODIFIED', + objectType: 'table', + columnDiffs: [], + fkDiffs: [], + indexDiffs: [{ name: 'idx_new', status: 'ADDED' }], + }, + ], + }; + + vi.spyOn(connectionRef, 'resolveRef') + .mockResolvedValueOnce({ dialect: 'postgres', schema: 'a', option: {} } as any) + .mockResolvedValueOnce({ dialect: 'postgres', schema: 'b', option: {} } as any); + vi.spyOn(engine, 'loadScopedTables').mockResolvedValueOnce([]).mockResolvedValueOnce([]); + vi.spyOn(engine.compareModule, 'compare').mockResolvedValueOnce(mockResult as any); + const genSpy = vi.spyOn(engine.sqlGenerator, 'generateMigrationSql').mockReturnValue('-- sql'); + vi.spyOn(console, 'log'); + + await runCompare({ source: 'a', target: 'b', ddl: true }); + expect(genSpy).toHaveBeenCalledWith( + [expect.objectContaining({ indexDiffs: [] })], + 'postgres', + expect.anything() + ); + + genSpy.mockClear(); + vi.spyOn(connectionRef, 'resolveRef') + .mockResolvedValueOnce({ dialect: 'postgres', schema: 'a', option: {} } as any) + .mockResolvedValueOnce({ dialect: 'postgres', schema: 'b', option: {} } as any); + vi.spyOn(engine, 'loadScopedTables').mockResolvedValueOnce([]).mockResolvedValueOnce([]); + vi.spyOn(engine.compareModule, 'compare').mockResolvedValueOnce(mockResult as any); + await runCompare({ source: 'a', target: 'b', ddl: true, includeIndexes: true }); + expect(genSpy).toHaveBeenCalledWith( + [expect.objectContaining({ indexDiffs: [{ name: 'idx_new', status: 'ADDED' }] })], + 'postgres', + expect.anything() + ); + }); + it('should exit with code 1 on drift (default behavior)', async () => { vi.spyOn(connectionRef, 'resolveRef') .mockResolvedValueOnce({ dialect: 'postgres', schema: 'a', option: {} } as any) diff --git a/apps/cli/src/commands/__tests__/migrate.test.ts b/apps/cli/src/commands/__tests__/migrate.test.ts index 95c0e42..71d828c 100644 --- a/apps/cli/src/commands/__tests__/migrate.test.ts +++ b/apps/cli/src/commands/__tests__/migrate.test.ts @@ -58,6 +58,43 @@ describe('CLI: migrate command', () => { expect(executeSpy).not.toHaveBeenCalled(); }); + it('excludes index diffs by default, includes them with --include-indexes', async () => { + const withIndexDiff = { + summary: { added: 0, removed: 0, modified: 1, unchanged: 5 }, + tables: [ + { + tableName: 'orders', + status: 'MODIFIED', + objectType: 'table', + columnDiffs: [], + fkDiffs: [], + indexDiffs: [{ name: 'idx_new', status: 'ADDED' }], + }, + ], + }; + + stubRefsAndCompare(withIndexDiff); + const planSpy = vi.spyOn(engine.sqlGenerator, 'generateMigrationPlan').mockReturnValue([{ sql: 'ALTER TABLE orders ...' }] as any); + vi.spyOn(engine.sqlGenerator, 'generateMigrationSql').mockReturnValue('-- sql'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runMigrate({ source: 'demo_c', target: 'demo_d', execute: false }); + expect(planSpy).toHaveBeenCalledWith( + [expect.objectContaining({ indexDiffs: [] })], + 'postgres', + expect.anything() + ); + + planSpy.mockClear(); + stubRefsAndCompare(withIndexDiff); + await runMigrate({ source: 'demo_c', target: 'demo_d', execute: false, includeIndexes: true }); + expect(planSpy).toHaveBeenCalledWith( + [expect.objectContaining({ indexDiffs: [{ name: 'idx_new', status: 'ADDED' }] })], + 'postgres', + expect.anything() + ); + }); + it('prompts for confirmation with --execute and aborts when declined', async () => { const { confirm } = await import('@inquirer/prompts'); (confirm as any).mockResolvedValueOnce(false); diff --git a/apps/cli/src/commands/compare.ts b/apps/cli/src/commands/compare.ts index 9c29fbe..464dcf3 100644 --- a/apps/cli/src/commands/compare.ts +++ b/apps/cli/src/commands/compare.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; import type { TableDiff } from '@foxschema/core'; import { ensureSourceTarget, resolveRef } from '../runtime/connectionRef'; -import { compareModule, loadScopedTables, parseScope, sqlGenerator } from '../runtime/engine'; +import { compareModule, filterIndexDiffs, loadScopedTables, parseScope, sqlGenerator } from '../runtime/engine'; import { TYPE_LABEL, groupByType, @@ -22,6 +22,7 @@ export interface CompareOptions { scope?: string; json?: boolean; ddl?: boolean; + includeIndexes?: boolean; fail?: boolean; // commander: --no-fail sets this false } @@ -139,7 +140,7 @@ export async function runCompare(opts: CompareOptions): Promise { if (opts.json) { console.log(JSON.stringify(result, null, 2)); } else if (opts.ddl) { - const changed = result.tables.filter((t: TableDiff) => t.status !== 'UNCHANGED'); + const changed = filterIndexDiffs(result.tables, !!opts.includeIndexes).filter((t: TableDiff) => t.status !== 'UNCHANGED'); if (changed.length === 0) { console.log(chalk.dim('-- schemas are identical; no migration needed')); } else { diff --git a/apps/cli/src/commands/migrate.ts b/apps/cli/src/commands/migrate.ts index 046264e..147dc2a 100644 --- a/apps/cli/src/commands/migrate.ts +++ b/apps/cli/src/commands/migrate.ts @@ -3,7 +3,7 @@ import chalk from 'chalk'; import type { TableDiff } from '@foxschema/core'; import { ensureSourceTarget, resolveRef } from '../runtime/connectionRef'; import { friendlyError } from '../format/friendlyError'; -import { compareModule, connectionModule, loadScopedTables, migrationModule, parseScope, sqlGenerator } from '../runtime/engine'; +import { compareModule, connectionModule, filterIndexDiffs, loadScopedTables, migrationModule, parseScope, sqlGenerator } from '../runtime/engine'; import { getContext } from '../runtime/store'; export interface MigrateOptions { @@ -15,6 +15,7 @@ export interface MigrateOptions { execute?: boolean; yes?: boolean; continueOnError?: boolean; + includeIndexes?: boolean; } interface MigrationEvent { @@ -57,7 +58,7 @@ export async function runMigrate(opts: MigrateOptions): Promise { source: src.dialect, target: tgt.dialect, }); - const changed = result.tables.filter((d: TableDiff) => d.status !== 'UNCHANGED'); + const changed = filterIndexDiffs(result.tables, !!opts.includeIndexes).filter((d: TableDiff) => d.status !== 'UNCHANGED'); if (changed.length === 0) { console.log(chalk.green('✔ Target already matches source — nothing to migrate.')); return; diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 858c7d5..ee50631 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -95,6 +95,7 @@ program .option('--scope ', 'comma list: tables,views,functions,procedures,triggers,sequences,types,mqts,roles') .option('--json', 'output the full comparison as JSON') .option('--ddl', 'output the migration DDL for the differences') + .option('--include-indexes', 'also include index CREATE/DROP/ALTER in --ddl output (excluded by default)') .option('--no-fail', 'always exit 0, even when there is drift') .action((opts) => runCompare(opts)); @@ -122,6 +123,7 @@ program .option('--execute', 'apply the migration (default is a dry run)') .option('--yes', 'skip the confirmation prompt') .option('--continue-on-error', 'skip a failed object and continue instead of rolling back the whole run') + .option('--include-indexes', 'also migrate index CREATE/DROP/ALTER changes (excluded by default)') .action((opts) => runMigrate(opts)); const history = program.command('history').description('Migration run history'); diff --git a/apps/cli/src/runtime/__tests__/engine.test.ts b/apps/cli/src/runtime/__tests__/engine.test.ts new file mode 100644 index 0000000..6a079e6 --- /dev/null +++ b/apps/cli/src/runtime/__tests__/engine.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import type { TableDiff } from '@foxschema/core'; +import { filterIndexDiffs } from '../engine'; + +function tableWithIndexes(indexDiffs: TableDiff['indexDiffs']): TableDiff { + return { + tableName: 'ORDERS', + status: 'MODIFIED', + objectType: 'table', + columnDiffs: [], + indexDiffs, + fkDiffs: [], + } as unknown as TableDiff; +} + +describe('filterIndexDiffs', () => { + const changed = [ + { name: 'idx_added', status: 'ADDED' }, + { name: 'idx_removed', status: 'REMOVED' }, + { name: 'idx_modified', status: 'MODIFIED' }, + { name: 'idx_same', status: 'UNCHANGED' }, + ] as TableDiff['indexDiffs']; + + it('strips ADDED/REMOVED/MODIFIED index diffs by default (includeIndexes: false)', () => { + const [result] = filterIndexDiffs([tableWithIndexes(changed)], false); + expect(result.indexDiffs.map((i) => i.name)).toEqual(['idx_same']); + }); + + it('keeps every index diff when includeIndexes is true', () => { + const [result] = filterIndexDiffs([tableWithIndexes(changed)], true); + expect(result.indexDiffs).toEqual(changed); + }); + + it('leaves tables with no index diffs untouched', () => { + const [result] = filterIndexDiffs([tableWithIndexes([])], false); + expect(result.indexDiffs).toEqual([]); + }); + + it('does not mutate the input array', () => { + const original = tableWithIndexes(changed); + filterIndexDiffs([original], false); + expect(original.indexDiffs).toEqual(changed); + }); +}); diff --git a/apps/cli/src/runtime/engine.ts b/apps/cli/src/runtime/engine.ts index 517c89d..985d6fd 100644 --- a/apps/cli/src/runtime/engine.ts +++ b/apps/cli/src/runtime/engine.ts @@ -1,5 +1,5 @@ import { ConnectionModule, MigrationModule } from '@foxschema/core'; -import { CompareModule, SqlGeneratorModule, type ConnectionOptions, type DbObjectType, type TableSchema } from '@foxschema/core'; +import { CompareModule, SqlGeneratorModule, type ConnectionOptions, type DbObjectType, type TableSchema, type TableDiff } from '@foxschema/core'; // Shared engine singletons — the same modules the web/desktop apps use. export const connectionModule = new ConnectionModule(); @@ -34,6 +34,17 @@ const SCOPE_ALIASES: Record = { role: 'ROLE', roles: 'ROLE', }; +/** + * Index changes are opt-IN for migration/DDL generation (`--include-indexes`, + * off by default) — the CLI equivalent of the web app's per-index "deploy" + * checkboxes, which also default to excluded. UNCHANGED entries are harmless + * (no SQL either way) and kept so they don't skew counts. + */ +export function filterIndexDiffs(tables: TableDiff[], includeIndexes: boolean): TableDiff[] { + if (includeIndexes) return tables; + return tables.map((t) => ({ ...t, indexDiffs: t.indexDiffs.filter((i) => i.status === 'UNCHANGED') })); +} + /** Parse `--scope tables,views,roles` into DbObjectType[] (empty = all). */ export function parseScope(scope?: string): DbObjectType[] { if (!scope) return []; diff --git a/apps/web/src/frontend/components/ObjectDetailPanel.tsx b/apps/web/src/frontend/components/ObjectDetailPanel.tsx index 5d97c14..c541da7 100644 --- a/apps/web/src/frontend/components/ObjectDetailPanel.tsx +++ b/apps/web/src/frontend/components/ObjectDetailPanel.tsx @@ -168,6 +168,9 @@ export const ObjectDetailPanel: React.FC = () => { memberSelection, toggleMemberSelection, setAllMemberSelection, + indexSelection, + toggleIndexSelection, + setAllIndexSelection, targetServerVersion, } = useSyncStore(); @@ -220,14 +223,14 @@ export const ObjectDetailPanel: React.FC = () => { ); const reviewIssues = useMemo(() => { if (!compareResult) return []; - const includedDiffs = buildIncludedDiffs(compareResult.tables, syncSelection, memberSelection); + const includedDiffs = buildIncludedDiffs(compareResult.tables, syncSelection, memberSelection, indexSelection); const steps = ddlGenerator.generateMigrationPlan( includedDiffs, targetConfig.dialect, buildMapping({ sourceConfig, targetConfig, nonDestructive, targetServerVersion }) ); return extractReviewNotices(steps); - }, [compareResult, syncSelection, memberSelection, sourceConfig, targetConfig, nonDestructive, targetServerVersion]); + }, [compareResult, syncSelection, memberSelection, indexSelection, sourceConfig, targetConfig, nonDestructive, targetServerVersion]); const hasMissingFkTargets = missingFkIssues.length > 0; const hasNarrowingChanges = narrowingIssues.length > 0; const narrowingAcked = narrowingAckSql !== null && narrowingAckSql === generatedSql; @@ -514,6 +517,12 @@ export const ObjectDetailPanel: React.FC = () => { const allMembersSelected = roleChangedMembers.length > 0 && roleChangedMembers.every((m) => memberSelection[selectedTable.tableName]?.[m.name] !== false); + // Index deploy selection (changed indexes only) — opt-IN, so an index change + // is excluded from the migration unless the user explicitly checks it. + const indexChangedItems = selectedTable.indexDiffs.filter((i) => i.status !== 'UNCHANGED'); + const allIndexesSelected = + indexChangedItems.length > 0 && + indexChangedItems.every((i) => indexSelection[selectedTable.tableName]?.[i.name] === true); // Hide UNCHANGED items unless the "Show unchanged" toggle is on. const keep = (status: string) => showUnchangedDetail || status !== 'UNCHANGED'; const colDiffs = selectedTable.columnDiffs.filter((c) => keep(c.status)); @@ -976,6 +985,20 @@ export const ObjectDetailPanel: React.FC = () => {

Table Indexes + {indexChangedItems.length > 0 && ( + + )}

@@ -999,7 +1022,20 @@ export const ObjectDetailPanel: React.FC = () => { return ( - + diff --git a/apps/web/src/frontend/store/sync-helpers.ts b/apps/web/src/frontend/store/sync-helpers.ts index aa74e7e..1bb7b75 100644 --- a/apps/web/src/frontend/store/sync-helpers.ts +++ b/apps/web/src/frontend/store/sync-helpers.ts @@ -28,20 +28,25 @@ export function buildRef(cfg: ConnectionConfig): ConnectionRef { /** * Build the diffs to deploy from the object selection, applying per-role member - * opt-outs: a role member explicitly set to false is dropped from the role's - * diffs, so it won't appear in the generated GRANT/REVOKE. + * opt-outs (a role member explicitly set to false is dropped from the role's + * diffs, so it won't appear in the generated GRANT/REVOKE) and per-index opt-ins + * (an index change is only included once explicitly checked — see + * sync-types.ts's indexSelection doc comment for why the polarity is reversed). */ export function buildIncludedDiffs( tables: TableDiff[], selection: Record, - memberSelection: Record> + memberSelection: Record>, + indexSelection: Record> ): TableDiff[] { return tables .filter((t) => selection[t.tableName]) .map((t) => { - if (t.objectType !== 'ROLE') return t; + const idxSel = indexSelection[t.tableName] ?? {}; + const indexDiffs = t.indexDiffs.filter((i) => i.status === 'UNCHANGED' || idxSel[i.name] === true); + if (t.objectType !== 'ROLE') return { ...t, indexDiffs }; const sel = memberSelection[t.tableName] ?? {}; - return { ...t, columnDiffs: t.columnDiffs.filter((c) => sel[c.name] !== false) }; + return { ...t, indexDiffs, columnDiffs: t.columnDiffs.filter((c) => sel[c.name] !== false) }; }); } @@ -61,7 +66,7 @@ export function buildMapping(s: { }; } -/** Regenerate the preview migration script for a selection + per-role member opt-outs. */ +/** Regenerate the preview migration script for a selection + per-role member opt-outs + per-index opt-ins. */ export function regenerateSql( s: { compareResult: SchemaCompareResult | null; @@ -70,9 +75,10 @@ export function regenerateSql( nonDestructive: boolean; }, selection: Record, - memberSelection: Record> + memberSelection: Record>, + indexSelection: Record> ): string { if (!s.compareResult) return ''; - const includedDiffs = buildIncludedDiffs(s.compareResult.tables, selection, memberSelection); + const includedDiffs = buildIncludedDiffs(s.compareResult.tables, selection, memberSelection, indexSelection); return sqlGeneratorModule.generateMigrationSql(includedDiffs, s.targetConfig.dialect, buildMapping(s)); } diff --git a/apps/web/src/frontend/store/sync-types.ts b/apps/web/src/frontend/store/sync-types.ts index ae77036..2c4d6db 100644 --- a/apps/web/src/frontend/store/sync-types.ts +++ b/apps/web/src/frontend/store/sync-types.ts @@ -106,6 +106,15 @@ export interface SyncState { memberSelection: Record>; toggleMemberSelection: (roleName: string, memberName: string) => void; setAllMemberSelection: (roleName: string, selected: boolean) => void; + /** + * Per-index opt-IN: indexSelection[table][index] === true includes that index + * change in the deploy script. Opposite polarity from memberSelection — index + * changes are excluded by default, so a fresh compare never silently rebuilds + * indexes the user hasn't reviewed. + */ + indexSelection: Record>; + toggleIndexSelection: (tableName: string, indexName: string) => void; + setAllIndexSelection: (tableName: string, selected: boolean) => void; // --- Live migration execution ----------------------------------------- isMigrating: boolean; diff --git a/apps/web/src/frontend/store/useSyncStore.ts b/apps/web/src/frontend/store/useSyncStore.ts index 7e36d9b..c178bfa 100644 --- a/apps/web/src/frontend/store/useSyncStore.ts +++ b/apps/web/src/frontend/store/useSyncStore.ts @@ -71,6 +71,7 @@ export const useSyncStore = create()( searchTerm: '', typeFilter: [], memberSelection: {}, + indexSelection: {}, syncSelection: {}, isMigrating: false, @@ -186,7 +187,7 @@ export const useSyncStore = create()( const s = get(); if (!s.compareResult) return; set({ - generatedSql: regenerateSql(s, s.syncSelection, s.memberSelection), + generatedSql: regenerateSql(s, s.syncSelection, s.memberSelection, s.indexSelection), migrationExecuted: false, }); }, @@ -211,7 +212,7 @@ export const useSyncStore = create()( const nextSelection = { ...s.syncSelection, [tableName]: !s.syncSelection[tableName] }; set({ syncSelection: nextSelection, - generatedSql: regenerateSql(s, nextSelection, s.memberSelection), + generatedSql: regenerateSql(s, nextSelection, s.memberSelection, s.indexSelection), migrationExecuted: false, }); }, @@ -225,7 +226,7 @@ export const useSyncStore = create()( } set({ syncSelection: nextSelection, - generatedSql: regenerateSql(s, nextSelection, s.memberSelection), + generatedSql: regenerateSql(s, nextSelection, s.memberSelection, s.indexSelection), migrationExecuted: false, }); }, @@ -239,7 +240,7 @@ export const useSyncStore = create()( const nextMember = { ...s.memberSelection, [roleName]: roleSel }; set({ memberSelection: nextMember, - generatedSql: regenerateSql(s, s.syncSelection, nextMember), + generatedSql: regenerateSql(s, s.syncSelection, nextMember, s.indexSelection), migrationExecuted: false, }); }, @@ -256,7 +257,38 @@ export const useSyncStore = create()( const nextMember = { ...s.memberSelection, [roleName]: roleSel }; set({ memberSelection: nextMember, - generatedSql: regenerateSql(s, s.syncSelection, nextMember), + generatedSql: regenerateSql(s, s.syncSelection, nextMember, s.indexSelection), + migrationExecuted: false, + }); + }, + + toggleIndexSelection: (tableName, indexName) => { + const s = get(); + if (!s.compareResult) return; + const tableSel = { ...(s.indexSelection[tableName] ?? {}) }; + // Default excluded; toggle to true (included) and back. + tableSel[indexName] = tableSel[indexName] === true ? false : true; + const nextIndex = { ...s.indexSelection, [tableName]: tableSel }; + set({ + indexSelection: nextIndex, + generatedSql: regenerateSql(s, s.syncSelection, s.memberSelection, nextIndex), + migrationExecuted: false, + }); + }, + + setAllIndexSelection: (tableName, selected) => { + const s = get(); + if (!s.compareResult) return; + const table = s.compareResult.tables.find((t) => t.tableName === tableName); + if (!table) return; + const tableSel: Record = {}; + for (const i of table.indexDiffs) { + if (i.status !== 'UNCHANGED') tableSel[i.name] = selected; + } + const nextIndex = { ...s.indexSelection, [tableName]: tableSel }; + set({ + indexSelection: nextIndex, + generatedSql: regenerateSql(s, s.syncSelection, s.memberSelection, nextIndex), migrationExecuted: false, }); }, @@ -380,7 +412,7 @@ export const useSyncStore = create()( const nextSelection = { ...s.syncSelection, [objectName]: false }; set({ syncSelection: nextSelection, - generatedSql: regenerateSql(s, nextSelection, s.memberSelection), + generatedSql: regenerateSql(s, nextSelection, s.memberSelection, s.indexSelection), migrationProgress: [], migrationError: null, migrationRolledBack: false,
{highlightMatch(idx.name, query)} + + {idx.status !== 'UNCHANGED' && ( + toggleIndexSelection(selectedTable.tableName, idx.name)} + title="Include this index change in the deploy script" + className="w-3.5 h-3.5 accent-cyan-500 cursor-pointer shrink-0" + /> + )} + {highlightMatch(idx.name, query)} + + {info?.columns.join(', ')} {info?.unique ? 'UNIQUE' : 'NON-UNIQUE'} {opBadge}