diff --git a/backend/src/entities/table-schema/ai/run-schema-change-ai-loop.ts b/backend/src/entities/table-schema/ai/run-schema-change-ai-loop.ts index 0e21869c8..291d429a8 100644 --- a/backend/src/entities/table-schema/ai/run-schema-change-ai-loop.ts +++ b/backend/src/entities/table-schema/ai/run-schema-change-ai-loop.ts @@ -34,10 +34,9 @@ export interface RunSchemaChangeAiLoopOptions { logger?: Logger; } -export interface SchemaChangeAiLoopResult { - proposals: ProposeSchemaChangeArgs[]; - responseId: string | null; -} +export type SchemaChangeAiLoopResult = + | { kind: 'proposals'; proposals: ProposeSchemaChangeArgs[]; responseId: string | null } + | { kind: 'clarification'; assistantMessage: string; responseId: string | null }; export async function runSchemaChangeAiLoop(opts: RunSchemaChangeAiLoopOptions): Promise { const { aiCoreService, provider, dao, userEmail, logger } = opts; @@ -77,15 +76,16 @@ export async function runSchemaChangeAiLoop(opts: RunSchemaChangeAiLoopOptions): const proposalCall = pendingToolCalls.find((tc) => TERMINAL_PROPOSAL_TOOL_NAMES.has(tc.name)); if (proposalCall) { const proposals = coerceAndValidateProposals(proposalCall); - return { proposals, responseId: lastResponseId }; + return { kind: 'proposals', proposals, responseId: lastResponseId }; } if (pendingToolCalls.length === 0) { - const hint = accumulatedContent - ? `AI replied with text but no tool call: "${accumulatedContent.slice(0, 200)}"` - : 'AI produced no tool calls and no text.'; + const trimmed = accumulatedContent.trim(); + if (trimmed.length > 0) { + return { kind: 'clarification', assistantMessage: trimmed, responseId: lastResponseId }; + } throw new Error( - `${hint} The model must call proposeSchemaChange (SQL), proposeMongoSchemaChange (Mongo), proposeDynamoDbSchemaChange (DynamoDB), or proposeElasticsearchSchemaChange (Elasticsearch) with structured arguments.`, + 'AI produced no tool calls and no text. The model must call proposeSchemaChange (SQL), proposeMongoSchemaChange (Mongo), proposeDynamoDbSchemaChange (DynamoDB), or proposeElasticsearchSchemaChange (Elasticsearch) with structured arguments, or ask a focused clarifying question in plain text.', ); } diff --git a/backend/src/entities/table-schema/ai/run-schema-change-fix-ai-loop.ts b/backend/src/entities/table-schema/ai/run-schema-change-fix-ai-loop.ts new file mode 100644 index 000000000..dd90427ba --- /dev/null +++ b/backend/src/entities/table-schema/ai/run-schema-change-fix-ai-loop.ts @@ -0,0 +1,156 @@ +import { Logger } from '@nestjs/common'; +import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js'; +import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js'; +import { AIProviderType } from '../../../ai-core/interfaces/ai-service.interface.js'; +import { AICoreService } from '../../../ai-core/services/ai-core.service.js'; +import { MessageBuilder } from '../../../ai-core/utils/message-builder.js'; +import { ConnectionEntity } from '../../connection/connection.entity.js'; +import { SchemaChangeTypeEnum } from '../table-schema-change-enums.js'; +import { isDynamoDbDialect, isElasticsearchDialect, isMongoDialect } from '../utils/assert-dialect-supported.js'; +import { runSchemaChangeAiLoop } from './run-schema-change-ai-loop.js'; +import { + createDynamoDbSchemaChangeTools, + createElasticsearchSchemaChangeTools, + createMongoSchemaChangeTools, + createSchemaChangeTools, + ProposeSchemaChangeArgs, +} from './schema-change-tools.js'; + +export interface SchemaChangeFixAiLoopOptions { + aiCoreService: AICoreService; + provider: AIProviderType; + connection: ConnectionEntity; + connectionType: ConnectionTypesEnum; + changeType: SchemaChangeTypeEnum; + targetTableName: string; + originalUserPrompt: string; + failingSql: string; + failingRollbackSql: string | null; + executionError: string; + logger?: Logger; +} + +export interface SchemaChangeFixResult { + fixedProposal: ProposeSchemaChangeArgs; +} + +export async function runSchemaChangeFixAiLoop( + opts: SchemaChangeFixAiLoopOptions, +): Promise { + const { + aiCoreService, + provider, + connection, + connectionType, + changeType, + targetTableName, + originalUserPrompt, + failingSql, + failingRollbackSql, + executionError, + logger, + } = opts; + + const systemPrompt = buildFixSystemPrompt(connectionType, changeType, targetTableName); + const humanPrompt = buildFixHumanPrompt(originalUserPrompt, failingSql, failingRollbackSql, executionError); + const messages = new MessageBuilder().system(systemPrompt).human(humanPrompt).build(); + + const tools = isMongoDialect(connectionType) + ? createMongoSchemaChangeTools() + : isDynamoDbDialect(connectionType) + ? createDynamoDbSchemaChangeTools() + : isElasticsearchDialect(connectionType) + ? createElasticsearchSchemaChangeTools() + : createSchemaChangeTools(); + + const dao = getDataAccessObject(connection); + + const result = await runSchemaChangeAiLoop({ + aiCoreService, + provider, + messages, + tools, + dao, + userEmail: undefined, + logger, + maxDepth: 4, + }); + + if (result.kind !== 'proposals') { + logger?.warn(`AI fix loop returned ${result.kind} instead of proposals; treating as fix-failed.`); + return null; + } + + if (result.proposals.length !== 1) { + logger?.warn( + `AI fix loop returned ${result.proposals.length} proposals; expected exactly 1. Treating as fix-failed.`, + ); + return null; + } + + const proposal = result.proposals[0]; + + if (proposal.changeType !== changeType) { + logger?.warn( + `AI fix loop changed changeType from ${changeType} to ${proposal.changeType}; treating as fix-failed.`, + ); + return null; + } + if (proposal.targetTableName !== targetTableName) { + logger?.warn( + `AI fix loop changed targetTableName from ${targetTableName} to ${proposal.targetTableName}; treating as fix-failed.`, + ); + return null; + } + if (!proposal.forwardSql || proposal.forwardSql.trim() === failingSql.trim()) { + logger?.warn('AI fix loop returned an identical or empty forwardSql; treating as fix-failed.'); + return null; + } + + return { fixedProposal: proposal }; +} + +function buildFixSystemPrompt( + connectionType: ConnectionTypesEnum, + changeType: SchemaChangeTypeEnum, + targetTableName: string, +): string { + return `You are a DDL repair assistant for ${connectionType}. + +A previously proposed schema change failed when applied against the live database. Your single job is to repair the failing statement and emit a corrected proposal via the appropriate proposeSchemaChange tool. + +Hard constraints (violations will be rejected and the fix discarded): +- Emit EXACTLY ONE proposal. +- The proposal MUST keep changeType = "${changeType}". +- The proposal MUST keep targetTableName = "${targetTableName}". +- The corrected forwardSql MUST be different from the failing forwardSql. +- All previously documented rules for ${changeType} on ${connectionType} still apply (single statement, dialect-correct identifier quoting, no DML, etc.). + +Workflow: +1. Read the database error carefully. Identify the precise cause (missing dependency, wrong type, reserved word, conflicting name, etc.). +2. If — and only if — the fix depends on inspecting the live structure of an existing table or collection, call getTableStructure first. +3. Emit the corrected proposal via the appropriate proposeSchemaChange / proposeMongoSchemaChange / proposeDynamoDbSchemaChange / proposeElasticsearchSchemaChange tool. + +Do NOT reply with free text. If you cannot produce a corrected statement, still emit a proposal with your best attempt and explain the residual risk in "reasoning".`; +} + +function buildFixHumanPrompt( + originalUserPrompt: string, + failingSql: string, + failingRollbackSql: string | null, + executionError: string, +): string { + return `Original user request: +${originalUserPrompt} + +Failing forwardSql: +${failingSql} + +Previously proposed rollbackSql: +${failingRollbackSql ?? '(none)'} + +Database error returned when applying forwardSql: +${executionError} + +Produce a corrected proposal that addresses the database error while still fulfilling the user's original request.`; +} diff --git a/backend/src/entities/table-schema/ai/schema-change-prompts.ts b/backend/src/entities/table-schema/ai/schema-change-prompts.ts index 8d4866d69..c2de72ec9 100644 --- a/backend/src/entities/table-schema/ai/schema-change-prompts.ts +++ b/backend/src/entities/table-schema/ai/schema-change-prompts.ts @@ -37,6 +37,7 @@ Your job: translate the user's natural-language request into ONE OR MORE DDL sta Workflow: 1. If the user's request references an existing table, call getTableStructure FIRST to inspect it. You may call it multiple times for different tables. 2. Once you have enough context, call proposeSchemaChange EXACTLY ONCE with a "proposals" array containing every change required by the user's request. Do not write free-text explanations outside the tool call. +3. If — and ONLY if — the user's request is genuinely too vague to translate into ANY concrete schema change (e.g. "draw a database", "make me something", with no domain, tables, or fields hinted), reply with a single short plain-text message containing ONE focused clarifying question (no tool call). Do not invent a domain. Once the user answers, proceed to step 2. Multi-proposal rules: - Order proposals so that any change depending on another comes AFTER its dependency. Tables referenced by foreign keys must be created BEFORE the tables that reference them. @@ -85,6 +86,7 @@ DynamoDB is a NoSQL key-value store with a fixed primary key schema per table an Workflow: 1. If the user's request references an existing table, call getTableStructure FIRST to see its key schema and sampled attributes. You may call it multiple times. 2. Once you have enough context, call proposeDynamoDbSchemaChange EXACTLY ONCE with a "proposals" array containing every change required by the user's request. Do not write free-text explanations outside the tool call. +3. If — and ONLY if — the user's request is genuinely too vague to translate into ANY concrete schema change (e.g. "draw a database", "make me something", with no domain, tables, or attributes hinted), reply with a single short plain-text message containing ONE focused clarifying question (no tool call). Do not invent a domain. Once the user answers, proceed to step 2. Multi-proposal rules: - Order proposals so dependent changes come after their prerequisites. @@ -168,6 +170,7 @@ Elasticsearch is a search engine where each "table" is an index with a mapping ( Workflow: 1. If the user's request references an existing index, call getTableStructure FIRST to inspect its sampled fields and inferred types. You may call it multiple times. 2. Once you have enough context, call proposeElasticsearchSchemaChange EXACTLY ONCE with a "proposals" array containing every change required by the user's request. Do not write free-text explanations outside the tool call. +3. If — and ONLY if — the user's request is genuinely too vague to translate into ANY concrete schema change (e.g. "draw a database", "make me something", with no domain, indices, or fields hinted), reply with a single short plain-text message containing ONE focused clarifying question (no tool call). Do not invent a domain. Once the user answers, proceed to step 2. Multi-proposal rules: - Order proposals so dependent changes come after their prerequisites. @@ -225,6 +228,7 @@ MongoDB does not use SQL DDL. Instead, schema changes are structured JSON operat Workflow: 1. If the user's request references an existing collection, call getTableStructure FIRST to inspect a sample of documents. You may call it multiple times for different collections. 2. Once you have enough context, call proposeMongoSchemaChange EXACTLY ONCE with a "proposals" array containing every change required by the user's request. Do not write free-text explanations outside the tool call. +3. If — and ONLY if — the user's request is genuinely too vague to translate into ANY concrete schema change (e.g. "draw a database", "make me something", with no domain, collections, or fields hinted), reply with a single short plain-text message containing ONE focused clarifying question (no tool call). Do not invent a domain. Once the user answers, proceed to step 2. Multi-proposal rules: - Order proposals so dependent changes come after their prerequisites (e.g. createCollection before createIndex on that collection). diff --git a/backend/src/entities/table-schema/application/data-transfer-objects/schema-change-batch-response.dto.ts b/backend/src/entities/table-schema/application/data-transfer-objects/schema-change-batch-response.dto.ts index b4ae87970..79a97e964 100644 --- a/backend/src/entities/table-schema/application/data-transfer-objects/schema-change-batch-response.dto.ts +++ b/backend/src/entities/table-schema/application/data-transfer-objects/schema-change-batch-response.dto.ts @@ -3,14 +3,17 @@ import { SchemaChangeResponseDto } from './schema-change-response.dto.js'; export class SchemaChangeBatchResponseDto { @ApiProperty({ + required: false, + nullable: true, description: - 'Identifier shared by every change generated from a single user prompt. Use it to approve/reject/rollback the entire batch in one call.', + 'Identifier shared by every change generated from a single user prompt. Use it to approve/reject/rollback the entire batch in one call. Null when the AI did not propose any change (e.g. it returned a clarifying question instead — see assistantMessage).', }) - batchId: string; + batchId: string | null; @ApiProperty({ type: [SchemaChangeResponseDto], - description: 'Generated changes ordered by orderInBatch (dependency order — parents first).', + description: + 'Generated changes ordered by orderInBatch (dependency order — parents first). Empty when the AI returned a clarifying question instead of a proposal.', }) changes: SchemaChangeResponseDto[]; @@ -21,4 +24,12 @@ export class SchemaChangeBatchResponseDto { 'Conversation thread ID. Present when the request used or created a chat thread. Pass it back as the threadId query param on subsequent generate calls to continue the conversation with full prior context.', }) threadId?: string | null; + + @ApiProperty({ + required: false, + nullable: true, + description: + "Free-text reply from the AI when it could not propose any change and needs more information from the user (e.g. a clarifying question). When present, batchId is null and changes is empty; resubmit the user's answer with the same threadId to continue the conversation.", + }) + assistantMessage?: string | null; } diff --git a/backend/src/entities/table-schema/application/data-transfer-objects/schema-change-response.dto.ts b/backend/src/entities/table-schema/application/data-transfer-objects/schema-change-response.dto.ts index fe4189337..6492d7a11 100644 --- a/backend/src/entities/table-schema/application/data-transfer-objects/schema-change-response.dto.ts +++ b/backend/src/entities/table-schema/application/data-transfer-objects/schema-change-response.dto.ts @@ -54,6 +54,25 @@ export class SchemaChangeResponseDto { @ApiProperty() autoRollbackSucceeded: boolean; + @ApiProperty({ + description: + 'True when the original AI-proposed SQL failed at apply time and the AI was asked to repair it. When true, forwardSql/rollbackSql reflect the repaired statements and the originals are preserved in aiAutoFixOriginalForwardSql/aiAutoFixOriginalRollbackSql.', + }) + aiAutoFixApplied: boolean; + + @ApiProperty({ required: false, nullable: true }) + aiAutoFixOriginalForwardSql: string | null; + + @ApiProperty({ required: false, nullable: true }) + aiAutoFixOriginalRollbackSql: string | null; + + @ApiProperty({ + required: false, + nullable: true, + description: 'Database error returned when the original SQL was attempted, before the AI repaired it.', + }) + aiAutoFixOriginalError: string | null; + @ApiProperty() userPrompt: string; diff --git a/backend/src/entities/table-schema/repository/custom-table-schema-change-repository-extension.ts b/backend/src/entities/table-schema/repository/custom-table-schema-change-repository-extension.ts index 3c78c5795..0ce00a07d 100644 --- a/backend/src/entities/table-schema/repository/custom-table-schema-change-repository-extension.ts +++ b/backend/src/entities/table-schema/repository/custom-table-schema-change-repository-extension.ts @@ -77,6 +77,24 @@ export const customTableSchemaChangeRepositoryExtension: ITableSchemaChangeRepos if (meta.userModifiedSql !== undefined) { patch.userModifiedSql = meta.userModifiedSql; } + if (meta.forwardSql !== undefined) { + patch.forwardSql = meta.forwardSql; + } + if (meta.rollbackSql !== undefined) { + patch.rollbackSql = meta.rollbackSql; + } + if (meta.aiAutoFixApplied !== undefined) { + patch.aiAutoFixApplied = meta.aiAutoFixApplied; + } + if (meta.aiAutoFixOriginalForwardSql !== undefined) { + patch.aiAutoFixOriginalForwardSql = meta.aiAutoFixOriginalForwardSql; + } + if (meta.aiAutoFixOriginalRollbackSql !== undefined) { + patch.aiAutoFixOriginalRollbackSql = meta.aiAutoFixOriginalRollbackSql; + } + if (meta.aiAutoFixOriginalError !== undefined) { + patch.aiAutoFixOriginalError = meta.aiAutoFixOriginalError; + } } await this.update({ id }, patch); return await this.findOne({ where: { id } }); diff --git a/backend/src/entities/table-schema/repository/table-schema-change.repository.interface.ts b/backend/src/entities/table-schema/repository/table-schema-change.repository.interface.ts index 1f2bc58d6..9a836bcb0 100644 --- a/backend/src/entities/table-schema/repository/table-schema-change.repository.interface.ts +++ b/backend/src/entities/table-schema/repository/table-schema-change.repository.interface.ts @@ -9,6 +9,12 @@ export interface UpdateSchemaChangeStatusMeta { autoRollbackAttempted?: boolean; autoRollbackSucceeded?: boolean; userModifiedSql?: string | null; + forwardSql?: string; + rollbackSql?: string | null; + aiAutoFixApplied?: boolean; + aiAutoFixOriginalForwardSql?: string | null; + aiAutoFixOriginalRollbackSql?: string | null; + aiAutoFixOriginalError?: string | null; } export interface ITableSchemaChangeRepository { diff --git a/backend/src/entities/table-schema/table-schema-change.entity.ts b/backend/src/entities/table-schema/table-schema-change.entity.ts index a527f3eeb..dab9d51f1 100644 --- a/backend/src/entities/table-schema/table-schema-change.entity.ts +++ b/backend/src/entities/table-schema/table-schema-change.entity.ts @@ -88,6 +88,18 @@ export class TableSchemaChangeEntity { @Column({ type: 'varchar', length: 128, nullable: true }) aiModelUsed: string | null; + @Column({ type: 'boolean', default: false }) + aiAutoFixApplied: boolean; + + @Column({ type: 'text', nullable: true }) + aiAutoFixOriginalForwardSql: string | null; + + @Column({ type: 'text', nullable: true }) + aiAutoFixOriginalRollbackSql: string | null; + + @Column({ type: 'text', nullable: true }) + aiAutoFixOriginalError: string | null; + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) createdAt: Date; diff --git a/backend/src/entities/table-schema/table-schema.controller.ts b/backend/src/entities/table-schema/table-schema.controller.ts index 38a59d895..c0869838e 100644 --- a/backend/src/entities/table-schema/table-schema.controller.ts +++ b/backend/src/entities/table-schema/table-schema.controller.ts @@ -72,7 +72,7 @@ export class TableSchemaController { @ApiOperation({ summary: - 'Generate one or more schema changes from a natural-language prompt. The response is always a batch envelope; single-change prompts return a length-1 array. Pass an optional threadId in the body to continue an existing conversation; the response returns the threadId to use for follow-up turns.', + "Generate one or more schema changes from a natural-language prompt. The response is always a batch envelope; single-change prompts return a length-1 array. Pass an optional threadId in the body to continue an existing conversation; the response returns the threadId to use for follow-up turns. If the AI needs more context (e.g. the request is too vague), the response carries an assistantMessage with a clarifying question, batchId is null, and changes is empty — resubmit the user's answer with the same threadId to continue.", }) @ApiParam({ name: 'connectionId', type: String }) @ApiBody({ type: GenerateSchemaChangeDto }) diff --git a/backend/src/entities/table-schema/use-cases/approve-and-apply-schema-change.use-case.ts b/backend/src/entities/table-schema/use-cases/approve-and-apply-schema-change.use-case.ts index e7a01d16a..f595ecd00 100644 --- a/backend/src/entities/table-schema/use-cases/approve-and-apply-schema-change.use-case.ts +++ b/backend/src/entities/table-schema/use-cases/approve-and-apply-schema-change.use-case.ts @@ -8,17 +8,25 @@ import { Scope, } from '@nestjs/common'; import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js'; +import { AIProviderType } from '../../../ai-core/interfaces/ai-service.interface.js'; +import { AICoreService } from '../../../ai-core/services/ai-core.service.js'; import AbstractUseCase from '../../../common/abstract-use.case.js'; import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; import { BaseType } from '../../../common/data-injection.tokens.js'; import { Messages } from '../../../exceptions/text/messages.js'; +import { getErrorMessage } from '../../../helpers/get-error-message.js'; +import { ConnectionEntity } from '../../connection/connection.entity.js'; +import { runSchemaChangeFixAiLoop } from '../ai/run-schema-change-fix-ai-loop.js'; +import { ProposeSchemaChangeArgs } from '../ai/schema-change-tools.js'; import { ApproveSchemaChangeDs } from '../application/data-structures/approve-schema-change.ds.js'; import { SchemaChangeResponseDto } from '../application/data-transfer-objects/schema-change-response.dto.js'; +import { TableSchemaChangeEntity } from '../table-schema-change.entity.js'; import { isDynamoDbSchemaChangeType, isElasticsearchSchemaChangeType, isMongoSchemaChangeType, SchemaChangeStatusEnum, + SchemaChangeTypeEnum, } from '../table-schema-change-enums.js'; import { assertDialectSupported } from '../utils/assert-dialect-supported.js'; import { validateProposedDynamoDbOp } from '../utils/dynamodb-schema-op.js'; @@ -35,10 +43,12 @@ export class ApproveAndApplySchemaChangeUseCase implements IApproveSchemaChange { private readonly logger = new Logger(ApproveAndApplySchemaChangeUseCase.name); + private readonly provider: AIProviderType = AIProviderType.BEDROCK; constructor( @Inject(BaseType.GLOBAL_DB_CONTEXT) protected _dbContext: IGlobalDatabaseContext, + private readonly aiCoreService: AICoreService, ) { super(); } @@ -138,6 +148,18 @@ export class ApproveAndApplySchemaChangeUseCase const error = err as Error; this.logger.error(`Forward execution failed for change ${change.id}: ${error.message}`); + const repaired = await this.tryAutoFixAndApply({ + change, + connection, + connectionType, + failingSql: sqlToRun, + failingRollbackSql: change.rollbackSql, + executionError: error.message, + }); + if (repaired) { + return mapSchemaChangeToResponseDto(repaired); + } + let autoRollbackSucceeded = false; let rollbackError: string | null = null; if (change.rollbackSql) { @@ -172,4 +194,152 @@ export class ApproveAndApplySchemaChangeUseCase }); } } + + private async tryAutoFixAndApply(args: { + change: TableSchemaChangeEntity; + connection: ConnectionEntity; + connectionType: ConnectionTypesEnum; + failingSql: string; + failingRollbackSql: string | null; + executionError: string; + }): Promise { + const { change, connection, connectionType, failingSql, failingRollbackSql, executionError } = args; + + if (change.aiAutoFixApplied) { + this.logger.warn(`Change ${change.id} already auto-fixed once; skipping further auto-fix attempts.`); + return null; + } + + let fixed: ProposeSchemaChangeArgs | null; + try { + const fixResult = await runSchemaChangeFixAiLoop({ + aiCoreService: this.aiCoreService, + provider: this.provider, + connection, + connectionType, + changeType: change.changeType, + targetTableName: change.targetTableName, + originalUserPrompt: change.userPrompt, + failingSql, + failingRollbackSql, + executionError, + logger: this.logger, + }); + fixed = fixResult?.fixedProposal ?? null; + } catch (fixErr) { + this.logger.error(`AI auto-fix attempt failed for change ${change.id}: ${getErrorMessage(fixErr)}`); + return null; + } + if (!fixed) { + return null; + } + + try { + this.validateRepairedProposal(fixed, connectionType, change.changeType); + } catch (valErr) { + this.logger.warn(`AI auto-fix proposal for change ${change.id} failed validation: ${getErrorMessage(valErr)}`); + return null; + } + + try { + await executeSchemaChange({ + connection, + connectionType, + changeType: change.changeType, + targetTableName: change.targetTableName, + sql: fixed.forwardSql, + }); + } catch (retryErr) { + this.logger.warn( + `AI auto-fix retry execute failed for change ${change.id}: ${getErrorMessage(retryErr)}; falling through to rollback.`, + ); + return null; + } + + const updated = await this._dbContext.tableSchemaChangeRepository.updateStatus( + change.id, + SchemaChangeStatusEnum.APPLIED, + { + appliedAt: new Date(), + executionError: null, + forwardSql: fixed.forwardSql, + rollbackSql: fixed.rollbackSql ?? null, + aiAutoFixApplied: true, + aiAutoFixOriginalForwardSql: failingSql, + aiAutoFixOriginalRollbackSql: failingRollbackSql, + aiAutoFixOriginalError: executionError, + }, + ); + this.logger.log(`AI auto-fix succeeded for change ${change.id}; original error: ${executionError}`); + return updated; + } + + private validateRepairedProposal( + proposal: ProposeSchemaChangeArgs, + connectionType: ConnectionTypesEnum, + changeType: SchemaChangeTypeEnum, + ): void { + if (isMongoSchemaChangeType(changeType)) { + validateProposedMongoOp({ + opJson: proposal.forwardSql, + changeType, + targetTableName: proposal.targetTableName, + }); + if (proposal.rollbackSql) { + validateProposedMongoOp({ + opJson: proposal.rollbackSql, + changeType, + targetTableName: proposal.targetTableName, + allowAnyOperation: true, + }); + } + return; + } + if (isDynamoDbSchemaChangeType(changeType)) { + validateProposedDynamoDbOp({ + opJson: proposal.forwardSql, + changeType, + targetTableName: proposal.targetTableName, + }); + if (proposal.rollbackSql) { + validateProposedDynamoDbOp({ + opJson: proposal.rollbackSql, + changeType, + targetTableName: proposal.targetTableName, + allowAnyOperation: true, + }); + } + return; + } + if (isElasticsearchSchemaChangeType(changeType)) { + validateProposedElasticsearchOp({ + opJson: proposal.forwardSql, + changeType, + targetTableName: proposal.targetTableName, + }); + if (proposal.rollbackSql) { + validateProposedElasticsearchOp({ + opJson: proposal.rollbackSql, + changeType, + targetTableName: proposal.targetTableName, + allowAnyOperation: true, + }); + } + return; + } + validateProposedDdl({ + sql: proposal.forwardSql, + connectionType, + changeType, + targetTableName: proposal.targetTableName, + }); + if (proposal.rollbackSql) { + validateProposedDdl({ + sql: proposal.rollbackSql, + connectionType, + changeType: SchemaChangeTypeEnum.ROLLBACK, + targetTableName: proposal.targetTableName, + }); + } + } } diff --git a/backend/src/entities/table-schema/use-cases/approve-batch-schema-changes.use-case.ts b/backend/src/entities/table-schema/use-cases/approve-batch-schema-changes.use-case.ts index ee1034dd2..401d038a0 100644 --- a/backend/src/entities/table-schema/use-cases/approve-batch-schema-changes.use-case.ts +++ b/backend/src/entities/table-schema/use-cases/approve-batch-schema-changes.use-case.ts @@ -8,17 +8,33 @@ import { Scope, } from '@nestjs/common'; import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js'; +import { AIProviderType } from '../../../ai-core/interfaces/ai-service.interface.js'; +import { AICoreService } from '../../../ai-core/services/ai-core.service.js'; import AbstractUseCase from '../../../common/abstract-use.case.js'; import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; import { BaseType } from '../../../common/data-injection.tokens.js'; import { Messages } from '../../../exceptions/text/messages.js'; +import { getErrorMessage } from '../../../helpers/get-error-message.js'; +import { ConnectionEntity } from '../../connection/connection.entity.js'; +import { runSchemaChangeFixAiLoop } from '../ai/run-schema-change-fix-ai-loop.js'; +import { ProposeSchemaChangeArgs } from '../ai/schema-change-tools.js'; import { ApproveBatchSchemaChangeDs } from '../application/data-structures/approve-batch-schema-change.ds.js'; import { SchemaChangeBatchResponseDto } from '../application/data-transfer-objects/schema-change-batch-response.dto.js'; import { TableSchemaChangeEntity } from '../table-schema-change.entity.js'; -import { SchemaChangeStatusEnum } from '../table-schema-change-enums.js'; +import { + isDynamoDbSchemaChangeType, + isElasticsearchSchemaChangeType, + isMongoSchemaChangeType, + SchemaChangeStatusEnum, + SchemaChangeTypeEnum, +} from '../table-schema-change-enums.js'; import { assertDialectSupported } from '../utils/assert-dialect-supported.js'; +import { validateProposedDynamoDbOp } from '../utils/dynamodb-schema-op.js'; +import { validateProposedElasticsearchOp } from '../utils/elasticsearch-schema-op.js'; import { executeSchemaChange } from '../utils/execute-schema-change.js'; import { mapSchemaChangeToResponseDto } from '../utils/map-schema-change-to-response-dto.js'; +import { validateProposedMongoOp } from '../utils/mongo-schema-op.js'; +import { validateProposedDdl } from '../utils/validate-proposed-ddl.js'; import { IApproveBatchSchemaChange } from './table-schema-use-cases.interface.js'; @Injectable({ scope: Scope.REQUEST }) @@ -27,10 +43,12 @@ export class ApproveBatchSchemaChangesUseCase implements IApproveBatchSchemaChange { private readonly logger = new Logger(ApproveBatchSchemaChangesUseCase.name); + private readonly provider: AIProviderType = AIProviderType.BEDROCK; constructor( @Inject(BaseType.GLOBAL_DB_CONTEXT) protected _dbContext: IGlobalDatabaseContext, + private readonly aiCoreService: AICoreService, ) { super(); } @@ -82,13 +100,14 @@ export class ApproveBatchSchemaChangesUseCase throw new ConflictException(`Schema change ${item.id} state changed concurrently; batch aborted.`); } + const sqlToRun = item.userModifiedSql ?? item.forwardSql; try { await executeSchemaChange({ connection, connectionType, changeType: item.changeType, targetTableName: item.targetTableName, - sql: item.userModifiedSql ?? item.forwardSql, + sql: sqlToRun, }); const updated = await this._dbContext.tableSchemaChangeRepository.updateStatus( item.id, @@ -104,6 +123,19 @@ export class ApproveBatchSchemaChangesUseCase `Batch ${batchId}: forward execution failed at order ${item.orderInBatch} (${item.id}): ${error.message}`, ); + const repaired = await this.tryAutoFixAndApply({ + change: item, + connection, + connectionType, + failingSql: sqlToRun, + failingRollbackSql: item.rollbackSql, + executionError: error.message, + }); + if (repaired) { + applied.push(repaired); + continue; + } + const compensation = await this.runCompensation(item, connection, connectionType); const composedError = compensation.rollbackError ? `${error.message} | self auto-rollback failed: ${compensation.rollbackError}` @@ -162,6 +194,154 @@ export class ApproveBatchSchemaChangesUseCase } } + private async tryAutoFixAndApply(args: { + change: TableSchemaChangeEntity; + connection: ConnectionEntity; + connectionType: ConnectionTypesEnum; + failingSql: string; + failingRollbackSql: string | null; + executionError: string; + }): Promise { + const { change, connection, connectionType, failingSql, failingRollbackSql, executionError } = args; + + if (change.aiAutoFixApplied) { + this.logger.warn(`Change ${change.id} already auto-fixed once; skipping further auto-fix attempts.`); + return null; + } + + let fixed: ProposeSchemaChangeArgs | null; + try { + const fixResult = await runSchemaChangeFixAiLoop({ + aiCoreService: this.aiCoreService, + provider: this.provider, + connection, + connectionType, + changeType: change.changeType, + targetTableName: change.targetTableName, + originalUserPrompt: change.userPrompt, + failingSql, + failingRollbackSql, + executionError, + logger: this.logger, + }); + fixed = fixResult?.fixedProposal ?? null; + } catch (fixErr) { + this.logger.error(`AI auto-fix attempt failed for change ${change.id}: ${getErrorMessage(fixErr)}`); + return null; + } + if (!fixed) { + return null; + } + + try { + this.validateRepairedProposal(fixed, connectionType, change.changeType); + } catch (valErr) { + this.logger.warn(`AI auto-fix proposal for change ${change.id} failed validation: ${getErrorMessage(valErr)}`); + return null; + } + + try { + await executeSchemaChange({ + connection, + connectionType, + changeType: change.changeType, + targetTableName: change.targetTableName, + sql: fixed.forwardSql, + }); + } catch (retryErr) { + this.logger.warn( + `AI auto-fix retry execute failed for change ${change.id}: ${getErrorMessage(retryErr)}; falling through to compensation.`, + ); + return null; + } + + const updated = await this._dbContext.tableSchemaChangeRepository.updateStatus( + change.id, + SchemaChangeStatusEnum.APPLIED, + { + appliedAt: new Date(), + executionError: null, + forwardSql: fixed.forwardSql, + rollbackSql: fixed.rollbackSql ?? null, + aiAutoFixApplied: true, + aiAutoFixOriginalForwardSql: failingSql, + aiAutoFixOriginalRollbackSql: failingRollbackSql, + aiAutoFixOriginalError: executionError, + }, + ); + this.logger.log(`AI auto-fix succeeded for change ${change.id}; original error: ${executionError}`); + return updated; + } + + private validateRepairedProposal( + proposal: ProposeSchemaChangeArgs, + connectionType: ConnectionTypesEnum, + changeType: SchemaChangeTypeEnum, + ): void { + if (isMongoSchemaChangeType(changeType)) { + validateProposedMongoOp({ + opJson: proposal.forwardSql, + changeType, + targetTableName: proposal.targetTableName, + }); + if (proposal.rollbackSql) { + validateProposedMongoOp({ + opJson: proposal.rollbackSql, + changeType, + targetTableName: proposal.targetTableName, + allowAnyOperation: true, + }); + } + return; + } + if (isDynamoDbSchemaChangeType(changeType)) { + validateProposedDynamoDbOp({ + opJson: proposal.forwardSql, + changeType, + targetTableName: proposal.targetTableName, + }); + if (proposal.rollbackSql) { + validateProposedDynamoDbOp({ + opJson: proposal.rollbackSql, + changeType, + targetTableName: proposal.targetTableName, + allowAnyOperation: true, + }); + } + return; + } + if (isElasticsearchSchemaChangeType(changeType)) { + validateProposedElasticsearchOp({ + opJson: proposal.forwardSql, + changeType, + targetTableName: proposal.targetTableName, + }); + if (proposal.rollbackSql) { + validateProposedElasticsearchOp({ + opJson: proposal.rollbackSql, + changeType, + targetTableName: proposal.targetTableName, + allowAnyOperation: true, + }); + } + return; + } + validateProposedDdl({ + sql: proposal.forwardSql, + connectionType, + changeType, + targetTableName: proposal.targetTableName, + }); + if (proposal.rollbackSql) { + validateProposedDdl({ + sql: proposal.rollbackSql, + connectionType, + changeType: SchemaChangeTypeEnum.ROLLBACK, + targetTableName: proposal.targetTableName, + }); + } + } + private async rollbackApplied( applied: TableSchemaChangeEntity[], connection: Parameters[0]['connection'], diff --git a/backend/src/entities/table-schema/use-cases/generate-schema-change.use-case.ts b/backend/src/entities/table-schema/use-cases/generate-schema-change.use-case.ts index 2dc613a1c..8e72a9be2 100644 --- a/backend/src/entities/table-schema/use-cases/generate-schema-change.use-case.ts +++ b/backend/src/entities/table-schema/use-cases/generate-schema-change.use-case.ts @@ -106,9 +106,9 @@ export class GenerateSchemaChangeUseCase }); } - let proposals: ProposeSchemaChangeArgs[]; + let loopResult; try { - const result = await runSchemaChangeAiLoop({ + loopResult = await runSchemaChangeAiLoop({ aiCoreService: this.aiCoreService, provider: this.provider, messages, @@ -117,12 +117,27 @@ export class GenerateSchemaChangeUseCase userEmail: undefined, logger: this.logger, }); - proposals = result.proposals; } catch (err) { this.logger.error(`AI loop failed: ${getErrorMessage(err)}`); throw new BadRequestException(`AI generation failed: ${getErrorMessage(err)}`); } + if (loopResult.kind === 'clarification') { + await this._dbContext.schemaChangeChatMessageRepository.saveMessage( + chat.id, + loopResult.assistantMessage, + MessageRole.ai, + ); + return { + batchId: null, + threadId: chat.id, + changes: [], + assistantMessage: loopResult.assistantMessage, + }; + } + + const proposals: ProposeSchemaChangeArgs[] = loopResult.proposals; + for (let i = 0; i < proposals.length; i++) { this.validateProposal(proposals[i], connectionType, i); } diff --git a/backend/src/entities/table-schema/utils/map-schema-change-to-response-dto.ts b/backend/src/entities/table-schema/utils/map-schema-change-to-response-dto.ts index eecdf0596..8e2f0784a 100644 --- a/backend/src/entities/table-schema/utils/map-schema-change-to-response-dto.ts +++ b/backend/src/entities/table-schema/utils/map-schema-change-to-response-dto.ts @@ -20,6 +20,10 @@ export function mapSchemaChangeToResponseDto(entity: TableSchemaChangeEntity): S isReversible: entity.isReversible, autoRollbackAttempted: entity.autoRollbackAttempted, autoRollbackSucceeded: entity.autoRollbackSucceeded, + aiAutoFixApplied: entity.aiAutoFixApplied, + aiAutoFixOriginalForwardSql: entity.aiAutoFixOriginalForwardSql, + aiAutoFixOriginalRollbackSql: entity.aiAutoFixOriginalRollbackSql, + aiAutoFixOriginalError: entity.aiAutoFixOriginalError, userPrompt: entity.userPrompt, aiSummary: entity.aiSummary, aiReasoning: entity.aiReasoning, diff --git a/backend/src/migrations/1778767036234-AddSchemaChangeChatEntities.ts b/backend/src/migrations/1779975103808-AddAiAutoFixColumnsToTableSchemaChange.ts similarity index 71% rename from backend/src/migrations/1778767036234-AddSchemaChangeChatEntities.ts rename to backend/src/migrations/1779975103808-AddAiAutoFixColumnsToTableSchemaChange.ts index ba00cb234..022248d4a 100644 --- a/backend/src/migrations/1778767036234-AddSchemaChangeChatEntities.ts +++ b/backend/src/migrations/1779975103808-AddAiAutoFixColumnsToTableSchemaChange.ts @@ -1,39 +1,47 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddSchemaChangeChatEntities1778767036234 implements MigrationInterface { - name = 'AddSchemaChangeChatEntities1778767036234'; +export class AddAiAutoFixColumnsToTableSchemaChange1779975103808 implements MigrationInterface { + name = 'AddAiAutoFixColumnsToTableSchemaChange1779975103808'; public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `CREATE TABLE "schema_change_chat" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP DEFAULT now(), "user_id" uuid NOT NULL, "connection_id" character varying(38) NOT NULL, "last_batch_id" uuid, CONSTRAINT "PK_60082e3e240c265fc043290381d" PRIMARY KEY ("id"))`, - ); await queryRunner.query( `CREATE TYPE "public"."schema_change_chat_message_role_enum" AS ENUM('user', 'ai', 'system')`, ); await queryRunner.query( `CREATE TABLE "schema_change_chat_message" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "message" text, "role" "public"."schema_change_chat_message_role_enum", "batch_id" uuid, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP DEFAULT now(), "chat_id" uuid NOT NULL, CONSTRAINT "PK_5984cdb248fa9c2f55f5a19022c" PRIMARY KEY ("id"))`, ); + await queryRunner.query( + `CREATE TABLE "schema_change_chat" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP DEFAULT now(), "user_id" uuid NOT NULL, "connection_id" character varying(38) NOT NULL, "last_batch_id" uuid, CONSTRAINT "PK_60082e3e240c265fc043290381d" PRIMARY KEY ("id"))`, + ); await queryRunner.query(`ALTER TABLE "ai_chat_message" DROP COLUMN "response_id"`); + await queryRunner.query(`ALTER TABLE "table_schema_change" ADD "aiAutoFixApplied" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "table_schema_change" ADD "aiAutoFixOriginalForwardSql" text`); + await queryRunner.query(`ALTER TABLE "table_schema_change" ADD "aiAutoFixOriginalRollbackSql" text`); + await queryRunner.query(`ALTER TABLE "table_schema_change" ADD "aiAutoFixOriginalError" text`); await queryRunner.query( - `ALTER TABLE "schema_change_chat" ADD CONSTRAINT "FK_4dbf7dad457505747189fb98d7e" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + `ALTER TABLE "schema_change_chat_message" ADD CONSTRAINT "FK_32825f4780664738f60fa75cd50" FOREIGN KEY ("chat_id") REFERENCES "schema_change_chat"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, ); await queryRunner.query( - `ALTER TABLE "schema_change_chat" ADD CONSTRAINT "FK_9f9acf0578fcf239576640d7b7b" FOREIGN KEY ("connection_id") REFERENCES "connection"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + `ALTER TABLE "schema_change_chat" ADD CONSTRAINT "FK_4dbf7dad457505747189fb98d7e" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, ); await queryRunner.query( - `ALTER TABLE "schema_change_chat_message" ADD CONSTRAINT "FK_32825f4780664738f60fa75cd50" FOREIGN KEY ("chat_id") REFERENCES "schema_change_chat"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + `ALTER TABLE "schema_change_chat" ADD CONSTRAINT "FK_9f9acf0578fcf239576640d7b7b" FOREIGN KEY ("connection_id") REFERENCES "connection"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, ); } public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "schema_change_chat" DROP CONSTRAINT "FK_9f9acf0578fcf239576640d7b7b"`); + await queryRunner.query(`ALTER TABLE "schema_change_chat" DROP CONSTRAINT "FK_4dbf7dad457505747189fb98d7e"`); await queryRunner.query( `ALTER TABLE "schema_change_chat_message" DROP CONSTRAINT "FK_32825f4780664738f60fa75cd50"`, ); - await queryRunner.query(`ALTER TABLE "schema_change_chat" DROP CONSTRAINT "FK_9f9acf0578fcf239576640d7b7b"`); - await queryRunner.query(`ALTER TABLE "schema_change_chat" DROP CONSTRAINT "FK_4dbf7dad457505747189fb98d7e"`); + await queryRunner.query(`ALTER TABLE "table_schema_change" DROP COLUMN "aiAutoFixOriginalError"`); + await queryRunner.query(`ALTER TABLE "table_schema_change" DROP COLUMN "aiAutoFixOriginalRollbackSql"`); + await queryRunner.query(`ALTER TABLE "table_schema_change" DROP COLUMN "aiAutoFixOriginalForwardSql"`); + await queryRunner.query(`ALTER TABLE "table_schema_change" DROP COLUMN "aiAutoFixApplied"`); await queryRunner.query(`ALTER TABLE "ai_chat_message" ADD "response_id" character varying(255)`); + await queryRunner.query(`DROP TABLE "schema_change_chat"`); await queryRunner.query(`DROP TABLE "schema_change_chat_message"`); await queryRunner.query(`DROP TYPE "public"."schema_change_chat_message_role_enum"`); - await queryRunner.query(`DROP TABLE "schema_change_chat"`); } } diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-ai-settings-creation-stream-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-ai-settings-creation-stream-e2e.test.ts index 134057f93..cbaf60ec5 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-ai-settings-creation-stream-e2e.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-ai-settings-creation-stream-e2e.test.ts @@ -193,8 +193,13 @@ test.serial(`${currentTest} streams human-readable progress and ends with a comp test.serial(`${currentTest} streams a no-new-tables message when settings already exist`, async (t) => { const connectionToTestDB = getTestData(mockFactory).connectionToPostgresSchema; + // Use a schema unique to this test so concurrently-running test files (which share the + // same Postgres and the default "test_schema") cannot inject new tables between the two + // scans below, which would otherwise make the "no new tables" assertion flaky. + const isolatedSchema = `ai_stream_${faker.string.alphanumeric(10).toLowerCase()}`; + connectionToTestDB.schema = isolatedSchema; const { token } = await registerUserAndReturnUserInfo(app); - const { testTableName } = await createTestPostgresTableWithSchema(connectionToTestDB); + const { testTableName } = await createTestPostgresTableWithSchema(connectionToTestDB, 42, 'Vasia', isolatedSchema); testTables.push(testTableName); const createConnectionResponse = await request(app.getHttpServer()) diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-table-schema-dynamodb-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-table-schema-dynamodb-e2e.test.ts index 1b1738881..b651b78dd 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-table-schema-dynamodb-e2e.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-table-schema-dynamodb-e2e.test.ts @@ -47,6 +47,7 @@ interface DynamoProposedChange { } let nextProposal: DynamoProposedChange | null = null; +let nextFixProposal: DynamoProposedChange | null = null; function createDynamoProposalStream(proposal: DynamoProposedChange) { return { @@ -64,8 +65,21 @@ function createDynamoProposalStream(proposal: DynamoProposedChange) { }; } +function isFixCall(messages: unknown): boolean { + if (!Array.isArray(messages)) return false; + for (const m of messages) { + const content = (m as { content?: unknown })?.content; + if (typeof content === 'string' && content.includes('DDL repair assistant')) return true; + } + return false; +} + const mockAICoreService = { - streamChatWithToolsAndProvider: async () => { + streamChatWithToolsAndProvider: async (_provider: unknown, messages: unknown) => { + if (isFixCall(messages)) { + if (!nextFixProposal) throw new Error('Test invoked the AI fix loop but nextFixProposal was not set.'); + return createDynamoProposalStream(nextFixProposal); + } if (!nextProposal) throw new Error('Test must set nextProposal.'); return createDynamoProposalStream(nextProposal); }, @@ -689,3 +703,95 @@ test.serial('DynamoDB: runtime failure marks FAILED and attempts auto-rollback', t.true(record.autoRollbackAttempted); t.truthy(record.executionError); }); + +test.serial('DynamoDB: runtime failure is repaired by AI auto-fix and applied', async (t) => { + const { token } = await registerUserAndReturnUserInfo(app); + const connectionId = await createConnection(token); + const tableName = randomTableName('ra_ddb_fix'); + createdTables.push(tableName); + + await seedTable(tableName, [{ AttributeName: 'id', KeyType: 'HASH' }], [{ AttributeName: 'id', AttributeType: 'S' }]); + + const fixedIndexName = 'gsi_fixed_email'; + const originalForwardOp = JSON.stringify({ + operation: 'updateTable', + tableName, + globalSecondaryIndexUpdates: [{ delete: { indexName: 'nonexistent_gsi' } }], + }); + const originalRollbackOp = JSON.stringify({ + operation: 'updateTable', + tableName, + attributeDefinitions: [ + { attributeName: 'id', attributeType: 'S' }, + { attributeName: 'email', attributeType: 'S' }, + ], + globalSecondaryIndexUpdates: [ + { + create: { + indexName: 'nonexistent_gsi', + keySchema: [{ attributeName: 'email', keyType: 'HASH' }], + projection: { projectionType: 'ALL' }, + }, + }, + ], + }); + const fixedForwardOp = JSON.stringify({ + operation: 'updateTable', + tableName, + attributeDefinitions: [ + { attributeName: 'id', attributeType: 'S' }, + { attributeName: 'email', attributeType: 'S' }, + ], + globalSecondaryIndexUpdates: [ + { + create: { + indexName: fixedIndexName, + keySchema: [{ attributeName: 'email', keyType: 'HASH' }], + projection: { projectionType: 'ALL' }, + }, + }, + ], + }); + const fixedRollbackOp = JSON.stringify({ + operation: 'updateTable', + tableName, + globalSecondaryIndexUpdates: [{ delete: { indexName: fixedIndexName } }], + }); + + nextProposal = { + forwardOp: originalForwardOp, + rollbackOp: originalRollbackOp, + changeType: SchemaChangeTypeEnum.DYNAMODB_UPDATE_TABLE, + targetTableName: tableName, + isReversible: true, + summary: 'delete missing GSI', + reasoning: '', + }; + nextFixProposal = { + forwardOp: fixedForwardOp, + rollbackOp: fixedRollbackOp, + changeType: SchemaChangeTypeEnum.DYNAMODB_UPDATE_TABLE, + targetTableName: tableName, + isReversible: true, + summary: 'AI repaired to create a real GSI', + reasoning: '', + }; + + const generateResp = await request(app.getHttpServer()) + .post(`/table-schema/${connectionId}/generate`) + .set('Cookie', token) + .send({ userPrompt: 'update the table' }); + const changeId = JSON.parse(generateResp.text).changes[0].id; + + const approveResp = await request(app.getHttpServer()) + .post(`/table-schema/change/${changeId}/approve`) + .set('Cookie', token) + .send({}); + t.is(approveResp.status, 200); + const applied = JSON.parse(approveResp.text); + t.is(applied.status, SchemaChangeStatusEnum.APPLIED); + t.true(applied.aiAutoFixApplied); + t.truthy(applied.aiAutoFixOriginalError); + t.is(applied.aiAutoFixOriginalForwardSql, originalForwardOp); + t.is(applied.forwardSql, fixedForwardOp); +}); diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-table-schema-elasticsearch-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-table-schema-elasticsearch-e2e.test.ts index bf4e5b672..d9179ad78 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-table-schema-elasticsearch-e2e.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-table-schema-elasticsearch-e2e.test.ts @@ -39,6 +39,7 @@ interface ElasticProposedChange { } let nextProposal: ElasticProposedChange | null = null; +let nextFixProposal: ElasticProposedChange | null = null; function createElasticProposalStream(proposal: ElasticProposedChange) { return { @@ -56,8 +57,21 @@ function createElasticProposalStream(proposal: ElasticProposedChange) { }; } +function isFixCall(messages: unknown): boolean { + if (!Array.isArray(messages)) return false; + for (const m of messages) { + const content = (m as { content?: unknown })?.content; + if (typeof content === 'string' && content.includes('DDL repair assistant')) return true; + } + return false; +} + const mockAICoreService = { - streamChatWithToolsAndProvider: async () => { + streamChatWithToolsAndProvider: async (_provider: unknown, messages: unknown) => { + if (isFixCall(messages)) { + if (!nextFixProposal) throw new Error('Test invoked the AI fix loop but nextFixProposal was not set.'); + return createElasticProposalStream(nextFixProposal); + } if (!nextProposal) throw new Error('Test must set nextProposal.'); return createElasticProposalStream(nextProposal); }, @@ -386,6 +400,69 @@ test.serial('Elasticsearch: invalid op marks FAILED and attempts auto-rollback', t.truthy(record.executionError); }); +test.serial('Elasticsearch: invalid op is repaired by AI auto-fix and applied', async (t) => { + const { token } = await registerUserAndReturnUserInfo(app); + const connectionId = await createConnection(token); + const indexName = randomIndexName('ra_es_fix'); + createdIndices.push(indexName); + + await createIndexWithMapping(indexName, { properties: { name: { type: 'keyword' } } }); + + const originalForwardOp = JSON.stringify({ + operation: 'updateMapping', + indexName, + properties: { bad_field: { type: 'totally_made_up_type' } }, + }); + const originalRollbackOp = JSON.stringify({ operation: 'deleteIndex', indexName }); + const fixedForwardOp = JSON.stringify({ + operation: 'updateMapping', + indexName, + properties: { phone: { type: 'keyword' } }, + }); + const fixedRollbackOp = JSON.stringify({ operation: 'deleteIndex', indexName }); + + nextProposal = { + forwardOp: originalForwardOp, + rollbackOp: originalRollbackOp, + changeType: SchemaChangeTypeEnum.ELASTICSEARCH_UPDATE_MAPPING, + targetTableName: indexName, + isReversible: false, + summary: 'mapping with bogus type', + reasoning: '', + }; + nextFixProposal = { + forwardOp: fixedForwardOp, + rollbackOp: fixedRollbackOp, + changeType: SchemaChangeTypeEnum.ELASTICSEARCH_UPDATE_MAPPING, + targetTableName: indexName, + isReversible: false, + summary: 'AI repaired the bogus type to keyword', + reasoning: '', + }; + + const generateResp = await request(app.getHttpServer()) + .post(`/table-schema/${connectionId}/generate`) + .set('Cookie', token) + .send({ userPrompt: 'add a field' }); + const changeId = JSON.parse(generateResp.text).changes[0].id; + + const approveResp = await request(app.getHttpServer()) + .post(`/table-schema/change/${changeId}/approve`) + .set('Cookie', token) + .send({ confirmedDestructive: true }); + t.is(approveResp.status, 200); + const applied = JSON.parse(approveResp.text); + t.is(applied.status, SchemaChangeStatusEnum.APPLIED); + t.true(applied.aiAutoFixApplied); + t.truthy(applied.aiAutoFixOriginalError); + t.is(applied.aiAutoFixOriginalForwardSql, originalForwardOp); + t.is(applied.forwardSql, fixedForwardOp); + + const mapping = await getMapping(indexName); + const properties = (mapping?.properties as Record) ?? {}; + t.is(properties.phone?.type, 'keyword'); +}); + test.serial('Elasticsearch: userModifiedSql JSON op is validated and applied', async (t) => { const { token } = await registerUserAndReturnUserInfo(app); const connectionId = await createConnection(token); diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-table-schema-mongodb-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-table-schema-mongodb-e2e.test.ts index bca7a1505..81ba3424e 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-table-schema-mongodb-e2e.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-table-schema-mongodb-e2e.test.ts @@ -39,6 +39,7 @@ interface MongoProposedChange { } let nextProposal: MongoProposedChange | null = null; +let nextFixProposal: MongoProposedChange | null = null; function createMongoProposalStream(proposal: MongoProposedChange) { return { @@ -56,8 +57,21 @@ function createMongoProposalStream(proposal: MongoProposedChange) { }; } +function isFixCall(messages: unknown): boolean { + if (!Array.isArray(messages)) return false; + for (const m of messages) { + const content = (m as { content?: unknown })?.content; + if (typeof content === 'string' && content.includes('DDL repair assistant')) return true; + } + return false; +} + const mockAICoreService = { - streamChatWithToolsAndProvider: async () => { + streamChatWithToolsAndProvider: async (_provider: unknown, messages: unknown) => { + if (isFixCall(messages)) { + if (!nextFixProposal) throw new Error('Test invoked the AI fix loop but nextFixProposal was not set.'); + return createMongoProposalStream(nextFixProposal); + } if (!nextProposal) throw new Error('Test must set nextProposal.'); return createMongoProposalStream(nextProposal); }, @@ -557,6 +571,76 @@ test.serial('MongoDB: invalid op marks FAILED and attempts auto-rollback', async t.truthy(record.executionError); }); +test.serial('MongoDB: invalid op is repaired by AI auto-fix and applied', async (t) => { + const { token } = await registerUserAndReturnUserInfo(app); + const connectionId = await createConnection(token); + const collectionName = randomCollectionName('ra_mfix'); + createdCollections.push(collectionName); + + const mongoParams = getTestData(mockFactory).mongoDbConnection; + await seedCollection(mongoParams, collectionName, [{ email: 'a@a.test' }]); + await withMongoClient(mongoParams, async (db) => { + await db.collection(collectionName).createIndex({ email: 1 }, { name: 'idx_real' }); + }); + + const originalForwardOp = JSON.stringify({ operation: 'dropIndex', collectionName, indexName: 'idx_missing' }); + const originalRollbackOp = JSON.stringify({ + operation: 'createIndex', + collectionName, + indexName: 'idx_missing', + indexSpec: { x: 1 }, + indexOptions: { name: 'idx_missing' }, + }); + const fixedForwardOp = JSON.stringify({ operation: 'dropIndex', collectionName, indexName: 'idx_real' }); + const fixedRollbackOp = JSON.stringify({ + operation: 'createIndex', + collectionName, + indexName: 'idx_real', + indexSpec: { email: 1 }, + indexOptions: { name: 'idx_real' }, + }); + + nextProposal = { + forwardOp: originalForwardOp, + rollbackOp: originalRollbackOp, + changeType: SchemaChangeTypeEnum.MONGO_DROP_INDEX, + targetTableName: collectionName, + isReversible: true, + summary: 'drop wrong index', + reasoning: '', + }; + nextFixProposal = { + forwardOp: fixedForwardOp, + rollbackOp: fixedRollbackOp, + changeType: SchemaChangeTypeEnum.MONGO_DROP_INDEX, + targetTableName: collectionName, + isReversible: true, + summary: 'corrected index name', + reasoning: 'AI repaired indexName', + }; + + const generateResp = await request(app.getHttpServer()) + .post(`/table-schema/${connectionId}/generate`) + .set('Cookie', token) + .send({ userPrompt: 'drop the email index' }); + const changeId = JSON.parse(generateResp.text).changes[0].id; + + const approveResp = await request(app.getHttpServer()) + .post(`/table-schema/change/${changeId}/approve`) + .set('Cookie', token) + .send({}); + t.is(approveResp.status, 200); + const applied = JSON.parse(approveResp.text); + t.is(applied.status, SchemaChangeStatusEnum.APPLIED); + t.true(applied.aiAutoFixApplied); + t.truthy(applied.aiAutoFixOriginalError); + t.is(applied.aiAutoFixOriginalForwardSql, originalForwardOp); + t.is(applied.forwardSql, fixedForwardOp); + + const indexes = await getIndexes(mongoParams, collectionName); + t.falsy(indexes.find((idx) => idx.name === 'idx_real')); +}); + test.serial('MongoDB: tool/changeType mismatch is rejected', async (t) => { const { token } = await registerUserAndReturnUserInfo(app); const connectionId = await createConnection(token); diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-table-schema-postgres-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-table-schema-postgres-e2e.test.ts index 88fc9856d..305da3781 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-table-schema-postgres-e2e.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-table-schema-postgres-e2e.test.ts @@ -46,6 +46,8 @@ interface ProposedChange { let nextProposal: ProposedChange | null = null; let nextProposals: ProposedChange[] | null = null; +let nextFixProposal: ProposedChange | null = null; +let nextFixProposals: ProposedChange[] | null = null; function resolveProposals(): ProposedChange[] { if (nextProposals && nextProposals.length > 0) return nextProposals; @@ -53,6 +55,21 @@ function resolveProposals(): ProposedChange[] { throw new Error('Test must set nextProposal or nextProposals before invoking AI.'); } +function resolveFixProposals(): ProposedChange[] { + if (nextFixProposals && nextFixProposals.length > 0) return nextFixProposals; + if (nextFixProposal) return [nextFixProposal]; + throw new Error('Test invoked the AI fix loop but nextFixProposal/nextFixProposals was not set.'); +} + +function isFixCall(messages: unknown): boolean { + if (!Array.isArray(messages)) return false; + for (const m of messages) { + const content = (m as { content?: unknown })?.content; + if (typeof content === 'string' && content.includes('DDL repair assistant')) return true; + } + return false; +} + function createProposalStream(proposals: ProposedChange[]) { return { *[Symbol.asyncIterator]() { @@ -70,7 +87,8 @@ function createProposalStream(proposals: ProposedChange[]) { } const mockAICoreService = { - streamChatWithToolsAndProvider: async () => createProposalStream(resolveProposals()), + streamChatWithToolsAndProvider: async (_provider: unknown, messages: unknown) => + isFixCall(messages) ? createProposalStream(resolveFixProposals()) : createProposalStream(resolveProposals()), complete: async () => 'Mocked completion', chat: async () => ({ content: 'Mocked chat', responseId: faker.string.uuid() }), streamChat: async () => ({ @@ -98,6 +116,8 @@ const mockAICoreService = { test.beforeEach(() => { nextProposal = null; nextProposals = null; + nextFixProposal = null; + nextFixProposals = null; }); const mockFactory = new MockFactory(); @@ -309,11 +329,180 @@ test.serial('approve with invalid SQL marks FAILED and attempts auto-rollback', t.is(record.status, SchemaChangeStatusEnum.FAILED); t.true(record.autoRollbackAttempted); t.truthy(record.executionError); + t.false(record.aiAutoFixApplied); + + const knex = getTestKnex(getTestData(mockFactory).connectionToPostgres); + t.false(await knex.schema.hasTable(tableName)); +}); + +test.serial('approve with invalid SQL: AI auto-fix repairs and the change succeeds', async (t) => { + const { token } = await registerUserAndReturnUserInfo(app); + const connectionId = await createConnection(token); + const tableName = `ra_fix_${faker.string.alphanumeric(6).toLowerCase()}`; + testTables.push(tableName); + + nextProposal = { + forwardSql: `CREATE TABLE "${tableName}" (id INVALIDTYPE)`, + rollbackSql: `DROP TABLE IF EXISTS "${tableName}"`, + changeType: SchemaChangeTypeEnum.CREATE_TABLE, + targetTableName: tableName, + isReversible: true, + summary: 'initially broken', + reasoning: '', + }; + nextFixProposal = { + forwardSql: `CREATE TABLE "${tableName}" (id SERIAL PRIMARY KEY)`, + rollbackSql: `DROP TABLE "${tableName}"`, + changeType: SchemaChangeTypeEnum.CREATE_TABLE, + targetTableName: tableName, + isReversible: true, + summary: 'repaired', + reasoning: 'replaced INVALIDTYPE with SERIAL PRIMARY KEY', + }; + + const generateResp = await request(app.getHttpServer()) + .post(`/table-schema/${connectionId}/generate`) + .set('Cookie', token) + .send({ userPrompt: 'create a table' }); + const changeId = JSON.parse(generateResp.text).changes[0].id; + + const approveResp = await request(app.getHttpServer()) + .post(`/table-schema/change/${changeId}/approve`) + .set('Cookie', token) + .send({}); + t.is(approveResp.status, 200); + const applied = JSON.parse(approveResp.text); + t.is(applied.status, SchemaChangeStatusEnum.APPLIED); + t.true(applied.aiAutoFixApplied); + t.truthy(applied.aiAutoFixOriginalError); + t.is(applied.aiAutoFixOriginalForwardSql, `CREATE TABLE "${tableName}" (id INVALIDTYPE)`); + t.is(applied.forwardSql, `CREATE TABLE "${tableName}" (id SERIAL PRIMARY KEY)`); + + const knex = getTestKnex(getTestData(mockFactory).connectionToPostgres); + t.true(await knex.schema.hasTable(tableName)); +}); + +test.serial('approve with invalid SQL: AI auto-fix returns still-broken SQL → FAILED', async (t) => { + const { token } = await registerUserAndReturnUserInfo(app); + const connectionId = await createConnection(token); + const tableName = `ra_dblbad_${faker.string.alphanumeric(6).toLowerCase()}`; + + nextProposal = { + forwardSql: `CREATE TABLE "${tableName}" (id INVALIDTYPE)`, + rollbackSql: `DROP TABLE IF EXISTS "${tableName}"`, + changeType: SchemaChangeTypeEnum.CREATE_TABLE, + targetTableName: tableName, + isReversible: true, + summary: 'broken', + reasoning: '', + }; + nextFixProposal = { + forwardSql: `CREATE TABLE "${tableName}" (id STILLBADTYPE)`, + rollbackSql: `DROP TABLE IF EXISTS "${tableName}"`, + changeType: SchemaChangeTypeEnum.CREATE_TABLE, + targetTableName: tableName, + isReversible: true, + summary: 'also broken', + reasoning: '', + }; + + const generateResp = await request(app.getHttpServer()) + .post(`/table-schema/${connectionId}/generate`) + .set('Cookie', token) + .send({ userPrompt: 'will fail twice' }); + const changeId = JSON.parse(generateResp.text).changes[0].id; + + const approveResp = await request(app.getHttpServer()) + .post(`/table-schema/change/${changeId}/approve`) + .set('Cookie', token) + .send({}); + t.is(approveResp.status, 400); + + const getResp = await request(app.getHttpServer()).get(`/table-schema/change/${changeId}`).set('Cookie', token); + const record = JSON.parse(getResp.text); + t.is(record.status, SchemaChangeStatusEnum.FAILED); + t.false(record.aiAutoFixApplied); + t.is(record.forwardSql, `CREATE TABLE "${tableName}" (id INVALIDTYPE)`); const knex = getTestKnex(getTestData(mockFactory).connectionToPostgres); t.false(await knex.schema.hasTable(tableName)); }); +test.serial('batch approve: one item is auto-fixed by AI, batch completes', async (t) => { + const { token } = await registerUserAndReturnUserInfo(app); + const connectionId = await createConnection(token); + const ok1 = `ra_bf_ok1_${faker.string.alphanumeric(6).toLowerCase()}`; + const fix = `ra_bf_fix_${faker.string.alphanumeric(6).toLowerCase()}`; + const ok2 = `ra_bf_ok2_${faker.string.alphanumeric(6).toLowerCase()}`; + testTables.push(ok1, fix, ok2); + + nextProposals = [ + { + forwardSql: `CREATE TABLE "${ok1}" (id SERIAL PRIMARY KEY)`, + rollbackSql: `DROP TABLE "${ok1}"`, + changeType: SchemaChangeTypeEnum.CREATE_TABLE, + targetTableName: ok1, + isReversible: true, + summary: 'ok1', + reasoning: '', + }, + { + forwardSql: `CREATE TABLE "${fix}" (id INVALIDTYPE)`, + rollbackSql: `DROP TABLE IF EXISTS "${fix}"`, + changeType: SchemaChangeTypeEnum.CREATE_TABLE, + targetTableName: fix, + isReversible: true, + summary: 'will need a fix', + reasoning: '', + }, + { + forwardSql: `CREATE TABLE "${ok2}" (id SERIAL PRIMARY KEY)`, + rollbackSql: `DROP TABLE "${ok2}"`, + changeType: SchemaChangeTypeEnum.CREATE_TABLE, + targetTableName: ok2, + isReversible: true, + summary: 'ok2', + reasoning: '', + }, + ]; + nextFixProposal = { + forwardSql: `CREATE TABLE "${fix}" (id SERIAL PRIMARY KEY)`, + rollbackSql: `DROP TABLE "${fix}"`, + changeType: SchemaChangeTypeEnum.CREATE_TABLE, + targetTableName: fix, + isReversible: true, + summary: 'repaired', + reasoning: '', + }; + + const generateResp = await request(app.getHttpServer()) + .post(`/table-schema/${connectionId}/generate`) + .set('Cookie', token) + .send({ userPrompt: 'three tables, one bad' }); + const { batchId } = JSON.parse(generateResp.text); + + const approveResp = await request(app.getHttpServer()) + .post(`/table-schema/batch/${batchId}/approve`) + .set('Cookie', token) + .send({}); + t.is(approveResp.status, 200); + const applied = JSON.parse(approveResp.text); + t.is(applied.changes.length, 3); + t.true(applied.changes.every((c: { status: string }) => c.status === SchemaChangeStatusEnum.APPLIED)); + + const fixedItem = applied.changes.find((c: { targetTableName: string }) => c.targetTableName === fix); + t.truthy(fixedItem); + t.true(fixedItem.aiAutoFixApplied); + t.truthy(fixedItem.aiAutoFixOriginalError); + t.is(fixedItem.aiAutoFixOriginalForwardSql, `CREATE TABLE "${fix}" (id INVALIDTYPE)`); + t.is(fixedItem.forwardSql, `CREATE TABLE "${fix}" (id SERIAL PRIMARY KEY)`); + + const knex = getTestKnex(getTestData(mockFactory).connectionToPostgres); + t.true(await knex.schema.hasTable(ok1)); + t.true(await knex.schema.hasTable(fix)); + t.true(await knex.schema.hasTable(ok2)); +}); + test.serial('rollback of PENDING change is rejected', async (t) => { const { token } = await registerUserAndReturnUserInfo(app); const connectionId = await createConnection(token); diff --git a/backend/test/utils/create-test-table.ts b/backend/test/utils/create-test-table.ts index 6f2a846b7..daa9a6965 100644 --- a/backend/test/utils/create-test-table.ts +++ b/backend/test/utils/create-test-table.ts @@ -569,10 +569,10 @@ export async function createTestPostgresTableWithSchema( connectionParams: any, testEntitiesSeedsCount = 42, testSearchedUserName = 'Vasia', + testSchema = 'test_schema', ) { const Knex = getTestKnex(connectionParams); const testTableName = getRandomTestTableName(); - const testSchema = 'test_schema'; await Knex.schema.dropTableIfExists(testTableName); const testTableColumnName = 'name'; @@ -611,6 +611,7 @@ export async function createTestPostgresTableWithSchema( testTableColumnName: testTableColumnName, testTableSecondColumnName: testTableSecondColumnName, testEntitiesSeedsCount: testEntitiesSeedsCount, + testSchema: testSchema, }; } diff --git a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.ts b/frontend/src/app/components/edit-database-schema/edit-database-schema.component.ts index 608e2dc54..779580ab7 100644 --- a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.ts +++ b/frontend/src/app/components/edit-database-schema/edit-database-schema.component.ts @@ -162,11 +162,14 @@ export class EditDatabaseSchemaComponent implements OnInit { return next; }); } else { + const clarification = result.assistantMessage?.trim(); this.messages.update((msgs) => [ ...msgs, { role: 'ai', - text: 'I could not generate any schema changes for that prompt. Could you describe your database in more detail?', + text: + clarification || + 'I could not generate any schema changes for that prompt. Could you describe your database in more detail?', }, ]); } @@ -204,12 +207,20 @@ export class EditDatabaseSchemaComponent implements OnInit { ]); } else { this.applied.set(true); + const autoFixed = result.changes.filter((c) => c.aiAutoFixApplied); + const successText = + autoFixed.length > 0 + ? `All changes applied successfully! ${autoFixed.length} statement(s) needed an AI repair before they could be applied — the originals failed against the database (e.g. ${autoFixed + .map((c) => `${c.targetTableName}: ${this._summarizeError(c.aiAutoFixOriginalError)}`) + .slice(0, 2) + .join('; ')}).` + : 'All changes applied successfully! Your tables have been created.'; this.messages.update((msgs) => msgs .map((m) => (m === batch ? { ...m, batchId: undefined } : m)) .concat({ role: 'ai', - text: 'All changes applied successfully! Your tables have been created.', + text: successText, }), ); this._loadDiagram('Updated Database Structure'); @@ -278,6 +289,12 @@ export class EditDatabaseSchemaComponent implements OnInit { } } + private _summarizeError(err: string | null | undefined): string { + if (!err) return 'unknown error'; + const compact = err.replace(/\s+/g, ' ').trim(); + return compact.length > 120 ? `${compact.slice(0, 120)}…` : compact; + } + private _mermaidHasEntities(source: string): boolean { if (!source) return false; for (const line of source.split('\n')) { diff --git a/frontend/src/app/services/table-schema.service.ts b/frontend/src/app/services/table-schema.service.ts index f03582138..35705f23d 100644 --- a/frontend/src/app/services/table-schema.service.ts +++ b/frontend/src/app/services/table-schema.service.ts @@ -54,12 +54,17 @@ export interface SchemaChangeResponse { createdAt: string; appliedAt: string | null; rolledBackAt: string | null; + aiAutoFixApplied?: boolean; + aiAutoFixOriginalForwardSql?: string | null; + aiAutoFixOriginalRollbackSql?: string | null; + aiAutoFixOriginalError?: string | null; } export interface SchemaChangeBatchResponse { - batchId: string; + batchId: string | null; changes: SchemaChangeResponse[]; threadId?: string | null; + assistantMessage?: string | null; } @Injectable({