Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions apps/cli/src/commands/__tests__/compare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions apps/cli/src/commands/__tests__/migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions apps/cli/src/commands/compare.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -22,6 +22,7 @@ export interface CompareOptions {
scope?: string;
json?: boolean;
ddl?: boolean;
includeIndexes?: boolean;
fail?: boolean; // commander: --no-fail sets this false
}

Expand Down Expand Up @@ -139,7 +140,7 @@ export async function runCompare(opts: CompareOptions): Promise<void> {
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 {
Expand Down
5 changes: 3 additions & 2 deletions apps/cli/src/commands/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -15,6 +15,7 @@ export interface MigrateOptions {
execute?: boolean;
yes?: boolean;
continueOnError?: boolean;
includeIndexes?: boolean;
}

interface MigrationEvent {
Expand Down Expand Up @@ -57,7 +58,7 @@ export async function runMigrate(opts: MigrateOptions): Promise<void> {
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;
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ program
.option('--scope <types>', '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));

Expand Down Expand Up @@ -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');
Expand Down
44 changes: 44 additions & 0 deletions apps/cli/src/runtime/__tests__/engine.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
13 changes: 12 additions & 1 deletion apps/cli/src/runtime/engine.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -34,6 +34,17 @@ const SCOPE_ALIASES: Record<string, DbObjectType> = {
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 [];
Expand Down
42 changes: 39 additions & 3 deletions apps/web/src/frontend/components/ObjectDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ export const ObjectDetailPanel: React.FC = () => {
memberSelection,
toggleMemberSelection,
setAllMemberSelection,
indexSelection,
toggleIndexSelection,
setAllIndexSelection,
targetServerVersion,
} = useSyncStore();

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -976,6 +985,20 @@ export const ObjectDetailPanel: React.FC = () => {
<div>
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider mb-2.5 flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-indigo-500 rounded-full"></span> Table Indexes
{indexChangedItems.length > 0 && (
<label
className="ml-auto flex items-center gap-1.5 normal-case text-[10px] font-semibold text-slate-300 cursor-pointer"
title="Include/exclude all changed indexes in the deploy script"
>
<input
type="checkbox"
checked={allIndexesSelected}
onChange={(e) => setAllIndexSelection(selectedTable.tableName, e.target.checked)}
className="w-3.5 h-3.5 accent-cyan-500 cursor-pointer"
/>
Deploy all indexes
</label>
)}
</h4>
<div className="bg-slate-950/60 border border-slate-800/80 rounded-lg overflow-hidden">
<table className="w-full text-left border-collapse text-xs">
Expand All @@ -999,7 +1022,20 @@ export const ObjectDetailPanel: React.FC = () => {

return (
<tr key={idx.name} className="hover:bg-slate-900/10">
<td className="p-3 text-slate-200 font-semibold font-mono">{highlightMatch(idx.name, query)}</td>
<td className="p-3 text-slate-200 font-semibold font-mono">
<span className="flex items-center gap-1.5">
{idx.status !== 'UNCHANGED' && (
<input
type="checkbox"
checked={indexSelection[selectedTable.tableName]?.[idx.name] === true}
onChange={() => 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)}
</span>
</td>
<td className="p-3 text-slate-400 font-mono">{info?.columns.join(', ')}</td>
<td className="p-3 text-slate-400 font-mono">{info?.unique ? 'UNIQUE' : 'NON-UNIQUE'}</td>
<td className="p-3 text-right">{opBadge}</td>
Expand Down
22 changes: 14 additions & 8 deletions apps/web/src/frontend/store/sync-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean>,
memberSelection: Record<string, Record<string, boolean>>
memberSelection: Record<string, Record<string, boolean>>,
indexSelection: Record<string, Record<string, boolean>>
): 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) };
});
}

Expand All @@ -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;
Expand All @@ -70,9 +75,10 @@ export function regenerateSql(
nonDestructive: boolean;
},
selection: Record<string, boolean>,
memberSelection: Record<string, Record<string, boolean>>
memberSelection: Record<string, Record<string, boolean>>,
indexSelection: Record<string, Record<string, boolean>>
): 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));
}
9 changes: 9 additions & 0 deletions apps/web/src/frontend/store/sync-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,15 @@ export interface SyncState {
memberSelection: Record<string, Record<string, boolean>>;
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<string, Record<string, boolean>>;
toggleIndexSelection: (tableName: string, indexName: string) => void;
setAllIndexSelection: (tableName: string, selected: boolean) => void;

// --- Live migration execution -----------------------------------------
isMigrating: boolean;
Expand Down
Loading
Loading