+
+
+
+
+
@@ -837,12 +1080,23 @@ const targetConnectionInfo = computed(() => {
- {{ deployResult?.success ? t("diff.deploySuccess") : t("diff.deployFailed") }}
+ {{ t("diff.deployMixedTitle") }}
+ {{ t("diff.deployRolledBackTitle") }}
+ {{ deployResult?.success ? t("diff.deploySuccess") : t("diff.deployFailed") }}
-
+
+
{{ deployResult.message }}
+
+ {{ t("diff.deployMixedWarning") }}
+
+
+
+
{{ deployResult.message }}
+
+
{{ t("diff.deploySuccessMessage") }}
{{ t("diff.affectedRows") }}: {{ deployResult.affectedRows ?? 0 }}
@@ -875,11 +1129,25 @@ const targetConnectionInfo = computed(() => {
{{ t("schemaDiff.optionsTitle") }}
- ✕
+ ✕
+
+
+
diff --git a/apps/desktop/src/components/diff/SchemaDiffOptionsPanel.vue b/apps/desktop/src/components/diff/SchemaDiffOptionsPanel.vue
index 8c82e10c7..ca2f83094 100644
--- a/apps/desktop/src/components/diff/SchemaDiffOptionsPanel.vue
+++ b/apps/desktop/src/components/diff/SchemaDiffOptionsPanel.vue
@@ -147,6 +147,36 @@ function getItemClasses(state: "checked" | "unchecked" | "indeterminate"): strin
+
+
+
{{ t("schemaDiff.options.advancedSection") }}
+
+
+
+
+ {{ localOptions.renameThreshold.toFixed(2) }}
+
+
+ {{ t("schemaDiff.options.detectRenames") }}
+
+
+
+
+
+ {{ localOptions.compatibilityThreshold.toFixed(2) }}
+
+
+
+
+
+
+
+
+
{{ t("schemaDiff.options.batchSection") }}
+
+
+
+
diff --git a/apps/desktop/src/components/diff/StrictTagAlertPanel.vue b/apps/desktop/src/components/diff/StrictTagAlertPanel.vue
new file mode 100644
index 000000000..a66eef464
--- /dev/null
+++ b/apps/desktop/src/components/diff/StrictTagAlertPanel.vue
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
{{ t("strictTag.title") }}
+
+
+
+
+
{{ t("strictTag.blockingExecution") }}
+
+
+
+ {{ t("strictTag.noViolations") }}
+
+
+
+
+ {{ t("strictTag.violationsFound", { count: violations.length }) }}
+
+
+
+
+
+
+
+ | {{ t("strictTag.fileName") }} |
+ {{ t("strictTag.lineNumber") }} |
+ {{ t("strictTag.tagName") }} |
+ {{ t("strictTag.suggestion") }} |
+ {{ t("common.actions") }} |
+
+
+
+
+ | {{ v.fileName }} |
+ {{ v.lineNumber }} |
+ {{ v.tagName }} |
+ {{ v.suggestion }} |
+
+
+
+ {{ t("strictTag.registerTag") }}
+
+
+ {{ t("strictTag.removeTag") }}
+
+
+ {{ t("strictTag.ignore") }}
+
+
+ |
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index ad49b62df..b583d6168 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -4000,10 +4000,54 @@ export default {
port: "Port",
copied: "Copied to clipboard",
saveConfigPrompt: "Please enter config name:",
+ defaultConfigName: "Default",
+ dependsOn: "depends on",
+ dependedBy: "depended by",
close: "Close",
searchConnection: "Search connections...",
searchDatabase: "Search databases...",
searchSchema: "Search schemas...",
+ openFieldMapping: "Field Mapping",
+ fieldMapping: {
+ title: "Field Type Mapping",
+ sameTypeHint: "Source and target are the same database type. Field mapping is only needed when comparing across different database types.",
+ preset: "Built-in Preset",
+ selectPreset: "Select a preset...",
+ noMappings: "No field mappings defined",
+ sourceType: "Source Type",
+ targetType: "Target Type",
+ paramStrategy: "Param Strategy",
+ strategyPreserve: "Preserve",
+ strategyStrip: "Strip",
+ strategyCustom: "Custom",
+ customParamsPlaceholder: "e.g. (100)",
+ addMapping: "Add Mapping",
+ autoGenerate: "Auto-Generate",
+ import: "Import",
+ export: "Export",
+ done: "Done",
+ importTypeMismatch: "The imported mappings were exported for {src} → {tgt}, but the current comparison is {curSrc} → {curTgt}. The type mappings may not be accurate. Continue anyway?",
+ importSuccess: "Field mappings imported successfully",
+ importError: "Failed to import field mappings. Please check the JSON format.",
+ clearMappings: "Clear",
+ staleMappingWarning: "The database type has changed from {prevSrc} → {prevTgt} to {curSrc} → {curTgt}. Existing field mappings may no longer be valid. Clear them?",
+ },
+ deployMode: "Deploy",
+ forwardSql: "Forward SQL",
+ rollbackSql: "Rollback SQL",
+ rollbackMode: "Rollback",
+ deployMixed: "Deployment partially completed. Some statements may already be applied ({executedCount}/{statementCount}). DDL may not be transactional.",
+ deployRolledBack: "All changes have been rolled back.",
+ rollbackIncompleteBlocked: "Rollback SQL is incomplete (missing objects). Execution is blocked.",
+ rollbackIncompleteBanner: "Rollback is incomplete — missing trigger/table DDL. Fix missing objects before executing.",
+ deployMixedTitle: "Partially Deployed",
+ deployRolledBackTitle: "Rolled Back",
+ deployMixedWarning: "Some statements may already be applied while others failed. The database may be in an inconsistent state. Review the transaction log and take manual corrective action before retrying.",
+ },
+ rollbackComparison: {
+ title: "Rollback Comparison",
+ forwardSql: "Forward SQL",
+ rollbackSql: "Rollback SQL",
},
schemaDiff: {
optionsTitle: "Compare Options",
@@ -4052,6 +4096,17 @@ export default {
cascadeDelete: "Use CASCADE delete",
sequenceLastValues: "Compare sequence last values",
compareColumnOrder: "Compare column order",
+ detectRenames: "Detect renames",
+ detectTableRenames: "Detect table renames",
+ enableRollback: "Enable rollback",
+ advancedSection: "Advanced",
+ renameThreshold: "Rename threshold",
+ compatibilityThreshold: "Compatibility threshold",
+ sourceDialect: "Source dialect",
+ targetDialect: "Target dialect",
+ batchSection: "Batch",
+ batchPatterns: "Table patterns (comma-separated)",
+ dialectAuto: "auto",
},
},
dataCompare: {
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index b6422561c..26e1cbb2b 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -3785,6 +3785,50 @@ export default withEnglishFallback({
searchConnection: "Buscar conexiones...",
searchDatabase: "Buscar bases de datos...",
searchSchema: "Buscar esquemas...",
+ defaultConfigName: "Predeterminado",
+ dependsOn: "depende de",
+ dependedBy: "dependido por",
+ openFieldMapping: "Mapeo de Campos",
+ fieldMapping: {
+ title: "Mapeo de Tipos de Campo",
+ sameTypeHint: "Origen y destino son del mismo tipo de base de datos. El mapeo de campos solo es necesario al comparar entre diferentes tipos de bases de datos.",
+ preset: "Preajuste Integrado",
+ selectPreset: "Seleccionar un preajuste...",
+ noMappings: "No hay mapeos de campos definidos",
+ sourceType: "Tipo de Origen",
+ targetType: "Tipo de Destino",
+ paramStrategy: "Estrategia de Parámetros",
+ strategyPreserve: "Preservar",
+ strategyStrip: "Eliminar",
+ strategyCustom: "Personalizado",
+ customParamsPlaceholder: "ej. (100)",
+ addMapping: "Agregar Mapeo",
+ autoGenerate: "Auto-Generar",
+ import: "Importar",
+ export: "Exportar",
+ done: "Listo",
+ importTypeMismatch: "Los mapeos importados se exportaron para {src} → {tgt}, pero la comparación actual es {curSrc} → {curTgt}. Los mapeos de tipos pueden no ser precisos. ¿Continuar de todos modos?",
+ importSuccess: "Mapeos de campos importados correctamente",
+ importError: "Error al importar mapeos de campos. Verifique el formato JSON.",
+ clearMappings: "Limpiar",
+ staleMappingWarning: "El tipo de base de datos ha cambiado de {prevSrc} → {prevTgt} a {curSrc} → {curTgt}. Los mapeos de campos existentes pueden no ser válidos. ¿Limpiarlos?",
+ },
+ deployMode: "Desplegar",
+ forwardSql: "SQL Directo",
+ rollbackSql: "SQL de Reversión",
+ rollbackMode: "Reversión",
+ deployMixed: "Despliegue parcialmente completado. Algunas declaraciones pueden haberse aplicado ya ({executedCount}/{statementCount}). El DDL puede no ser transaccional.",
+ deployRolledBack: "Todos los cambios han sido revertidos.",
+ rollbackIncompleteBlocked: "Rollback incomplete (missing objects). Execution blocked.",
+ rollbackIncompleteBanner: "Rollback incomplete — missing trigger/table DDL.",
+ deployMixedTitle: "Parcialmente Desplegado",
+ deployRolledBackTitle: "Revertido",
+ deployMixedWarning: "Algunas declaraciones pueden haberse aplicado mientras que otras fallaron. La base de datos puede estar en un estado inconsistente. Revise el registro de transacciones y tome medidas correctivas manuales antes de reintentar.",
+ },
+ rollbackComparison: {
+ title: "Comparación de Reversión",
+ forwardSql: "SQL Directo",
+ rollbackSql: "SQL de Reversión",
},
schemaDiff: {
optionsTitle: "Opciones de comparación",
@@ -3833,6 +3877,17 @@ export default withEnglishFallback({
cascadeDelete: "Use CASCADE delete",
sequenceLastValues: "Compare sequence last values",
compareColumnOrder: "Compare column order",
+ detectRenames: "Detectar renombrados",
+ detectTableRenames: "Detectar renombrados de tabla",
+ enableRollback: "Habilitar reversión",
+ advancedSection: "Avanzado",
+ renameThreshold: "Umbral de renombrado",
+ compatibilityThreshold: "Umbral de compatibilidad",
+ sourceDialect: "Dialecto de origen",
+ targetDialect: "Dialecto de destino",
+ batchSection: "Lote",
+ batchPatterns: "Patrones de tabla (separados por comas)",
+ dialectAuto: "automático",
},
},
dataCompare: {
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 4d46d441e..b7e34dd5c 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -3783,6 +3783,50 @@ export default withEnglishFallback({
searchConnection: "Cerca connessioni...",
searchDatabase: "Cerca database...",
searchSchema: "Cerca schemi...",
+ defaultConfigName: "Predefinito",
+ dependsOn: "dipende da",
+ dependedBy: "dipendente da",
+ openFieldMapping: "Mappatura Campi",
+ fieldMapping: {
+ title: "Mappatura Tipi di Campo",
+ sameTypeHint: "Origine e destinazione sono lo stesso tipo di database. La mappatura dei campi è necessaria solo quando si confrontano tipi di database diversi.",
+ preset: "Preimpostazione Integrata",
+ selectPreset: "Seleziona una preimpostazione...",
+ noMappings: "Nessuna mappatura di campi definita",
+ sourceType: "Tipo Origine",
+ targetType: "Tipo Destinazione",
+ paramStrategy: "Strategia Parametri",
+ strategyPreserve: "Preserva",
+ strategyStrip: "Rimuovi",
+ strategyCustom: "Personalizzato",
+ customParamsPlaceholder: "es. (100)",
+ addMapping: "Aggiungi Mappatura",
+ autoGenerate: "Genera Automaticamente",
+ import: "Importa",
+ export: "Esporta",
+ done: "Fatto",
+ importTypeMismatch: "Le mappature importate sono state esportate per {src} → {tgt}, ma il confronto corrente è {curSrc} → {curTgt}. Le mappature dei tipi potrebbero non essere accurate. Continuare comunque?",
+ importSuccess: "Mappature dei campi importate con successo",
+ importError: "Importazione mappature campi non riuscita. Verificare il formato JSON.",
+ clearMappings: "Cancella",
+ staleMappingWarning: "Il tipo di database è cambiato da {prevSrc} → {prevTgt} a {curSrc} → {curTgt}. Le mappature dei campi esistenti potrebbero non essere più valide. Cancellarle?",
+ },
+ deployMode: "Distribuisci",
+ forwardSql: "SQL Diretto",
+ rollbackSql: "SQL di Rollback",
+ rollbackMode: "Rollback",
+ deployMixed: "Distribuzione parzialmente completata. Alcune istruzioni potrebbero essere già applicate ({executedCount}/{statementCount}). Il DDL potrebbe non essere transazionale.",
+ deployRolledBack: "Tutte le modifiche sono state annullate.",
+ rollbackIncompleteBlocked: "Rollback incomplete (missing objects). Execution blocked.",
+ rollbackIncompleteBanner: "Rollback incomplete — missing trigger/table DDL.",
+ deployMixedTitle: "Parzialmente Distribuito",
+ deployRolledBackTitle: "Annullato",
+ deployMixedWarning: "Alcune istruzioni potrebbero essere già applicate mentre altre sono fallite. Il database potrebbe trovarsi in uno stato inconsistente. Controllare il log delle transazioni e intervenire manualmente prima di riprovare.",
+ },
+ rollbackComparison: {
+ title: "Confronto Rollback",
+ forwardSql: "SQL Diretto",
+ rollbackSql: "SQL di Rollback",
},
schemaDiff: {
optionsTitle: "Opzioni di Confronto",
@@ -3831,6 +3875,17 @@ export default withEnglishFallback({
cascadeDelete: "Usa CASCADE delete",
sequenceLastValues: "Confronta ultimi valori sequenza",
compareColumnOrder: "Confronta ordine colonne",
+ detectRenames: "Rileva ridenominazioni",
+ detectTableRenames: "Rileva ridenominazioni tabelle",
+ enableRollback: "Abilita rollback",
+ advancedSection: "Avanzate",
+ renameThreshold: "Soglia ridenominazione",
+ compatibilityThreshold: "Soglia compatibilità",
+ sourceDialect: "Dialetto origine",
+ targetDialect: "Dialetto destinazione",
+ batchSection: "Batch",
+ batchPatterns: "Pattern tabella (separati da virgola)",
+ dialectAuto: "auto",
},
},
dataCompare: {
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index dee28013b..4ce56b0fb 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -3784,6 +3784,50 @@ export default withEnglishFallback({
searchConnection: "接続を検索...",
searchDatabase: "データベースを検索...",
searchSchema: "スキーマを検索...",
+ defaultConfigName: "デフォルト",
+ dependsOn: "に依存",
+ dependedBy: "から依存される",
+ openFieldMapping: "フィールドマッピング",
+ fieldMapping: {
+ title: "フィールド型マッピング",
+ sameTypeHint: "ソースとターゲットが同じデータベースタイプです。フィールドマッピングは異なるデータベースタイプ間での比較時のみ必要です。",
+ preset: "組み込みプリセット",
+ selectPreset: "プリセットを選択...",
+ noMappings: "フィールドマッピングが定義されていません",
+ sourceType: "ソース型",
+ targetType: "ターゲット型",
+ paramStrategy: "パラメータ戦略",
+ strategyPreserve: "保持",
+ strategyStrip: "除去",
+ strategyCustom: "カスタム",
+ customParamsPlaceholder: "例: (100)",
+ addMapping: "マッピングを追加",
+ autoGenerate: "自動生成",
+ import: "インポート",
+ export: "エクスポート",
+ done: "完了",
+ importTypeMismatch: "インポートされたマッピングは {src} → {tgt} 用にエクスポートされましたが、現在の比較は {curSrc} → {curTgt} です。型マッピングが正確でない可能性があります。続行しますか?",
+ importSuccess: "フィールドマッピングが正常にインポートされました",
+ importError: "フィールドマッピングのインポートに失敗しました。JSON形式を確認してください。",
+ clearMappings: "クリア",
+ staleMappingWarning: "データベースタイプが {prevSrc} → {prevTgt} から {curSrc} → {curTgt} に変更されました。既存のフィールドマッピングは有効でない可能性があります。クリアしますか?",
+ },
+ deployMode: "デプロイ",
+ forwardSql: "フォワードSQL",
+ rollbackSql: "ロールバックSQL",
+ rollbackMode: "ロールバック",
+ deployMixed: "デプロイが部分的に完了しました。一部のステートメントは既に適用済みの可能性があります({executedCount}/{statementCount})。DDL はトランザクション対象外の場合があります。",
+ deployRolledBack: "すべての変更がロールバックされました。",
+ rollbackIncompleteBlocked: "Rollback incomplete (missing objects). Execution blocked.",
+ rollbackIncompleteBanner: "Rollback incomplete — missing trigger/table DDL.",
+ deployMixedTitle: "部分的にデプロイ",
+ deployRolledBackTitle: "ロールバック済み",
+ deployMixedWarning: "一部のステートメントは既に適用済みで、他のステートメントは失敗した可能性があります。データベースが不整合な状態にある可能性があります。再試行前にトランザクションログを確認し、手動で修正してください。",
+ },
+ rollbackComparison: {
+ title: "ロールバック比較",
+ forwardSql: "フォワードSQL",
+ rollbackSql: "ロールバックSQL",
},
schemaDiff: {
optionsTitle: "比較オプション",
@@ -3832,6 +3876,17 @@ export default withEnglishFallback({
cascadeDelete: "CASCADE削除を使用",
sequenceLastValues: "シーケンス最終値を比較",
compareColumnOrder: "列順序を比較",
+ detectRenames: "名前変更を検出",
+ detectTableRenames: "テーブル名変更を検出",
+ enableRollback: "ロールバックを有効化",
+ advancedSection: "詳細設定",
+ renameThreshold: "名前変更しきい値",
+ compatibilityThreshold: "互換性しきい値",
+ sourceDialect: "ソース方言",
+ targetDialect: "ターゲット方言",
+ batchSection: "バッチ",
+ batchPatterns: "テーブルパターン(カンマ区切り)",
+ dialectAuto: "自動",
},
},
dataCompare: {
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 1a2411e9a..7874025c3 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -3785,6 +3785,50 @@ export default withEnglishFallback({
searchConnection: "Pesquisar conexões...",
searchDatabase: "Pesquisar bancos de dados...",
searchSchema: "Pesquisar schemas...",
+ defaultConfigName: "Padrão",
+ dependsOn: "depende de",
+ dependedBy: "dependido por",
+ openFieldMapping: "Mapeamento de Campos",
+ fieldMapping: {
+ title: "Mapeamento de Tipos de Campo",
+ sameTypeHint: "Origem e destino são do mesmo tipo de banco de dados. O mapeamento de campos só é necessário ao comparar entre tipos de banco de dados diferentes.",
+ preset: "Predefinição Integrada",
+ selectPreset: "Selecionar uma predefinição...",
+ noMappings: "Nenhum mapeamento de campos definido",
+ sourceType: "Tipo de Origem",
+ targetType: "Tipo de Destino",
+ paramStrategy: "Estratégia de Parâmetros",
+ strategyPreserve: "Preservar",
+ strategyStrip: "Remover",
+ strategyCustom: "Personalizado",
+ customParamsPlaceholder: "ex.: (100)",
+ addMapping: "Adicionar Mapeamento",
+ autoGenerate: "Auto-Gerar",
+ import: "Importar",
+ export: "Exportar",
+ done: "Concluído",
+ importTypeMismatch: "Os mapeamentos importados foram exportados para {src} → {tgt}, mas a comparação atual é {curSrc} → {curTgt}. Os mapeamentos de tipo podem não ser precisos. Continuar mesmo assim?",
+ importSuccess: "Mapeamentos de campos importados com sucesso",
+ importError: "Falha ao importar mapeamentos de campos. Verifique o formato JSON.",
+ clearMappings: "Limpar",
+ staleMappingWarning: "O tipo de banco de dados mudou de {prevSrc} → {prevTgt} para {curSrc} → {curTgt}. Os mapeamentos de campos existentes podem não ser mais válidos. Limpá-los?",
+ },
+ deployMode: "Implantar",
+ forwardSql: "SQL Direto",
+ rollbackSql: "SQL de Reversão",
+ rollbackMode: "Reversão",
+ deployMixed: "Implantação parcialmente concluída. Algumas instruções podem já ter sido aplicadas ({executedCount}/{statementCount}). DDL pode não ser transacional.",
+ deployRolledBack: "Todas as alterações foram revertidas.",
+ rollbackIncompleteBlocked: "Rollback incomplete (missing objects). Execution blocked.",
+ rollbackIncompleteBanner: "Rollback incomplete — missing trigger/table DDL.",
+ deployMixedTitle: "Parcialmente Implantado",
+ deployRolledBackTitle: "Revertido",
+ deployMixedWarning: "Algumas instruções podem já ter sido aplicadas enquanto outras falharam. O banco de dados pode estar em um estado inconsistente. Revise o log de transações e tome ação corretiva manual antes de tentar novamente.",
+ },
+ rollbackComparison: {
+ title: "Comparação de Reversão",
+ forwardSql: "SQL Direto",
+ rollbackSql: "SQL de Reversão",
},
schemaDiff: {
optionsTitle: "Opções de Comparação",
@@ -3833,6 +3877,17 @@ export default withEnglishFallback({
cascadeDelete: "Usar CASCADE delete",
sequenceLastValues: "Comparar últimos valores de sequência",
compareColumnOrder: "Comparar ordem das colunas",
+ detectRenames: "Detectar renomeações",
+ detectTableRenames: "Detectar renomeações de tabela",
+ enableRollback: "Habilitar rollback",
+ advancedSection: "Avançado",
+ renameThreshold: "Limiar de renomeação",
+ compatibilityThreshold: "Limiar de compatibilidade",
+ sourceDialect: "Dialeto de origem",
+ targetDialect: "Dialeto de destino",
+ batchSection: "Lote",
+ batchPatterns: "Padrões de tabela (separados por vírgula)",
+ dialectAuto: "automático",
},
},
dataCompare: {
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 4c097b595..2be5a72fc 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -4001,9 +4001,53 @@ export default withEnglishFallback({
configDeleted: "配置已删除",
noDeployScriptAll: "暂无所选对象的部署脚本",
saveConfigPrompt: "请输入配置名称:",
+ defaultConfigName: "默认",
+ dependsOn: "依赖于",
+ dependedBy: "被依赖",
searchConnection: "搜索连接...",
searchDatabase: "搜索数据库...",
searchSchema: "搜索模式...",
+ openFieldMapping: "字段映射",
+ fieldMapping: {
+ title: "字段类型映射",
+ sameTypeHint: "源和目标数据库类型相同,仅在不同类型数据库之间比较时才需要字段映射。",
+ preset: "内置预设",
+ selectPreset: "选择预设...",
+ noMappings: "暂无字段映射",
+ sourceType: "源类型",
+ targetType: "目标类型",
+ paramStrategy: "参数策略",
+ strategyPreserve: "保留",
+ strategyStrip: "去除",
+ strategyCustom: "自定义",
+ customParamsPlaceholder: "如 (100)",
+ addMapping: "添加映射",
+ autoGenerate: "自动生成",
+ import: "导入",
+ export: "导出",
+ done: "完成",
+ importTypeMismatch: "导入的映射文件是 {src} → {tgt} 的,但当前比较是 {curSrc} → {curTgt}。类型映射可能不准确。是否继续?",
+ importSuccess: "字段映射导入成功",
+ importError: "字段映射导入失败,请检查 JSON 格式。",
+ clearMappings: "清空",
+ staleMappingWarning: "数据库类型已从 {prevSrc} → {prevTgt} 变更为 {curSrc} → {curTgt},现有字段映射可能不再有效。是否清除?",
+ },
+ deployMode: "部署",
+ forwardSql: "正向 SQL",
+ rollbackSql: "回滚 SQL",
+ rollbackMode: "回滚",
+ deployMixed: "部署部分完成,部分语句可能已生效({executedCount}/{statementCount})。DDL 可能不支持事务。",
+ deployRolledBack: "所有变更已回滚。",
+ rollbackIncompleteBlocked: "回滚 SQL 不完整(存在缺失对象),已禁止执行。",
+ rollbackIncompleteBanner: "回滚不完整 — 缺少触发器/表 DDL。请先补全缺失对象再执行。",
+ deployMixedTitle: "部分已部署",
+ deployRolledBackTitle: "已回滚",
+ deployMixedWarning: "部分语句可能已生效,其余语句失败。数据库可能处于不一致状态,请在重试前检查事务日志并手动处理。",
+ },
+ rollbackComparison: {
+ title: "回滚对比",
+ forwardSql: "正向 SQL",
+ rollbackSql: "回滚 SQL",
},
schemaDiff: {
optionsTitle: "比较选项",
@@ -4052,6 +4096,17 @@ export default withEnglishFallback({
cascadeDelete: "使用级联删除",
sequenceLastValues: "比较序列最后值",
compareColumnOrder: "比较字段顺序",
+ detectRenames: "检测重命名",
+ detectTableRenames: "检测表重命名",
+ enableRollback: "启用回滚",
+ advancedSection: "高级选项",
+ renameThreshold: "重命名阈值",
+ compatibilityThreshold: "兼容性阈值",
+ sourceDialect: "源数据库方言",
+ targetDialect: "目标数据库方言",
+ batchSection: "批次模式",
+ batchPatterns: "表名模式(逗号分隔)",
+ dialectAuto: "自动",
},
},
dataCompare: {
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index 934078c17..bcecba777 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -3456,6 +3456,50 @@ export default withEnglishFallback({
searchConnection: "搜尋連線...",
searchDatabase: "搜尋資料庫...",
searchSchema: "搜尋結構描述...",
+ defaultConfigName: "預設",
+ dependsOn: "依賴於",
+ dependedBy: "被依賴",
+ openFieldMapping: "欄位映射",
+ fieldMapping: {
+ title: "欄位類型映射",
+ sameTypeHint: "來源與目標資料庫類型相同,僅在不同類型資料庫之間比較時才需要欄位映射。",
+ preset: "內建預設",
+ selectPreset: "選擇預設...",
+ noMappings: "暫無欄位映射",
+ sourceType: "來源類型",
+ targetType: "目標類型",
+ paramStrategy: "參數策略",
+ strategyPreserve: "保留",
+ strategyStrip: "去除",
+ strategyCustom: "自訂",
+ customParamsPlaceholder: "如 (100)",
+ addMapping: "新增映射",
+ autoGenerate: "自動生成",
+ import: "匯入",
+ export: "匯出",
+ done: "完成",
+ importTypeMismatch: "匯入的映射檔案是 {src} → {tgt} 的,但目前比較是 {curSrc} → {curTgt}。類型映射可能不準確。是否繼續?",
+ importSuccess: "欄位映射匯入成功",
+ importError: "欄位映射匯入失敗,請檢查 JSON 格式。",
+ clearMappings: "清空",
+ staleMappingWarning: "資料庫類型已從 {prevSrc} → {prevTgt} 變更為 {curSrc} → {curTgt},現有欄位映射可能不再有效。是否清除?",
+ },
+ deployMode: "部署",
+ forwardSql: "正向 SQL",
+ rollbackSql: "回溯 SQL",
+ rollbackMode: "回溯",
+ deployMixed: "部署部分完成,部分語句可能已生效({executedCount}/{statementCount})。DDL 可能不支援交易。",
+ deployRolledBack: "所有變更已回溯。",
+ rollbackIncompleteBlocked: "Rollback SQL is incomplete (missing objects). Execution is blocked.",
+ rollbackIncompleteBanner: "Rollback is incomplete — missing trigger/table DDL. Fix missing objects before executing.",
+ deployMixedTitle: "部分已部署",
+ deployRolledBackTitle: "已回溯",
+ deployMixedWarning: "部分語句可能已生效,其餘語句失敗。資料庫可能處於不一致狀態,請在重試前檢查交易日誌並手動處理。",
+ },
+ rollbackComparison: {
+ title: "回溯對比",
+ forwardSql: "正向 SQL",
+ rollbackSql: "回溯 SQL",
},
schemaDiff: {
optionsTitle: "比較選項",
@@ -3504,6 +3548,17 @@ export default withEnglishFallback({
cascadeDelete: "使用級聯刪除",
sequenceLastValues: "比較序列最後值",
compareColumnOrder: "比較欄位順序",
+ detectRenames: "檢測重新命名",
+ detectTableRenames: "檢測表重新命名",
+ enableRollback: "啟用回溯",
+ advancedSection: "進階選項",
+ renameThreshold: "重新命名閾值",
+ compatibilityThreshold: "相容性閾值",
+ sourceDialect: "來源資料庫方言",
+ targetDialect: "目標資料庫方言",
+ batchSection: "批次模式",
+ batchPatterns: "資料表模式(逗號分隔)",
+ dialectAuto: "自動",
},
},
dataCompare: {
diff --git a/apps/desktop/src/lib/backend/__tests__/listDialectDataTypes.spec.ts b/apps/desktop/src/lib/backend/__tests__/listDialectDataTypes.spec.ts
new file mode 100644
index 000000000..bca2ee17a
--- /dev/null
+++ b/apps/desktop/src/lib/backend/__tests__/listDialectDataTypes.spec.ts
@@ -0,0 +1,40 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ invoke: vi.fn(),
+}));
+
+vi.mock("@tauri-apps/api/core", () => ({
+ invoke: mocks.invoke,
+}));
+
+vi.mock("@tauri-apps/api/event", () => ({
+ listen: vi.fn(),
+}));
+
+describe("listDialectDataTypes backend adapters", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it("uses the Tauri dialect command", async () => {
+ mocks.invoke.mockResolvedValue(["INTEGER", "TEXT"]);
+ const { listDialectDataTypes } = await import("@/lib/backend/tauri");
+
+ await expect(listDialectDataTypes("PostgreSQL")).resolves.toEqual(["INTEGER", "TEXT"]);
+ expect(mocks.invoke).toHaveBeenCalledWith("list_dialect_data_types", { dialectName: "PostgreSQL" });
+ });
+
+ it("uses the matching Web dialect route", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: vi.fn().mockResolvedValue(["INTEGER", "TEXT"]),
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ const { listDialectDataTypes } = await import("@/lib/backend/http");
+
+ await expect(listDialectDataTypes("PostgreSQL")).resolves.toEqual(["INTEGER", "TEXT"]);
+ expect(fetchMock).toHaveBeenCalledWith("/api/dialect/data-types?dialect_name=PostgreSQL");
+ });
+});
diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts
index e088ee18a..5342f16df 100644
--- a/apps/desktop/src/lib/backend/api.ts
+++ b/apps/desktop/src/lib/backend/api.ts
@@ -175,6 +175,7 @@ export const listExtensions = forward("listExtensions");
export const listAvailableExtensions = forward("listAvailableExtensions");
export const prepareSchemaDiff = forward("prepareSchemaDiff");
export const generateSchemaSyncSql = forward("generateSchemaSyncSql");
+export const listDialectDataTypes = forward("listDialectDataTypes");
// Query
export const executeQuery = forward("executeQuery");
@@ -182,6 +183,7 @@ export const executeMulti = forward("executeMulti");
export const executeMultiWithProgress = forward("executeMultiWithProgress");
export const executeBatch = forward("executeBatch");
export const executeScript = forward("executeScript");
+export const executeScriptWith2pc = forward("executeScriptWith2pc");
export const executeInTransaction = forward("executeInTransaction");
export const beginManualTransaction = forward("beginManualTransaction");
export const executeInManualTransaction = forward("executeInManualTransaction");
diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts
index 9cdb4322a..eb880bb0f 100644
--- a/apps/desktop/src/lib/backend/http.ts
+++ b/apps/desktop/src/lib/backend/http.ts
@@ -776,6 +776,10 @@ export async function listAvailableExtensions(connectionId: string, database: st
return get(`/api/schema/available-extensions?${qs({ connection_id: connectionId, database })}`);
}
+export async function listDialectDataTypes(dialectName: string): Promise
{
+ return get(`/api/dialect/data-types?${qs({ dialect_name: dialectName })}`);
+}
+
// ---------------------------------------------------------------------------
// Query
// ---------------------------------------------------------------------------
@@ -867,6 +871,10 @@ export async function executeScript(connectionId: string, database: string, sql:
return post("/api/query/execute-script", { connectionId, database, sql, schema });
}
+export async function executeScriptWith2pc(connectionId: string, database: string, statements: string[], schema?: string): Promise {
+ return post("/api/query/execute-script-2pc", { connectionId, database, statements, schema });
+}
+
export async function executeInTransaction(connectionId: string, database: string, statements: string[], schema?: string): Promise {
return post("/api/query/execute-in-transaction", { connectionId, database, statements, schema });
}
diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts
index 945bdbab3..4da46fa26 100644
--- a/apps/desktop/src/lib/backend/tauri.ts
+++ b/apps/desktop/src/lib/backend/tauri.ts
@@ -44,6 +44,7 @@ import type {
SavedSqlLibrary,
SshConfigHostEntry,
TunnelProfile,
+ TransactionLog,
} from "@/types/database";
import { isTauriCommandUnavailable, normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
import type { CollectionInfo } from "@/types/database";
@@ -999,6 +1000,10 @@ export async function executeScript(connectionId: string, database: string, sql:
return invoke("execute_script", { connectionId, database, sql, schema });
}
+export async function executeScriptWith2pc(connectionId: string, database: string, statements: string[], schema?: string): Promise {
+ return invoke("execute_script_with_2pc", { connectionId, database, statements, schema });
+}
+
export async function executeInTransaction(connectionId: string, database: string, statements: string[], schema?: string): Promise {
return invoke("execute_in_transaction", { connectionId, database, statements, schema });
}
@@ -1284,6 +1289,10 @@ export async function prepareSchemaDiff(options: SchemaDiffPreparationOptions):
return invoke("prepare_schema_diff", { options });
}
+export async function listDialectDataTypes(dialectName: string): Promise {
+ return invoke("list_dialect_data_types", { dialectName });
+}
+
export async function generateSchemaSyncSql(diffs: TableDiff[], databaseType: DatabaseType, targetSchema?: string, functionDiffs?: FunctionDiff[], sequenceDiffs?: SequenceDiff[], ruleDiffs?: RuleDiff[], ownerDiffs?: OwnerDiff[], cascadeDelete?: boolean): Promise {
return invoke("generate_schema_sync_sql", {
diffs,
diff --git a/apps/desktop/src/lib/dataGrid/dataCompare.ts b/apps/desktop/src/lib/dataGrid/dataCompare.ts
index 4cfdb6ac1..24d66b940 100644
--- a/apps/desktop/src/lib/dataGrid/dataCompare.ts
+++ b/apps/desktop/src/lib/dataGrid/dataCompare.ts
@@ -2,6 +2,15 @@ import type { ColumnInfo, DatabaseType, QueryResult } from "@/types/database";
export type DataCompareCellValue = QueryResult["rows"][number][number];
+export type SamplingStrategy = "Random" | "ExtremeValues" | "Hybrid";
+
+export interface DegradationThreshold {
+ fullCompareMaxRows: number;
+ sampleMaxRows: number;
+ sampleSize: number;
+ extremeSampleCount: number;
+}
+
export interface DataCompareChangedCell {
column: string;
source: DataCompareCellValue;
@@ -57,6 +66,9 @@ export interface DataCompareFromTablesOptions {
columns: string[];
keyColumns: string[];
fetchBatchSize?: number;
+ degradationThreshold?: DegradationThreshold;
+ samplingStrategy?: SamplingStrategy;
+ enableChecksum?: boolean;
}
export interface DataCompareMissingTargetOptions {
@@ -70,6 +82,9 @@ export interface DataCompareMissingTargetOptions {
targetTable: string;
keyColumns: string[];
fetchBatchSize?: number;
+ degradationThreshold?: DegradationThreshold;
+ samplingStrategy?: SamplingStrategy;
+ enableChecksum?: boolean;
}
export interface DataCompareFromTablesPreparation extends DataComparePreparation {
diff --git a/apps/desktop/src/lib/fieldMappingPresets.ts b/apps/desktop/src/lib/fieldMappingPresets.ts
new file mode 100644
index 000000000..9199c2690
--- /dev/null
+++ b/apps/desktop/src/lib/fieldMappingPresets.ts
@@ -0,0 +1,112 @@
+import type { FieldMappingEntry } from "@/types/schemaDiff";
+
+export interface FieldMappingPreset {
+ id: string;
+ label: string;
+ sourceDialect: string;
+ targetDialect: string;
+ mappings: FieldMappingEntry[];
+}
+
+export const FIELD_MAPPING_PRESETS: FieldMappingPreset[] = [
+ {
+ id: "mysql-to-dameng",
+ label: "MySQL → 达梦 (DM)",
+ sourceDialect: "mysql",
+ targetDialect: "dameng",
+ mappings: [
+ { sourceType: "VARCHAR", targetType: "VARCHAR", paramStrategy: "preserve" },
+ { sourceType: "CHAR", targetType: "CHAR", paramStrategy: "preserve" },
+ { sourceType: "TEXT", targetType: "TEXT", paramStrategy: "strip" },
+ { sourceType: "TINYTEXT", targetType: "TEXT", paramStrategy: "strip" },
+ { sourceType: "MEDIUMTEXT", targetType: "TEXT", paramStrategy: "strip" },
+ { sourceType: "LONGTEXT", targetType: "TEXT", paramStrategy: "strip" },
+ { sourceType: "INT", targetType: "INT", paramStrategy: "preserve" },
+ { sourceType: "BIGINT", targetType: "BIGINT", paramStrategy: "preserve" },
+ { sourceType: "DECIMAL", targetType: "NUMERIC", paramStrategy: "preserve" },
+ { sourceType: "FLOAT", targetType: "FLOAT", paramStrategy: "preserve" },
+ { sourceType: "DOUBLE", targetType: "DOUBLE", paramStrategy: "preserve" },
+ { sourceType: "DATE", targetType: "DATE", paramStrategy: "preserve" },
+ { sourceType: "DATETIME", targetType: "TIMESTAMP", paramStrategy: "preserve" },
+ { sourceType: "TIMESTAMP", targetType: "TIMESTAMP", paramStrategy: "preserve" },
+ { sourceType: "BLOB", targetType: "BLOB", paramStrategy: "strip" },
+ { sourceType: "JSON", targetType: "TEXT", paramStrategy: "strip" },
+ { sourceType: "TINYINT", targetType: "TINYINT", paramStrategy: "preserve" },
+ { sourceType: "SMALLINT", targetType: "SMALLINT", paramStrategy: "preserve" },
+ { sourceType: "BOOLEAN", targetType: "BOOLEAN", paramStrategy: "preserve" },
+ ],
+ },
+ {
+ id: "mysql-to-postgresql",
+ label: "MySQL → PostgreSQL",
+ sourceDialect: "mysql",
+ targetDialect: "postgresql",
+ mappings: [
+ { sourceType: "VARCHAR", targetType: "VARCHAR", paramStrategy: "preserve" },
+ { sourceType: "CHAR", targetType: "CHAR", paramStrategy: "preserve" },
+ { sourceType: "TEXT", targetType: "TEXT", paramStrategy: "strip" },
+ { sourceType: "TINYTEXT", targetType: "TEXT", paramStrategy: "strip" },
+ { sourceType: "MEDIUMTEXT", targetType: "TEXT", paramStrategy: "strip" },
+ { sourceType: "LONGTEXT", targetType: "TEXT", paramStrategy: "strip" },
+ { sourceType: "INT", targetType: "INTEGER", paramStrategy: "preserve" },
+ { sourceType: "BIGINT", targetType: "BIGINT", paramStrategy: "preserve" },
+ { sourceType: "DECIMAL", targetType: "NUMERIC", paramStrategy: "preserve" },
+ { sourceType: "FLOAT", targetType: "REAL", paramStrategy: "preserve" },
+ { sourceType: "DOUBLE", targetType: "DOUBLE PRECISION", paramStrategy: "preserve" },
+ { sourceType: "DATETIME", targetType: "TIMESTAMP", paramStrategy: "preserve" },
+ { sourceType: "TIMESTAMP", targetType: "TIMESTAMP", paramStrategy: "preserve" },
+ { sourceType: "BLOB", targetType: "BYTEA", paramStrategy: "strip" },
+ { sourceType: "JSON", targetType: "JSONB", paramStrategy: "preserve" },
+ { sourceType: "TINYINT", targetType: "SMALLINT", paramStrategy: "preserve" },
+ { sourceType: "BOOLEAN", targetType: "BOOLEAN", paramStrategy: "preserve" },
+ ],
+ },
+ {
+ id: "mysql-to-oracle",
+ label: "MySQL → Oracle",
+ sourceDialect: "mysql",
+ targetDialect: "oracle",
+ mappings: [
+ { sourceType: "VARCHAR", targetType: "VARCHAR2", paramStrategy: "preserve" },
+ { sourceType: "CHAR", targetType: "CHAR", paramStrategy: "preserve" },
+ { sourceType: "TEXT", targetType: "CLOB", paramStrategy: "strip" },
+ { sourceType: "TINYTEXT", targetType: "CLOB", paramStrategy: "strip" },
+ { sourceType: "MEDIUMTEXT", targetType: "CLOB", paramStrategy: "strip" },
+ { sourceType: "LONGTEXT", targetType: "CLOB", paramStrategy: "strip" },
+ { sourceType: "INT", targetType: "NUMBER", paramStrategy: "preserve" },
+ { sourceType: "BIGINT", targetType: "NUMBER", paramStrategy: "preserve" },
+ { sourceType: "DECIMAL", targetType: "NUMBER", paramStrategy: "preserve" },
+ { sourceType: "FLOAT", targetType: "BINARY_FLOAT", paramStrategy: "preserve" },
+ { sourceType: "DOUBLE", targetType: "BINARY_DOUBLE", paramStrategy: "preserve" },
+ { sourceType: "DATETIME", targetType: "TIMESTAMP", paramStrategy: "preserve" },
+ { sourceType: "TIMESTAMP", targetType: "TIMESTAMP", paramStrategy: "preserve" },
+ { sourceType: "BLOB", targetType: "BLOB", paramStrategy: "strip" },
+ { sourceType: "JSON", targetType: "CLOB", paramStrategy: "strip" },
+ { sourceType: "BOOLEAN", targetType: "NUMBER(1)", paramStrategy: "custom", customParams: "(1)" },
+ ],
+ },
+];
+
+export function findPreset(sourceDialect: string, targetDialect: string): FieldMappingPreset | undefined {
+ // Look for exact forward match
+ const forward = FIELD_MAPPING_PRESETS.find((p) => p.sourceDialect === sourceDialect && p.targetDialect === targetDialect);
+ if (forward) return forward;
+
+ // Look for reverse match and auto-generate bidirectional preset
+ const reverse = FIELD_MAPPING_PRESETS.find((p) => p.sourceDialect === targetDialect && p.targetDialect === sourceDialect);
+ if (reverse) {
+ return {
+ id: `${reverse.id}-reverse`,
+ label: `${reverse.label.split(" → ").reverse().join(" → ")}`,
+ sourceDialect,
+ targetDialect,
+ mappings: reverse.mappings.map((m) => ({
+ sourceType: m.targetType,
+ targetType: m.sourceType,
+ paramStrategy: m.paramStrategy === "custom" ? "strip" : m.paramStrategy,
+ })),
+ };
+ }
+
+ return undefined;
+}
diff --git a/apps/desktop/src/lib/schema/__tests__/deployTxResult.spec.ts b/apps/desktop/src/lib/schema/__tests__/deployTxResult.spec.ts
new file mode 100644
index 000000000..8c9bd410a
--- /dev/null
+++ b/apps/desktop/src/lib/schema/__tests__/deployTxResult.spec.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from "vitest";
+import { buildDeployTxResult } from "@/lib/schema/deployTxResult";
+
+const t = (key: string, params?: Record) => {
+ const fallback: Record = {
+ "diff.executeSuccess": "Executed successfully",
+ "diff.deployMixed": "Deployment partially completed. Some statements may already be applied ({executedCount}/{statementCount}). DDL may not be transactional.",
+ "diff.deployRolledBack": "All changes have been rolled back.",
+ "diff.deployFailed": "Deployment failed: {status}",
+ };
+ let msg = fallback[key] || key;
+ if (params) {
+ for (const [k, v] of Object.entries(params)) {
+ msg = msg.replace(`{${k}}`, String(v));
+ }
+ }
+ return msg;
+};
+
+describe("buildDeployTxResult", () => {
+ it("returns success for committed transaction", () => {
+ const result = buildDeployTxResult({ status: "committed", transaction_id: "tx1", executedCount: 2 }, t);
+ expect(result.success).toBe(true);
+ expect(result.status).toBe("committed");
+ expect(result.message).toBe("Executed successfully");
+ expect(result.executedCount).toBe(2);
+ });
+
+ it("returns failure with mixed status for partially committed", () => {
+ const result = buildDeployTxResult(
+ {
+ status: "mixed",
+ participants: [{ id: "1" }, { id: "2" }],
+ executedCount: 1,
+ statementCount: 2,
+ },
+ t,
+ );
+ expect(result.success).toBe(false);
+ expect(result.status).toBe("mixed");
+ expect(result.message).toContain("partially completed");
+ expect(result.message).toContain("1/2");
+ expect(result.message).toContain("may not be transactional");
+ expect(result.executedCount).toBe(1);
+ expect(result.statementCount).toBe(2);
+ });
+
+ it("returns failure with rolled_back status and error detail", () => {
+ const result = buildDeployTxResult({ status: "rolled_back", error: "syntax error near SELECT", executedCount: 0, statementCount: 2 }, t);
+ expect(result.success).toBe(false);
+ expect(result.status).toBe("rolled_back");
+ expect(result.message).toContain("rolled back");
+ expect(result.message).toContain("syntax error");
+ expect(result.executedCount).toBe(0);
+ expect(result.statementCount).toBe(2);
+ });
+
+ it("returns failure for unknown status", () => {
+ const result = buildDeployTxResult({ status: "unknown" }, t);
+ expect(result.success).toBe(false);
+ expect(result.status).toBe("unknown");
+ expect(result.message).toContain("unknown");
+ });
+
+ it("returns failure for null/undefined txLog", () => {
+ const result = buildDeployTxResult(null, t);
+ expect(result.success).toBe(false);
+ expect(result.status).toBe("unknown");
+ });
+
+ it("maps MySQL-style partial DDL failure (1 of 2 applied) for UI", () => {
+ const result = buildDeployTxResult(
+ {
+ status: "mixed",
+ executedCount: 1,
+ statementCount: 2,
+ error: "Statement 2 failed: table already exists",
+ metadata: { atomicity: "partial_effects_possible", ddl_atomic: false },
+ },
+ t,
+ );
+ expect(result.success).toBe(false);
+ expect(result.status).toBe("mixed");
+ expect(result.executedCount).toBe(1);
+ expect(result.statementCount).toBe(2);
+ expect(result.message).toContain("1/2");
+ expect(result.message).toMatch(/may already be applied|may not be transactional/i);
+ });
+});
diff --git a/apps/desktop/src/lib/schema/deployTxResult.ts b/apps/desktop/src/lib/schema/deployTxResult.ts
new file mode 100644
index 000000000..b17cbed53
--- /dev/null
+++ b/apps/desktop/src/lib/schema/deployTxResult.ts
@@ -0,0 +1,61 @@
+export interface DeployTxResult {
+ success: boolean;
+ status?: string;
+ message: string;
+ affectedRows?: number;
+ error?: string;
+ executedCount?: number;
+ statementCount?: number;
+}
+
+export function buildDeployTxResult(txLog: any, t: (key: string, params?: Record) => string): DeployTxResult {
+ const status = txLog?.status;
+ const error = txLog?.error ?? txLog?.metadata?.error;
+ const executedCount = txLog?.executedCount ?? txLog?.executed_count;
+ const statementCount = txLog?.statementCount ?? txLog?.statement_count;
+ const affectedRows = txLog?.metadata?.affected_rows ?? txLog?.affectedRows;
+
+ if (status === "committed") {
+ return {
+ success: true,
+ status,
+ message: t("diff.executeSuccess"),
+ affectedRows,
+ executedCount,
+ statementCount,
+ };
+ }
+ if (status === "mixed") {
+ return {
+ success: false,
+ status,
+ message: t("diff.deployMixed", {
+ participants: txLog?.participants?.length ?? 0,
+ executedCount: executedCount ?? 0,
+ statementCount: statementCount ?? 0,
+ }),
+ error,
+ executedCount,
+ statementCount,
+ };
+ }
+ if (status === "rolled_back") {
+ const detail = error ? `: ${error}` : "";
+ return {
+ success: false,
+ status,
+ message: `${t("diff.deployRolledBack")}${detail}`,
+ error,
+ executedCount: executedCount ?? 0,
+ statementCount,
+ };
+ }
+ return {
+ success: false,
+ status: status || "unknown",
+ message: t("diff.deployFailed", { status: status || "unknown" }),
+ error,
+ executedCount,
+ statementCount,
+ };
+}
diff --git a/apps/desktop/src/lib/schema/schemaDiff.ts b/apps/desktop/src/lib/schema/schemaDiff.ts
index 2bd7f7b07..50db44870 100644
--- a/apps/desktop/src/lib/schema/schemaDiff.ts
+++ b/apps/desktop/src/lib/schema/schemaDiff.ts
@@ -1,5 +1,111 @@
import type { ColumnInfo, IndexInfo, ForeignKeyInfo, TriggerInfo, FunctionInfo, SequenceInfo, RuleInfo, OwnerInfo, DatabaseType, TableInfo } from "@/types/database";
+const DIALECT_KIND_MAP: Record = {
+ mysql: "mysql",
+ doris: "mysql",
+ starrocks: "mysql",
+ goldendb: "mysql",
+ sundb: "mysql",
+ databend: "mysql",
+ gbase: "mysql",
+ postgres: "postgres",
+ gaussdb: "postgres",
+ kwdb: "postgres",
+ opengauss: "postgres",
+ highgo: "postgres",
+ vastbase: "postgres",
+ kingbase: "postgres",
+ firebird: "postgres",
+ redshift: "postgres",
+ vertica: "postgres",
+ exasol: "postgres",
+ sqlite: "sqlite",
+ rqlite: "sqlite",
+ turso: "sqlite",
+ duckdb: "duckdb",
+ sqlserver: "sql_server",
+ access: "sql_server",
+ oracle: "oracle",
+ dameng: "oracle",
+ "oceanbase-oracle": "oracle",
+ iris: "oracle",
+ yashandb: "oracle",
+ xugu: "oracle",
+ h2: "h2",
+ clickhouse: "click_house",
+ manticoresearch: "manticore_search",
+ informix: "informix",
+ questdb: "questdb",
+};
+
+export function databaseTypeToDialectKind(dbType: DatabaseType): string {
+ return DIALECT_KIND_MAP[dbType] ?? "unsupported";
+}
+
+const DIALECT_ALIAS_MAP: Record = {
+ access: "sql_server",
+ mssql: "sql_server",
+ "sql server": "sql_server",
+ postgresql: "postgres",
+ sqlite3: "sqlite",
+ "oceanbase-oracle": "oracle",
+ oceanbase: "oracle",
+ dameng: "oracle",
+ iris: "oracle",
+ yashandb: "oracle",
+ xugu: "oracle",
+ gaussdb: "postgres",
+ kwdb: "postgres",
+ opengauss: "postgres",
+ highgo: "postgres",
+ vastbase: "postgres",
+ kingbase: "postgres",
+ firebird: "postgres",
+ redshift: "postgres",
+ vertica: "postgres",
+ exasol: "postgres",
+ doris: "mysql",
+ starrocks: "mysql",
+ goldendb: "mysql",
+ sundb: "mysql",
+ databend: "mysql",
+ gbase: "mysql",
+ rqlite: "sqlite",
+ turso: "sqlite",
+ manticore: "manticore_search",
+ questdb: "questdb",
+ clickhouse: "click_house",
+};
+
+export function normalizeDialectKind(input: string): string {
+ const lower = input.trim().toLowerCase();
+ if (DIALECT_KIND_MAP[lower]) return DIALECT_KIND_MAP[lower];
+ if (DIALECT_ALIAS_MAP[lower]) return DIALECT_ALIAS_MAP[lower];
+ return lower;
+}
+
+function levenshteinDistance(a: string, b: string): number {
+ const m = a.length;
+ const n = b.length;
+ if (m === 0) return n;
+ if (n === 0) return m;
+ let prev = Array.from({ length: n + 1 }, (_, i) => i);
+ for (let i = 1; i <= m; i++) {
+ const curr = [i];
+ for (let j = 1; j <= n; j++) {
+ curr[j] = a[i - 1] === b[j - 1] ? prev[j - 1] : Math.min(prev[j], curr[j - 1], prev[j - 1]) + 1;
+ }
+ prev = curr;
+ }
+ return prev[n];
+}
+
+function nameSimilarity(a: string, b: string): number {
+ const maxLen = Math.max(a.length, b.length);
+ if (maxLen === 0) return 1;
+ return 1 - levenshteinDistance(a, b) / maxLen;
+}
+
export interface ColumnDiff {
type: "added" | "removed" | "modified";
name: string;
@@ -65,7 +171,7 @@ export interface OwnerDiff {
}
export interface TableDiff {
- type: "added" | "removed" | "modified";
+ type: "added" | "removed" | "modified" | "renamed";
objectType?: "table" | "view";
name: string;
columns?: ColumnDiff[];
@@ -88,6 +194,11 @@ export interface TableSchemaDetail {
ddl?: string;
}
+export interface FieldMappingEntry {
+ sourceType: string;
+ targetType: string;
+}
+
export interface SchemaDiffPreparationOptions {
sourceTables: TableInfo[];
targetTables: TableInfo[];
@@ -106,8 +217,58 @@ export interface SchemaDiffPreparationOptions {
ignoreComments?: boolean;
cascadeDelete?: boolean;
compareColumnOrder?: boolean;
+ detectRenames?: boolean;
+ detectTableRenames?: boolean;
+ renameThreshold?: number;
+ enableRollback?: boolean;
+ batchPatterns?: string[];
+ sourceDialect?: string;
+ targetDialect?: string;
+ compatibilityThreshold?: number;
+ fieldMappings?: FieldMappingEntry[];
}
+export interface RenameCandidate {
+ sourceName: string;
+ targetName: string;
+ score: number;
+}
+
+export interface CompatibilityWarning {
+ table: string;
+ column: string;
+ sourceType: string;
+ targetType: string;
+ risk: string;
+ message: string;
+}
+
+export interface PermissionDiff {
+ objectName: string;
+ permissionType: string;
+ sourcePermission: string | null;
+ targetPermission: string | null;
+}
+
+export interface DependencyNode {
+ tableName: string;
+ dependsOn: string[];
+ dependedBy: string[];
+}
+
+export interface DependencyGraph {
+ nodes: DependencyNode[];
+}
+
+export interface MissingRollbackObject {
+ kind: string;
+ name: string;
+ table?: string;
+ reason: string;
+}
+
+export type RollbackCompleteness = "complete" | "incomplete";
+
export interface SchemaDiffPreparation {
diffs: TableDiff[];
functionDiffs?: FunctionDiff[];
@@ -115,6 +276,15 @@ export interface SchemaDiffPreparation {
ruleDiffs?: RuleDiff[];
ownerDiffs?: OwnerDiff[];
syncSql: string;
+ rollbackSyncSql?: string;
+ rollbackCompleteness?: RollbackCompleteness;
+ missingRollbackObjects?: MissingRollbackObject[];
+ renameCandidates?: RenameCandidate[];
+ rollbackGraph?: unknown;
+ compatibilityWarnings?: CompatibilityWarning[];
+ permissionDiffs?: PermissionDiff[];
+ permissionSyncSql?: string;
+ dependencyGraph?: DependencyGraph;
}
const MYSQL_LIKE_SCHEMA_DIFF_TARGET_TYPES = new Set(["mysql", "doris", "starrocks", "goldendb", "sundb", "databend", "gbase"]);
@@ -146,10 +316,17 @@ export interface SchemaDiffObject {
sourceDdl?: string;
targetDdl?: string;
deploySql?: string;
+ rollbackDdl?: string;
changes?: string[];
children?: SchemaDiffObject[];
/** Function arguments signature (for PostgreSQL overloaded functions) */
arguments?: string;
+ renameMetadata?: {
+ confirmed: boolean;
+ sourceName?: string;
+ targetName?: string;
+ score?: number;
+ };
}
export interface SchemaDiffGroup {
@@ -164,6 +341,7 @@ export interface SchemaDiffGroup {
export function getOperationType(diffType: string): DiffOperationType {
switch (diffType) {
case "modified":
+ case "renamed":
return "modify";
case "added":
return "create";
@@ -203,23 +381,27 @@ function buildSequenceDdl(seq: SequenceInfo): string {
return parts.join("\n");
}
-export function convertToSchemaDiffObjects(tableDiffs: TableDiff[], functionDiffs: FunctionDiff[] = [], sequenceDiffs: SequenceDiff[] = [], ruleDiffs: RuleDiff[] = [], ownerDiffs: OwnerDiff[] = []): SchemaDiffObject[] {
+export function convertToSchemaDiffObjects(tableDiffs: TableDiff[], functionDiffs: FunctionDiff[] = [], sequenceDiffs: SequenceDiff[] = [], ruleDiffs: RuleDiff[] = [], ownerDiffs: OwnerDiff[] = [], renameCandidates?: RenameCandidate[]): SchemaDiffObject[] {
const objects: SchemaDiffObject[] = [];
for (const diff of tableDiffs) {
const opType = getOperationType(diff.type);
+ const isRenamed = diff.type === "renamed";
+ const newName = isRenamed && renameCandidates ? (renameCandidates.find((rc) => rc.sourceName === diff.name)?.targetName ?? diff.name) : undefined;
+
const obj: SchemaDiffObject = {
id: `table-${diff.name}`,
operationType: opType,
objectKind: diff.objectType === "view" ? "view" : "table",
name: diff.name,
sourceName: diff.type === "added" ? undefined : diff.name,
- targetName: diff.type === "removed" ? undefined : diff.name,
+ targetName: diff.type === "removed" ? undefined : isRenamed ? newName : diff.name,
selected: opType !== "none",
sourceDdl: diff.ddl,
targetDdl: diff.targetDdl,
- deploySql: diff.syncSql,
+ deploySql: isRenamed && newName ? (diff.objectType === "view" ? `ALTER VIEW ${diff.name} RENAME TO ${newName};` : `RENAME TABLE ${diff.name} TO ${newName};`) : diff.syncSql,
changes: diff.columns?.flatMap((c) => c.changes || []),
+ renameMetadata: isRenamed && newName ? { confirmed: true, sourceName: diff.name, targetName: newName, score: renameCandidates?.find((rc) => rc.sourceName === diff.name)?.score } : undefined,
children: [
...(diff.columns?.map((c) => ({
id: `col-${diff.name}-${c.name}`,
@@ -324,6 +506,24 @@ export function convertToSchemaDiffObjects(tableDiffs: TableDiff[], functionDiff
});
}
+ // Pre-mark rename candidates on diff objects (for UI display before user confirms)
+ if (renameCandidates && renameCandidates.length > 0) {
+ for (const rc of renameCandidates) {
+ for (const obj of objects) {
+ // Backend-detected renames: diff_type = "renamed", already has metadata set above
+ if (obj.renameMetadata) continue;
+ // Legacy: mark rename candidates on delete+create pairs (fallback for older backends)
+ if (obj.operationType === "delete" && obj.name === rc.sourceName) {
+ obj.renameMetadata = { confirmed: false, targetName: rc.targetName, score: rc.score };
+ }
+ if (obj.operationType === "create" && obj.name === rc.targetName) {
+ obj.renameMetadata = { confirmed: false, sourceName: rc.sourceName, score: rc.score };
+ obj.sourceName = rc.sourceName;
+ }
+ }
+ }
+ }
+
return objects;
}
@@ -382,6 +582,133 @@ function generateDropSql(obj: SchemaDiffObject): string {
return `DROP ${sqlType} IF EXISTS ${obj.name};`;
}
+/** Detect column renames in raw SQL and replace DROP+ADD with RENAME COLUMN. */
+export function injectColumnRenameSql(sql: string, diffs: TableDiff[], threshold: number, reverse = false): string {
+ if (!sql || !threshold) return sql;
+
+ // Build rename pairs: for each table, match removed columns with added columns by similarity
+ const replacements: { table: string; oldName: string; newName: string }[] = [];
+ for (const diff of diffs) {
+ if (!diff.columns || diff.type !== "modified") continue;
+ const removedCols = diff.columns.filter((c) => c.type === "removed");
+ const addedCols = diff.columns.filter((c) => c.type === "added");
+ if (removedCols.length === 0 || addedCols.length === 0) continue;
+
+ const used = new Set();
+ for (const rc of removedCols) {
+ let best: (typeof addedCols)[0] | null = null;
+ let bestSim = 0;
+ for (const ac of addedCols) {
+ if (used.has(ac.name)) continue;
+ const sim = nameSimilarity(rc.name, ac.name);
+ if (sim > bestSim) {
+ bestSim = sim;
+ best = ac;
+ }
+ }
+ if (best && bestSim >= threshold) {
+ // rc = removed column (exists in source, NOT in target → needs to be ADDED to target)
+ // best = added column (exists in target, NOT in source → needs to be DROPPED from target)
+ // To sync target → source: rename target's "best.name" to source's "rc.name"
+ replacements.push({ table: diff.name, oldName: best.name, newName: rc.name });
+ used.add(best.name);
+ }
+ }
+ }
+
+ if (replacements.length === 0) return sql;
+
+ // Process each ALTER TABLE block
+ const lines = sql.split("\n");
+ const out: string[] = [];
+ let currentTable = "";
+ let inAlter = false;
+ let alterStart = -1;
+ const tableRenames = new Map();
+
+ for (const r of replacements) {
+ const list = tableRenames.get(r.table) || [];
+ list.push(r);
+ tableRenames.set(r.table, list);
+ }
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const trimmed = line.trim();
+
+ // Detect ALTER TABLE
+ const alterMatch = trimmed.match(/^ALTER\s+TABLE\s+(?:`[^`]+`\.)?`?(\w+)`?/i);
+ if (alterMatch && !inAlter) {
+ currentTable = alterMatch[1];
+ const renames = tableRenames.get(currentTable);
+ if (renames) {
+ inAlter = true;
+ alterStart = i;
+ continue; // skip current ALTER TABLE line, we'll rebuild it
+ }
+ }
+
+ if (!inAlter) {
+ out.push(line);
+ continue;
+ }
+
+ // Inside an ALTER TABLE block being rewritten
+ const endOfAlter = trimmed === ";" || trimmed.endsWith(";") || (i + 1 < lines.length && lines[i + 1].trim().toUpperCase().startsWith("ALTER")) || i === lines.length - 1;
+
+ if (endOfAlter) {
+ const renames = tableRenames.get(currentTable)!;
+ // Emit RENAME COLUMN statements
+ out.push(`-- Alter table: ${currentTable} (column renames detected)`);
+ for (const r of renames) {
+ const fromName = reverse ? r.newName : r.oldName;
+ const toName = reverse ? r.oldName : r.newName;
+ out.push(`ALTER TABLE ${currentTable} RENAME COLUMN ${fromName} TO ${toName};`);
+ }
+ // Collect remaining ALTER clauses (non-renamed columns)
+ const remaining: string[] = [];
+ for (let j = alterStart + 1; j <= i; j++) {
+ const l = lines[j].trim();
+ if (!l || l === ";") continue;
+ // Forward: ADD for newName, DROP for oldName. Reverse: ADD for oldName, DROP for newName.
+ const isAddRename = l.toUpperCase().startsWith("ADD COLUMN") && renames.some((r) => l.includes(reverse ? r.oldName : r.newName));
+ const isDropRename = l.toUpperCase().startsWith("DROP COLUMN") && renames.some((r) => l.includes(reverse ? r.newName : r.oldName));
+ if (isAddRename || isDropRename) continue;
+ // Clean trailing comma if next line is removed
+ let cleaned = l;
+ let nextIdx = j + 1;
+ while (nextIdx <= i) {
+ const nextLine = lines[nextIdx].trim();
+ if (!nextLine) {
+ nextIdx++;
+ continue;
+ }
+ const nextIsAddRename = nextLine.toUpperCase().startsWith("ADD COLUMN") && renames.some((r) => nextLine.includes(reverse ? r.oldName : r.newName));
+ const nextIsDropRename = nextLine.toUpperCase().startsWith("DROP COLUMN") && renames.some((r) => nextLine.includes(reverse ? r.newName : r.oldName));
+ if (nextIsAddRename || nextIsDropRename) {
+ cleaned = cleaned.replace(/,\s*$/, "");
+ }
+ break;
+ }
+ remaining.push(cleaned);
+ }
+ if (remaining.length > 0) {
+ const last = remaining[remaining.length - 1].replace(/,\s*$/, "").replace(/;\s*$/, "");
+ remaining[remaining.length - 1] = last;
+ out.push(`ALTER TABLE ${currentTable}`);
+ for (const r of remaining) {
+ out.push(` ${r}`);
+ }
+ out.push(";");
+ }
+ inAlter = false;
+ currentTable = "";
+ }
+ }
+
+ return out.join("\n").trim();
+}
+
export interface ObjectTypeGroup {
kind: DiffObjectKind;
label: string;
diff --git a/apps/desktop/src/lib/schema/schemaDiffOptions.ts b/apps/desktop/src/lib/schema/schemaDiffOptions.ts
index 45cd1156e..07847bafa 100644
--- a/apps/desktop/src/lib/schema/schemaDiffOptions.ts
+++ b/apps/desktop/src/lib/schema/schemaDiffOptions.ts
@@ -23,6 +23,9 @@ export const POSTGRES_SCHEMA_DIFF_OPTIONS: SchemaDiffOptionItem[] = [
{ id: "cascadeDelete", labelKey: "schemaDiff.options.cascadeDelete", defaultChecked: false },
{ id: "sequenceLastValues", labelKey: "schemaDiff.options.sequenceLastValues", defaultChecked: true },
{ id: "compareColumnOrder", labelKey: "schemaDiff.options.compareColumnOrder", defaultChecked: false },
+ { id: "detectRenames", labelKey: "schemaDiff.options.detectRenames", defaultChecked: false },
+ { id: "detectTableRenames", labelKey: "schemaDiff.options.detectTableRenames", defaultChecked: false },
+ { id: "enableRollback", labelKey: "schemaDiff.options.enableRollback", defaultChecked: false },
];
export const SCHEMA_DIFF_OPTIONS_BY_DB_TYPE: Record = {
diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts
index aca7f33e0..6dc9910d8 100644
--- a/apps/desktop/src/types/database.ts
+++ b/apps/desktop/src/types/database.ts
@@ -640,6 +640,26 @@ export interface QueryResultRun {
tableMeta?: QueryTab["tableMeta"];
}
+export interface ParticipantInfo {
+ id: string;
+ name: string;
+ role: string;
+}
+
+export interface TransactionLog {
+ transaction_id: string;
+ status: string;
+ participants: ParticipantInfo[];
+ created_at: string;
+ updated_at: string;
+ metadata: unknown;
+ /** camelCase fields from SchemaDiffDeployResult */
+ transactionId?: string;
+ executedCount?: number;
+ statementCount?: number;
+ error?: string;
+}
+
export interface SqlTextSpan {
start_line: number;
start_column: number;
diff --git a/apps/desktop/src/types/governance.ts b/apps/desktop/src/types/governance.ts
new file mode 100644
index 000000000..9a6e8e955
--- /dev/null
+++ b/apps/desktop/src/types/governance.ts
@@ -0,0 +1,135 @@
+export interface ConfigAuditEntry {
+ id: string;
+ timestamp: string;
+ operator: string;
+ reason: string;
+ keyPath: string;
+ changeDiff: unknown;
+ configSnapshot: unknown;
+}
+
+export interface ConfigVersionSnapshot {
+ id: string;
+ keyPath: string;
+ version: number;
+ snapshotJson: unknown;
+ checksum: string;
+ createdAt: string;
+}
+
+export interface AuditQuery {
+ keyPath?: string;
+ operator?: string;
+ limit?: number;
+ offset?: number;
+}
+
+export interface AuditSummary {
+ totalEntries: number;
+ entries: ConfigAuditEntry[];
+}
+
+export type ApprovalStatus = "draft" | "pending_approval" | "approved" | "rejected";
+
+export interface ApprovalRecord {
+ id: string;
+ configDomain: string;
+ changeDescription: string;
+ status: ApprovalStatus;
+ requester: string;
+ reviewer?: string;
+ reviewedAt?: string;
+ webhookUrl?: string;
+ draftConfigJson: unknown;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface DriftAlert {
+ id: string;
+ sourceEnv: string;
+ targetEnv: string;
+ configKey: string;
+ expectedChecksum: string;
+ actualChecksum: string;
+ detailsJson: unknown;
+ detectedAt: string;
+ acknowledged: boolean;
+}
+
+export interface DriftReport {
+ sourceEnv: string;
+ targetEnv: string;
+ keyPath: string;
+ sourceChecksum: string;
+ targetChecksum: string;
+ mismatchedFields: string[];
+ sourceChangedAt?: string;
+ detectedAt: string;
+}
+
+export type DdlRiskLevel = "safe" | "caution" | "dangerous" | "blocked";
+
+export type ExecStrategy = "online" | "lazy" | "offline" | "batch";
+
+export interface LockInfo {
+ lockType: string;
+ objects: string[];
+ duration: string;
+}
+
+export interface ImpactReport {
+ overallRisk: DdlRiskLevel;
+ ddlRiskLevel: DdlRiskLevel;
+ estimatedLocks: LockInfo[];
+ estimatedTotalDuration: string;
+ recommendedStrategy: ExecStrategy;
+ warnings: string[];
+ requiresMaintenanceWindow: boolean;
+ isReversible: boolean;
+}
+
+export type OscExecutionStatus = "preparing" | "copying" | "cut_over" | "completed" | "failed" | "postponed";
+
+export interface OscStatus {
+ toolType: "gh-ost" | "pt-osc";
+ tableName: string;
+ status: OscExecutionStatus;
+ progressPercent: number;
+ estimatedRemainingSecs?: number;
+ error?: string;
+}
+
+export type DegradationLevel = "full" | "sample" | "skip_with_risk";
+
+export interface BusinessTag {
+ key: string;
+ value: string;
+ description: string;
+ immutable: boolean;
+}
+
+export interface ConflictItem {
+ objectName: string;
+ conflictType: string;
+ sourceValue: string;
+ targetValue: string;
+ autoResolvable: boolean;
+}
+
+export interface RebasePlan {
+ id: string;
+ baselineId: string;
+ conflicts: ConflictItem[];
+ totalObjects: number;
+ autoResolvedCount: number;
+ createdAt: string;
+}
+
+export interface ConfigDriftSummary {
+ sourceEnv: string;
+ targetEnv: string;
+ driftCount: number;
+ lastDetectedAt: string;
+ hasUnacknowledged: boolean;
+}
diff --git a/apps/desktop/src/types/schemaDiff.ts b/apps/desktop/src/types/schemaDiff.ts
index 4a3444ab9..4de47387d 100644
--- a/apps/desktop/src/types/schemaDiff.ts
+++ b/apps/desktop/src/types/schemaDiff.ts
@@ -20,6 +20,24 @@ export interface SchemaDiffCompareOptions {
tableIncludePattern: string;
tableExcludePattern: string;
tableFilterPriority: SchemaDiffTableFilterPriority;
+ detectRenames: boolean;
+ renameThreshold: number;
+ detectTableRenames: boolean;
+ enableRollback: boolean;
+ batchPatterns: string;
+ sourceDialect: string;
+ targetDialect: string;
+ compatibilityThreshold: number;
+ fieldMappings: FieldMappingEntry[];
+}
+
+export type FieldMappingParamStrategy = "preserve" | "strip" | "custom";
+
+export interface FieldMappingEntry {
+ sourceType: string;
+ targetType: string;
+ paramStrategy: FieldMappingParamStrategy;
+ customParams?: string;
}
export interface SchemaDiffConfig {
@@ -69,6 +87,15 @@ export const DEFAULT_POSTGRES_OPTIONS: SchemaDiffCompareOptions = {
tableIncludePattern: "",
tableExcludePattern: "",
tableFilterPriority: "exclude",
+ detectRenames: false,
+ renameThreshold: 0.5,
+ detectTableRenames: false,
+ enableRollback: false,
+ batchPatterns: "",
+ sourceDialect: "",
+ targetDialect: "",
+ compatibilityThreshold: 0.5,
+ fieldMappings: [],
};
export const DEFAULT_MYSQL_OPTIONS: SchemaDiffCompareOptions = {
@@ -91,6 +118,15 @@ export const DEFAULT_MYSQL_OPTIONS: SchemaDiffCompareOptions = {
tableIncludePattern: "",
tableExcludePattern: "",
tableFilterPriority: "exclude",
+ detectRenames: false,
+ renameThreshold: 0.5,
+ detectTableRenames: false,
+ enableRollback: false,
+ batchPatterns: "",
+ sourceDialect: "",
+ targetDialect: "",
+ compatibilityThreshold: 0.5,
+ fieldMappings: [],
};
export function getDefaultOptionsForDbType(dbType: string): SchemaDiffCompareOptions {
diff --git a/crates/dbx-core/Cargo.toml b/crates/dbx-core/Cargo.toml
index 2f2320d46..cbd616e6a 100644
--- a/crates/dbx-core/Cargo.toml
+++ b/crates/dbx-core/Cargo.toml
@@ -98,4 +98,9 @@ zip = { version = "4", default-features = false, features = ["deflate"] }
flate2 = "1"
tar = "0.4"
sysinfo = { version = "0.32", features = ["system"] }
+serde_yaml = "0.9"
+minijinja = "2"
+notify = { version = "7", default-features = false, features = ["macos_kqueue"] }
+arc-swap = "1"
+insta = { version = "1", features = ["json", "glob"] }
tempfile = "3"
diff --git a/crates/dbx-core/build.rs b/crates/dbx-core/build.rs
new file mode 100644
index 000000000..3f8e5da0a
--- /dev/null
+++ b/crates/dbx-core/build.rs
@@ -0,0 +1,51 @@
+use std::env;
+use std::path::Path;
+
+fn main() {
+ let cargo_manifest_dir_str = env::var("CARGO_MANIFEST_DIR").unwrap();
+ let cargo_manifest_dir = Path::new(&cargo_manifest_dir_str);
+ let dialects_dir = cargo_manifest_dir.join("..").join("..").join("plugins").join("dialects");
+ let dialects_dir = std::fs::canonicalize(&dialects_dir).unwrap_or(dialects_dir);
+
+ let out_dir_str = env::var("OUT_DIR").unwrap();
+ let dest_path = Path::new(&out_dir_str).join("core_dialects.rs");
+
+ let mut entries: Vec<_> = std::fs::read_dir(&dialects_dir)
+ .expect("Cannot read plugins/dialects directory")
+ .filter_map(|e| e.ok())
+ .filter(|e| e.path().extension().is_some_and(|ext| ext == "yaml" || ext == "yml"))
+ .collect();
+ entries.sort_by_key(|e| e.file_name());
+
+ // Watch the directory itself so additions/removals of dialect files trigger a rebuild.
+ println!("cargo::rerun-if-changed={}", dialects_dir.to_str().unwrap());
+
+ let mut code = String::from("{\n");
+
+ for entry in &entries {
+ let path = entry.path();
+ let canonical = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
+ let file_name = path.file_stem().unwrap().to_str().unwrap();
+ let path_str = canonical.to_str().unwrap();
+
+ // Watch each dialect file individually. Editing a single YAML must invalidate
+ // the embedded `core_dialects.rs`, otherwise the compiled binary keeps a stale
+ // type catalog (e.g. old type names) and silently misbehaves (see field mapping).
+ println!("cargo::rerun-if-changed={}", path_str);
+
+ code.push_str("match crate::sql_dialect::dialect_loader::DialectPluginLoader::load_from_string(\n");
+ code.push_str(&format!(" include_str!(\"{}\"),\n", path_str.replace('\\', "\\\\")));
+ code.push_str(" None,\n");
+ code.push_str(") {\n");
+ code.push_str(" Ok((_kind, yaml, descriptor)) => {\n");
+ code.push_str(" let name = yaml.dialect.name.clone();\n");
+ code.push_str(" registry.register_descriptor(&name, descriptor, yaml);\n");
+ code.push_str(" }\n");
+ code.push_str(&format!(" Err(e) => log::warn!(\"Failed to load core dialect '{}': {{e}}\"),\n", file_name));
+ code.push_str("};\n");
+ }
+
+ code.push_str("}\n");
+
+ std::fs::write(&dest_path, code).expect("Failed to write core_dialects.rs");
+}
diff --git a/crates/dbx-core/src/config/expression.rs b/crates/dbx-core/src/config/expression.rs
new file mode 100644
index 000000000..ed2011e64
--- /dev/null
+++ b/crates/dbx-core/src/config/expression.rs
@@ -0,0 +1,307 @@
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum Expression {
+ EnvVar(String),
+ Ref(String),
+ Eval(String),
+ Literal(String),
+}
+
+pub fn parse_expression(input: &str) -> Expression {
+ let trimmed = input.trim();
+ if trimmed.starts_with("${env:") && trimmed.ends_with('}') {
+ let inner = &trimmed[6..trimmed.len() - 1];
+ Expression::EnvVar(inner.to_string())
+ } else if trimmed.starts_with("${ref:") && trimmed.ends_with('}') {
+ let inner = &trimmed[6..trimmed.len() - 1];
+ Expression::Ref(inner.to_string())
+ } else if trimmed.starts_with("${eval:") && trimmed.ends_with('}') {
+ let inner = &trimmed[7..trimmed.len() - 1];
+ Expression::Eval(inner.to_string())
+ } else {
+ Expression::Literal(input.to_string())
+ }
+}
+
+pub fn resolve_env_var(name: &str) -> Result {
+ std::env::var(name).map_err(|_| format!("Environment variable '${name}' not set"))
+}
+
+pub fn resolve_ref(path: &str, merged: &HashMap) -> Result {
+ let parts: Vec<&str> = path.split('.').collect();
+ let key = parts[0];
+ let value = merged.get(key).ok_or_else(|| format!("Config reference '${path}' not found in merged config"))?;
+
+ if parts.len() == 1 {
+ return Ok(value.clone());
+ }
+
+ let mut current = value;
+ for part in &parts[1..] {
+ match current {
+ serde_json::Value::Object(map) => {
+ current =
+ map.get(*part).ok_or_else(|| format!("Config reference '${path}': key '{part}' not found"))?;
+ }
+ _ => {
+ return Err(format!("Config reference '${path}': intermediate value is not an object"));
+ }
+ }
+ }
+ Ok(current.clone())
+}
+
+/// Parses JSON literals only (numbers, booleans, null, quoted strings).
+/// Does NOT support arithmetic, operators, or function calls.
+fn eval_simple(expr: &str) -> Result {
+ let expr = expr.trim();
+
+ if let Ok(v) = expr.parse::() {
+ return Ok(serde_json::Value::Number(v.into()));
+ }
+
+ if let Ok(v) = expr.parse::() {
+ if let Some(n) = serde_json::Number::from_f64(v) {
+ return Ok(serde_json::Value::Number(n));
+ }
+ }
+
+ if expr == "true" {
+ return Ok(serde_json::Value::Bool(true));
+ }
+ if expr == "false" {
+ return Ok(serde_json::Value::Bool(false));
+ }
+ if expr == "null" {
+ return Ok(serde_json::Value::Null);
+ }
+
+ if expr.starts_with('"') && expr.ends_with('"') && expr.len() >= 2 {
+ return Ok(serde_json::Value::String(expr[1..expr.len() - 1].to_string()));
+ }
+
+ Err(format!("Cannot evaluate expression: '{expr}'"))
+}
+
+pub fn resolve_expression(
+ expression: &Expression,
+ merged: &HashMap,
+) -> Result {
+ match expression {
+ Expression::EnvVar(name) => {
+ let val = resolve_env_var(name)?;
+ Ok(serde_json::Value::String(val))
+ }
+ Expression::Ref(path) => resolve_ref(path, merged),
+ Expression::Eval(expr) => eval_simple(expr),
+ Expression::Literal(val) => Ok(serde_json::Value::String(val.clone())),
+ }
+}
+
+pub fn resolve_all_expressions_in_value(
+ value: &serde_json::Value,
+ merged: &HashMap,
+) -> Result {
+ match value {
+ serde_json::Value::String(s) => {
+ let expr = parse_expression(s);
+ resolve_expression(&expr, merged)
+ }
+ serde_json::Value::Object(map) => {
+ let mut resolved = serde_json::Map::new();
+ for (k, v) in map {
+ resolved.insert(k.clone(), resolve_all_expressions_in_value(v, merged)?);
+ }
+ Ok(serde_json::Value::Object(resolved))
+ }
+ serde_json::Value::Array(arr) => {
+ let mut resolved = Vec::new();
+ for v in arr {
+ resolved.push(resolve_all_expressions_in_value(v, merged)?);
+ }
+ Ok(serde_json::Value::Array(resolved))
+ }
+ other => Ok(other.clone()),
+ }
+}
+
+pub fn apply_expression_resolution(
+ config: &mut HashMap,
+ merged: &HashMap,
+) -> Result<(), String> {
+ let mut resolved = HashMap::new();
+ for (k, v) in config.iter() {
+ resolved.insert(k.clone(), resolve_all_expressions_in_value(v, merged)?);
+ }
+ config.extend(resolved);
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_env_var_expression() {
+ assert_eq!(parse_expression("${env:HOME}"), Expression::EnvVar("HOME".to_string()));
+ }
+
+ #[test]
+ fn test_parse_ref_expression() {
+ assert_eq!(parse_expression("${ref:database.host}"), Expression::Ref("database.host".to_string()));
+ }
+
+ #[test]
+ fn test_parse_eval_expression() {
+ assert_eq!(parse_expression("${eval:42}"), Expression::Eval("42".to_string()));
+ }
+
+ #[test]
+ fn test_parse_literal() {
+ assert_eq!(parse_expression("hello"), Expression::Literal("hello".to_string()));
+ assert_eq!(parse_expression("${not-an-expr}"), Expression::Literal("${not-an-expr}".to_string()));
+ }
+
+ #[test]
+ fn test_resolve_env_var() {
+ std::env::set_var("DBX_TEST_VAR", "test_value");
+ let result = resolve_env_var("DBX_TEST_VAR");
+ assert_eq!(result.unwrap(), "test_value");
+ std::env::remove_var("DBX_TEST_VAR");
+ }
+
+ #[test]
+ fn test_resolve_env_var_missing() {
+ let result = resolve_env_var("DBX_NONEXISTENT_VAR_12345");
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn test_resolve_ref_simple() {
+ let mut merged = HashMap::new();
+ merged.insert("host".to_string(), serde_json::Value::String("localhost".to_string()));
+
+ let result = resolve_ref("host", &merged).unwrap();
+ assert_eq!(result, serde_json::Value::String("localhost".to_string()));
+ }
+
+ #[test]
+ fn test_resolve_ref_nested() {
+ let mut merged = HashMap::new();
+ let mut db = serde_json::Map::new();
+ db.insert("host".to_string(), serde_json::Value::String("pg.example.com".to_string()));
+ db.insert("port".to_string(), serde_json::Value::Number(serde_json::Number::from(5432)));
+ merged.insert("database".to_string(), serde_json::Value::Object(db));
+
+ let host = resolve_ref("database.host", &merged).unwrap();
+ assert_eq!(host, serde_json::Value::String("pg.example.com".to_string()));
+
+ let port = resolve_ref("database.port", &merged).unwrap();
+ assert_eq!(port, serde_json::Value::Number(serde_json::Number::from(5432)));
+ }
+
+ #[test]
+ fn test_resolve_ref_missing() {
+ let merged = HashMap::new();
+ let result = resolve_ref("nonexistent", &merged);
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn test_eval_simple_integer() {
+ let result = eval_simple("42").unwrap();
+ assert_eq!(result, serde_json::Value::Number(serde_json::Number::from(42)));
+ }
+
+ #[test]
+ fn test_eval_simple_bool() {
+ assert_eq!(eval_simple("true").unwrap(), serde_json::Value::Bool(true));
+ assert_eq!(eval_simple("false").unwrap(), serde_json::Value::Bool(false));
+ }
+
+ #[test]
+ fn test_eval_simple_null() {
+ assert_eq!(eval_simple("null").unwrap(), serde_json::Value::Null);
+ }
+
+ #[test]
+ fn test_eval_simple_string() {
+ let result = eval_simple(r#""hello world""#).unwrap();
+ assert_eq!(result, serde_json::Value::String("hello world".to_string()));
+ }
+
+ #[test]
+ fn test_eval_simple_invalid() {
+ assert!(eval_simple("some_random_text").is_err());
+ }
+
+ #[test]
+ fn test_resolve_expression_env_var() {
+ std::env::set_var("DBX_TEST_PORT", "8080");
+ let expr = Expression::EnvVar("DBX_TEST_PORT".to_string());
+ let merged = HashMap::new();
+ let result = resolve_expression(&expr, &merged).unwrap();
+ assert_eq!(result, serde_json::Value::String("8080".to_string()));
+ std::env::remove_var("DBX_TEST_PORT");
+ }
+
+ #[test]
+ fn test_resolve_expression_ref() {
+ let mut merged = HashMap::new();
+ merged.insert("host".to_string(), serde_json::Value::String("db.local".to_string()));
+ let expr = Expression::Ref("host".to_string());
+ let result = resolve_expression(&expr, &merged).unwrap();
+ assert_eq!(result, serde_json::Value::String("db.local".to_string()));
+ }
+
+ #[test]
+ fn test_resolve_expression_eval() {
+ let expr = Expression::Eval("true".to_string());
+ let merged = HashMap::new();
+ let result = resolve_expression(&expr, &merged).unwrap();
+ assert_eq!(result, serde_json::Value::Bool(true));
+ }
+
+ #[test]
+ fn test_resolve_expression_literal() {
+ let expr = Expression::Literal("hello".to_string());
+ let merged = HashMap::new();
+ let result = resolve_expression(&expr, &merged).unwrap();
+ assert_eq!(result, serde_json::Value::String("hello".to_string()));
+ }
+
+ #[test]
+ fn test_resolve_all_in_nested_object() {
+ let mut merged = HashMap::new();
+ merged.insert("default_host".to_string(), serde_json::Value::String("pg.example.com".to_string()));
+
+ let mut obj = serde_json::Map::new();
+ obj.insert("host".to_string(), serde_json::Value::String("${ref:default_host}".to_string()));
+ obj.insert("port".to_string(), serde_json::Value::Number(serde_json::Number::from(5432)));
+ obj.insert("debug".to_string(), serde_json::Value::String("${eval:true}".to_string()));
+
+ let input = serde_json::Value::Object(obj);
+ let result = resolve_all_expressions_in_value(&input, &merged).unwrap();
+
+ let obj = result.as_object().unwrap();
+ assert_eq!(obj.get("host").unwrap(), &serde_json::Value::String("pg.example.com".to_string()));
+ assert_eq!(obj.get("port").unwrap(), &serde_json::Value::Number(serde_json::Number::from(5432)));
+ assert_eq!(obj.get("debug").unwrap(), &serde_json::Value::Bool(true));
+ }
+
+ #[test]
+ fn test_apply_expression_resolution() {
+ let mut merged = HashMap::new();
+ merged.insert("base_url".to_string(), serde_json::Value::String("https://api.example.com".to_string()));
+
+ let mut config = HashMap::new();
+ config.insert("url".to_string(), serde_json::Value::String("${ref:base_url}".to_string()));
+ config.insert("timeout".to_string(), serde_json::Value::String("${eval:30}".to_string()));
+
+ apply_expression_resolution(&mut config, &merged).unwrap();
+ assert_eq!(config.get("url").unwrap(), &serde_json::Value::String("https://api.example.com".to_string()));
+ assert_eq!(config.get("timeout").unwrap(), &serde_json::Value::Number(serde_json::Number::from(30)));
+ }
+}
diff --git a/crates/dbx-core/src/config/governance.rs b/crates/dbx-core/src/config/governance.rs
new file mode 100644
index 000000000..afc336cc9
--- /dev/null
+++ b/crates/dbx-core/src/config/governance.rs
@@ -0,0 +1,979 @@
+use arc_swap::ArcSwap;
+use chrono::Utc;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use crate::config::layer::ConfigTree;
+#[cfg(test)]
+use crate::config::layer::LayerConfig;
+use crate::storage::Storage;
+
+fn hex_encode(bytes: &[u8]) -> String {
+ bytes.iter().map(|b| format!("{b:02x}")).collect()
+}
+
+// ---------------------------------------------------------------------------
+// 16.1 — Config Change Audit & Version Management
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ConfigAuditEntry {
+ pub id: String,
+ pub timestamp: String,
+ pub operator: String,
+ pub reason: String,
+ pub key_path: String,
+ pub change_diff: serde_json::Value,
+ pub config_snapshot: serde_json::Value,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ConfigVersionSnapshot {
+ pub id: String,
+ pub key_path: String,
+ pub version: u64,
+ pub snapshot_json: serde_json::Value,
+ pub checksum: String,
+ pub created_at: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct AuditQuery {
+ pub key_path: Option,
+ pub operator: Option,
+ pub limit: Option,
+ pub offset: Option,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct AuditSummary {
+ pub total_entries: usize,
+ pub entries: Vec,
+}
+
+pub struct ConfigAuditor {
+ storage: Arc,
+}
+
+impl ConfigAuditor {
+ pub fn new(storage: Arc) -> Self {
+ Self { storage }
+ }
+
+ pub async fn record_change(
+ &self,
+ operator: &str,
+ reason: &str,
+ key_path: &str,
+ change_diff: serde_json::Value,
+ config_snapshot: serde_json::Value,
+ ) -> Result {
+ let entry = ConfigAuditEntry {
+ id: uuid::Uuid::new_v4().to_string(),
+ timestamp: Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
+ operator: operator.to_string(),
+ reason: reason.to_string(),
+ key_path: key_path.to_string(),
+ change_diff,
+ config_snapshot,
+ };
+ self.storage.save_audit_entry(&entry).await?;
+ Ok(entry)
+ }
+
+ pub async fn query_history(&self, query: &AuditQuery) -> Result {
+ self.storage.query_audit_entries(query).await
+ }
+
+ pub async fn save_snapshot(&self, key_path: &str, tree: &ConfigTree) -> Result {
+ let snapshot_json = serde_json::to_value(tree).map_err(|e| format!("serialize config tree: {e}"))?;
+ let json_bytes = serde_json::to_vec(&snapshot_json).map_err(|e| format!("json bytes: {e}"))?;
+ let checksum = hex_encode(&Sha256::digest(&json_bytes));
+ let version = self.storage.next_config_version(key_path).await?;
+
+ let snap = ConfigVersionSnapshot {
+ id: uuid::Uuid::new_v4().to_string(),
+ key_path: key_path.to_string(),
+ version,
+ snapshot_json: snapshot_json.clone(),
+ checksum,
+ created_at: Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
+ };
+ self.storage.save_config_snapshot(&snap).await?;
+ Ok(snap)
+ }
+
+ pub async fn get_snapshot(&self, key_path: &str, version: u64) -> Result