diff --git a/Cargo.lock b/Cargo.lock index 691746552..fc492187b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1772,6 +1772,7 @@ dependencies = [ "datazen-driver-mysql", "datazen-driver-postgres", "datazen-driver-sqlite", + "datazen-migration-common", "datazen-platform-api", "datazen-runtime", "serde", @@ -1789,11 +1790,11 @@ dependencies = [ "async-trait", "base64 0.22.1", "chrono", - "datazen-data-sync", "datazen-driver-api", "datazen-driver-mysql", "datazen-driver-postgres", "datazen-driver-sqlite", + "datazen-migration-common", "datazen-platform-api", "datazen-runtime", "flate2", @@ -2078,6 +2079,14 @@ dependencies = [ "uuid", ] +[[package]] +name = "datazen-migration-common" +version = "0.0.1" +dependencies = [ + "datazen-driver-api", + "serde_json", +] + [[package]] name = "datazen-platform-api" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 917b62580..18ca5c8a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "packages/runtime", "packages/schema-diff", "packages/data-sync", + "packages/migration-common", "packages/data-transfer", "packages/drivers/*", ] @@ -23,6 +24,7 @@ datazen-driver-api = { path = "packages/driver-api" } datazen-platform-api = { path = "packages/platform-api" } datazen-application = { path = "packages/application" } datazen-schema-diff = { path = "packages/schema-diff" } +datazen-migration-common = { path = "packages/migration-common" } datazen-data-sync = { path = "packages/data-sync" } datazen-data-transfer = { path = "packages/data-transfer" } diff --git a/docs/architecture/backend/data-sync.md b/docs/architecture/backend/data-sync.md index 7b2659e42..442d7c529 100644 --- a/docs/architecture/backend/data-sync.md +++ b/docs/architecture/backend/data-sync.md @@ -2,6 +2,8 @@ > P5 桌面 JobRuntime 接入已完成;迁移三件套独立测试与冒烟测试通过。Source of truth: `packages/data-sync/`、`src-tauri/src/commands/sync/` 和 `src/windows/data-sync/`。 +共享边界值规范化、比较与端点配对算法位于 `packages/migration-common/`;SQL 名称处理工具位于 `packages/driver-api/src/sql_identifiers.rs`。Data Sync 与 Data Transfer 直接依赖这些公共实现,Data Transfer 不依赖 Data Sync 领域包。Job 生命周期继续复用 `packages/runtime/src/job/`。 + Data Sync 用于**同族数据库的行级差异同步**。它与 Schema Diff、Data Transfer 是三个独立执行模型。 | 能力 | 用途 | diff --git a/docs/architecture/backend/data-transfer.md b/docs/architecture/backend/data-transfer.md index 8a6bb5cfb..8a2f68256 100644 --- a/docs/architecture/backend/data-transfer.md +++ b/docs/architecture/backend/data-transfer.md @@ -2,6 +2,8 @@ > 当前实现说明;P5 桌面 JobRuntime 与数据迁移三件套于 2026-10-09 完成并通过独立测试、冒烟测试。Data Transfer 的实现事实见 `packages/data-transfer/`、`src-tauri/src/commands/data_transfer/`、`packages/runtime/src/job/` 与 `src-tauri/src/store/app_db/jobs/`;后续服务端与跨进程恢复边界见[迁移任务详细设计](../platform/data-migration-jobs.md)。 +共享边界值规范化、比较与端点配对算法位于 `packages/migration-common/`;SQL 名称处理工具位于 `packages/driver-api/src/sql_identifiers.rs`。Data Sync 与 Data Transfer 直接依赖这些公共实现,Data Transfer 不依赖 Data Sync 领域包。Job 生命周期继续复用 `packages/runtime/src/job/`。 + ## 1. 职责与执行边界 Data Transfer 搬运结构和数据,支持异构数据库、表列映射,以及 SQL 文件目的地。它不承担 Schema Diff 的差异 DAG,也不使用 Data Sync 的同行 ChangeSet。三者可以共用驱动与类型模型,但执行入口、检查和恢复证据分别成立。 diff --git a/docs/architecture/platform/data-migration-jobs.md b/docs/architecture/platform/data-migration-jobs.md index de9221184..5aa4d6c13 100644 --- a/docs/architecture/platform/data-migration-jobs.md +++ b/docs/architecture/platform/data-migration-jobs.md @@ -16,6 +16,10 @@ 三个领域包和对应桌面 Job adapter 已由 P5 落地。`src-tauri/src/lib.rs` 将领域包导出给 Host;Driver 方言、DDL renderer、类型适配和专属测试留在 driver 包。领域引擎不引用 Tauri、HTTP、窗口 Store 或 driver 实现库类型。 +公共代码的实际归属:三套引擎共享 `runtime` 的 JobHandler/JobRuntime 和 `platform-api` 的任务 DTO;SQL 标识符引用与旧 family-based 表名限定工具位于 `driver-api::sql_identifiers`。`packages/migration-common` 只提供记录集边界值规范化/比较和端点 category/family 配对规则,不依赖领域引擎、runtime 或平台传输。Data Sync 与 Data Transfer 直接依赖它,Data Transfer 不再依赖 Data Sync。Data Sync 的 `recordset_bounds`、`sync_pairing` 和 `sql` 工具旧路径保留为转导出,只有一份实现。 + +领域语义仍分别拥有:Schema Diff 的结构差异与操作依赖图、Data Sync 的同族门闸与 ChangeSet、Data Transfer 的异构 IR 转换与续传策略。共享配对规则只分类端点;各领域继续执行自己的能力限制(例如 Transfer 拒绝缺少适配器的 Redis 配对)。`qualify_relation_sql` 保留既有 family-based 兼容语义;驱动自有 SQL 重写继续使用 `DatabaseDriver::qualified_sql`,本次不改变驱动 trait、协议版本或执行行为。 + Runtime 承担接受/认领、预算、资源申请、子 execution、事件、取消和 cleanup;领域 handler 承担计划校验、分阶段算法及恢复核验。Application 服务校验授权、输入与计划消费;前端只提交稳定目标、审阅选择及幂等令牌,不提交执行 SQL、原始检查点或 live handle 作为恢复资格。 ## 2. 公共执行模型 diff --git a/docs/architecture/platform/shared-boundaries-and-ports.md b/docs/architecture/platform/shared-boundaries-and-ports.md index 496c044ab..c34b1c52f 100644 --- a/docs/architecture/platform/shared-boundaries-and-ports.md +++ b/docs/architecture/platform/shared-boundaries-and-ports.md @@ -110,6 +110,7 @@ flowchart TD PA[packages/platform-api] DAPI[packages/driver-api] DOM[schema-diff / data-sync / data-transfer] + MC[packages/migration-common] DRV[packages/drivers/*] TA --> APP @@ -124,6 +125,9 @@ flowchart TD RT --> DAPI RT -.注册 JobHandler.-> DOM DOM --> PA + DOM --> DAPI + DOM -.Sync / Transfer.-> MC + MC --> DAPI DRV --> DAPI FE -.只依赖契约.-> PA ``` @@ -139,6 +143,7 @@ flowchart TD | F-05 | `packages/application`、`packages/runtime`、`packages/platform-api` 不出现 `react`、`@tauri-apps/api` 等前端标识(Rust 侧通过 crate 名与 `build.rs` 依赖检查,前端侧由 §7 的字符串扫描补齐) | UI 运行时进入后端依赖图 | | F-06 | `server` 的 normal + build 依赖闭包不含 `tauri*` 与 `datazen` 宿主 crate | server 无法独立构建 | | F-07 | `packages/backend-client` 不含 `@tauri-apps/` 前缀、`fetch(`、`XMLHttpRequest` 字面量 | 传输无关契约被具体传输污染 | +| F-08 | `packages/migration-common` 的 normal + build 工作区依赖只允许自身与 driver-api;不含 `tauri`、`axum`、`actix-web`、`warp`、`tonic`、`react` | 公共算法反向依赖领域引擎、runtime 或平台传输,重新形成耦合 | **F-01 的作用域同时覆盖两侧:manifest 声明边与解析闭包。** 上面"crate 依赖闭包"若按字面只理解为 `cargo metadata` 的 `resolve` 图,是**不够的**:那个图是 **feature-resolved 的**,只含当前 feature 解析下真正被链接的边。无人启用的 feature 背后的 optional 依赖根本不在 `resolve.nodes[].deps[]` 里,任何闭包遍历都看不见它。因此 F-01 也约束 `Cargo.toml` 的**声明边**——声明了、但当前 feature 解析不链接的边,同样算违反。两侧任一出现即违规。 diff --git a/packages/data-sync/Cargo.toml b/packages/data-sync/Cargo.toml index 473b0dac8..7ce22e684 100644 --- a/packages/data-sync/Cargo.toml +++ b/packages/data-sync/Cargo.toml @@ -7,6 +7,7 @@ license = "GPL-3.0-or-later" [dependencies] datazen-driver-api = { workspace = true } +datazen-migration-common = { workspace = true } datazen-platform-api = { workspace = true } datazen-runtime = { path = "../runtime" } async-trait = "0.1" diff --git a/packages/data-sync/src/lib.rs b/packages/data-sync/src/lib.rs index 455d10962..8b7bd5b59 100644 --- a/packages/data-sync/src/lib.rs +++ b/packages/data-sync/src/lib.rs @@ -19,7 +19,7 @@ pub mod model; pub mod pairing; pub mod profile; mod recordset; -pub mod recordset_bounds; +pub use datazen_migration_common::recordset_bounds; pub mod session; pub mod sql; pub mod state; diff --git a/packages/data-sync/src/sql.rs b/packages/data-sync/src/sql.rs index 181c17987..4d9ba9b30 100644 --- a/packages/data-sync/src/sql.rs +++ b/packages/data-sync/src/sql.rs @@ -30,64 +30,10 @@ pub struct SqlStatement { pub identity_insert: Option, } -pub fn quote_ident_sql(name: &str, quote: char) -> String { - if quote == '[' { - return format!("[{}]", name.replace(']', "]]")); - } - let doubled = name.replace(quote, &format!("{quote}{quote}")); - format!("{quote}{doubled}{quote}") -} - -/// Qualify `table` as `schema.table` when `schema` is non-empty (PostgreSQL etc.). -pub fn qualify_table_sql(schema: Option<&str>, table: &str, quote: char) -> String { - match schema.map(str::trim).filter(|s| !s.is_empty()) { - Some(schema) => format!( - "{}.{}", - quote_ident_sql(schema, quote), - quote_ident_sql(table, quote) - ), - None => quote_ident_sql(table, quote), - } -} - -/// Qualify a table reference for DML/SELECT without switching the session catalog. -/// -/// - MySQL/MariaDB/ClickHouse: `` `database`.`table` `` when `database` is set. -/// - SQL Server: `[database].[schema].[table]` with either qualifier set. -/// - PostgreSQL and similar: `"schema"."table"` when `schema` is set. -/// - Otherwise: bare `table`. -pub fn qualify_relation_sql( - family: &str, - database: Option<&str>, - schema: Option<&str>, - table: &str, - quote: char, -) -> String { - let family = family.to_ascii_lowercase(); - if matches!(family.as_str(), "mysql" | "mariadb" | "clickhouse") { - return match database.map(str::trim).filter(|s| !s.is_empty()) { - Some(db) => format!( - "{}.{}", - quote_ident_sql(db, quote), - quote_ident_sql(table, quote) - ), - None => quote_ident_sql(table, quote), - }; - } - if family == "sqlserver" { - return [ - database.map(str::trim).filter(|s| !s.is_empty()), - schema.map(str::trim).filter(|s| !s.is_empty()), - Some(table), - ] - .into_iter() - .flatten() - .map(|part| quote_ident_sql(part, quote)) - .collect::>() - .join("."); - } - qualify_table_sql(schema, table, quote) -} +// Compatibility exports; implementation belongs to the driver SQL contract. +pub use datazen_driver_api::sql_identifiers::{ + qualify_relation_sql, qualify_table_sql, quote_ident_sql, +}; pub fn qualify_table_ident(schema: Option<&str>, table: &str, quote_ident: Q) -> String where diff --git a/packages/data-sync/src/sync_pairing.rs b/packages/data-sync/src/sync_pairing.rs index ced37d2da..40c60bdd7 100644 --- a/packages/data-sync/src/sync_pairing.rs +++ b/packages/data-sync/src/sync_pairing.rs @@ -1,79 +1,6 @@ -//! Sync pairing policy: same dialect family → Direct; cross SQL → IR; cross category → forbidden. -//! -//! Category/family rules are driven by driver-api taxonomy (`sync_category_of` / -//! `sync_family_of`); frontend Transfer UI reads `DatabaseTypeMeta` instead. +//! Compatibility path for the shared migration endpoint pairing policy. -use datazen_driver_api::{sync_category_of, sync_family_of, SyncCategory}; - -/// Resolved sync path for a source/target database type pair. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SyncPairing { - Direct { family: String }, - Ir, - Unsupported { reason: String }, -} - -impl SyncPairing { - pub fn path_label(&self) -> &'static str { - match self { - Self::Direct { .. } => "direct", - Self::Ir => "ir", - Self::Unsupported { .. } => "unsupported", - } - } -} - -/// Normalize a database type id to its sync dialect family. -pub fn normalize_sync_family(raw: &str) -> String { - sync_family_of(raw) -} - -/// Map a database type id to a sync category. -pub fn sync_category(raw: &str) -> SyncCategory { - sync_category_of(raw) -} - -/// Classify how a source/target pair should sync. -pub fn resolve_sync_pairing(source: &str, target: &str) -> SyncPairing { - let src_cat = sync_category(source); - let tgt_cat = sync_category(target); - - if src_cat != tgt_cat { - return SyncPairing::Unsupported { - reason: format!( - "Sync between {} ({src_cat}) and {} ({tgt_cat}) is not supported", - source, target - ), - }; - } - - match src_cat { - SyncCategory::Other => SyncPairing::Unsupported { - reason: format!("Sync is not supported for database type '{source}'"), - }, - SyncCategory::Sql | SyncCategory::Document | SyncCategory::Kv => { - let src_family = normalize_sync_family(source); - let tgt_family = normalize_sync_family(target); - if src_family == tgt_family { - SyncPairing::Direct { family: src_family } - } else if src_cat == SyncCategory::Sql { - SyncPairing::Ir - } else { - SyncPairing::Unsupported { - reason: format!("Sync between {source} and {target} is not supported"), - } - } - } - } -} - -/// Fail fast when a pair is forbidden; returns the resolved pairing otherwise. -pub fn enforce_sync_pairing(source: &str, target: &str) -> Result { - match resolve_sync_pairing(source, target) { - SyncPairing::Unsupported { reason } => Err(reason), - ok @ (SyncPairing::Direct { .. } | SyncPairing::Ir) => Ok(ok), - } -} +pub use datazen_migration_common::pairing::*; #[cfg(test)] mod tests { diff --git a/packages/data-transfer/Cargo.toml b/packages/data-transfer/Cargo.toml index 9134dfc57..3298d6d8d 100644 --- a/packages/data-transfer/Cargo.toml +++ b/packages/data-transfer/Cargo.toml @@ -10,7 +10,7 @@ webdriver = [] [dependencies] datazen-driver-api = { workspace = true } -datazen-data-sync = { path = "../data-sync" } +datazen-migration-common = { workspace = true } datazen-runtime = { path = "../runtime" } datazen-platform-api = { workspace = true } async-trait = "0.1" diff --git a/packages/data-transfer/src/execute.rs b/packages/data-transfer/src/execute.rs index c91c10d81..5746eddef 100644 --- a/packages/data-transfer/src/execute.rs +++ b/packages/data-transfer/src/execute.rs @@ -10,7 +10,7 @@ use datazen_driver_api::TableSchema; use crate::transfer::adapter::SyncTargetAdapter; use crate::transfer::ir::IRType; -use datazen_data_sync::sql::{qualify_relation_sql, quote_ident_sql}; +use datazen_driver_api::sql_identifiers::{qualify_relation_sql, quote_ident_sql}; use datazen_driver_api::{ConnectionHandle, DatabaseDriver, Value}; use super::error::TransferError; diff --git a/packages/data-transfer/src/filter.rs b/packages/data-transfer/src/filter.rs index 5c6cfdb54..fbcfc2b5d 100644 --- a/packages/data-transfer/src/filter.rs +++ b/packages/data-transfer/src/filter.rs @@ -7,8 +7,8 @@ use crate::error::TransferError; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use datazen_data_sync::sql::quote_ident_sql; use datazen_driver_api::filters::{FilterCondition, FilterOperator}; +use datazen_driver_api::sql_identifiers::quote_ident_sql; use datazen_driver_api::{TableSchema, Value}; use serde::{Deserialize, Serialize}; diff --git a/packages/data-transfer/src/job/pipeline.rs b/packages/data-transfer/src/job/pipeline.rs index 32a18cc45..11afb65d5 100644 --- a/packages/data-transfer/src/job/pipeline.rs +++ b/packages/data-transfer/src/job/pipeline.rs @@ -163,7 +163,9 @@ pub async fn execute_bounded_table( "SELECT {} FROM {}", projection .iter() - .map(|column| { datazen_data_sync::sql::quote_ident_sql(column, context.source_quote) }) + .map(|column| { + datazen_driver_api::sql_identifiers::quote_ident_sql(column, context.source_quote) + }) .collect::>() .join(", "), context.source_table_ref diff --git a/packages/data-transfer/src/recordset.rs b/packages/data-transfer/src/recordset.rs index 19936ddbb..613bd62bd 100644 --- a/packages/data-transfer/src/recordset.rs +++ b/packages/data-transfer/src/recordset.rs @@ -7,13 +7,15 @@ use datazen_driver_api::TableSchema; -use datazen_data_sync::sql::quote_ident_sql; +use datazen_driver_api::sql_identifiers::quote_ident_sql; use datazen_driver_api::Value; use super::error::TransferError; use super::filter::SourceFilter; use super::model::{TransferRecordset, TransferRecordsetBound, TransferRecordsetTupleBound}; -use datazen_data_sync::recordset_bounds::{canonical_bound_value, compare_bound_keys, BoundKey}; +use datazen_migration_common::recordset_bounds::{ + canonical_bound_value, compare_bound_keys, BoundKey, +}; #[derive(Debug, Clone)] pub struct ResolvedRecordset { @@ -90,7 +92,7 @@ pub fn resolve_recordset( "source primary-key column '{key}' is nullable; tuple ranges require non-null keys" ))); } - datazen_data_sync::recordset_bounds::ensure_supported_bound_type( + datazen_migration_common::recordset_bounds::ensure_supported_bound_type( &column.data_type, key, ) @@ -409,7 +411,9 @@ pub fn preview_summary( .iter() .find(|column| column.name == *key) .is_some_and(|column| { - datazen_data_sync::recordset_bounds::is_text_bound_type(&column.data_type) + datazen_migration_common::recordset_bounds::is_text_bound_type( + &column.data_type, + ) }) }); if has_text_key { diff --git a/packages/data-transfer/src/resume.rs b/packages/data-transfer/src/resume.rs index d75541f0b..8d0517ddb 100644 --- a/packages/data-transfer/src/resume.rs +++ b/packages/data-transfer/src/resume.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use datazen_driver_api::TableSchema; -use datazen_data_sync::sql::quote_ident_sql; +use datazen_driver_api::sql_identifiers::quote_ident_sql; use datazen_driver_api::{ConnectionHandle, DatabaseDriver, TransactionHandle, Value}; use super::error::TransferError; diff --git a/packages/data-transfer/src/resume/fingerprint.rs b/packages/data-transfer/src/resume/fingerprint.rs index aa759b3dd..9ad17f5c9 100644 --- a/packages/data-transfer/src/resume/fingerprint.rs +++ b/packages/data-transfer/src/resume/fingerprint.rs @@ -69,13 +69,13 @@ fn primary_key_for_paging( ))); } if resumable { - datazen_data_sync::recordset_bounds::ensure_supported_bound_type( + datazen_migration_common::recordset_bounds::ensure_supported_bound_type( &column.data_type, key, ) .map_err(TransferError::validation) .map_err(|error| TransferError::unsupported(error.to_string()))?; - if datazen_data_sync::recordset_bounds::is_text_bound_type(&column.data_type) { + if datazen_migration_common::recordset_bounds::is_text_bound_type(&column.data_type) { return Err(TransferError::unsupported(format!( "primary-key column '{key}' uses text ordering whose collation cannot be verified for resume" ))); diff --git a/packages/data-transfer/src/sql_file.rs b/packages/data-transfer/src/sql_file.rs index 3ca8f65e2..fd9ad962b 100644 --- a/packages/data-transfer/src/sql_file.rs +++ b/packages/data-transfer/src/sql_file.rs @@ -28,7 +28,7 @@ use super::model::{ }; use crate::transfer::adapter::{SyncSourceAdapter, SyncTargetAdapter}; use crate::transfer::ir::{IRDefault, IRType}; -use datazen_data_sync::sql::{qualify_relation_sql, quote_ident_sql}; +use datazen_driver_api::sql_identifiers::{qualify_relation_sql, quote_ident_sql}; use datazen_driver_api::{ConnectionHandle, Value}; pub use super::sql_structure::build_structure_plan; diff --git a/packages/data-transfer/src/transfer/mod.rs b/packages/data-transfer/src/transfer/mod.rs index d97b16a42..38abdf0b1 100644 --- a/packages/data-transfer/src/transfer/mod.rs +++ b/packages/data-transfer/src/transfer/mod.rs @@ -4,7 +4,7 @@ pub mod adapter_registry; pub mod adapters; pub mod ddl; pub mod full_types; -pub use datazen_data_sync::sync_pairing as pairing; +pub use datazen_migration_common::pairing; pub use datazen_driver_api::sync::{ BoxedSyncAdapter, IRColumn, IRDefault, IRTable, IRType, SyncAdapterFactory, SyncSourceAdapter, diff --git a/packages/driver-api/src/lib.rs b/packages/driver-api/src/lib.rs index 926f9ceca..4dbfa73d4 100644 --- a/packages/driver-api/src/lib.rs +++ b/packages/driver-api/src/lib.rs @@ -28,6 +28,7 @@ pub mod schema_objects; pub mod schema_scope_mapping; pub mod session; pub mod sql_dump; +pub mod sql_identifiers; pub mod sql_split; pub mod sql_target; pub mod sqlite_structure; diff --git a/packages/driver-api/src/sql_identifiers.rs b/packages/driver-api/src/sql_identifiers.rs new file mode 100644 index 000000000..014f0759d --- /dev/null +++ b/packages/driver-api/src/sql_identifiers.rs @@ -0,0 +1,97 @@ +//! Shared quoting and relation-name construction for SQL consumers. +//! +//! These functions render names only; they never change session context or +//! execute SQL. `qualify_relation_sql` preserves the legacy family-based +//! migration API. Driver-owned SQL rewrites use `DatabaseDriver::qualified_sql`. + +pub fn quote_ident_sql(name: &str, quote: char) -> String { + if quote == '[' { + return format!("[{}]", name.replace(']', "]]")); + } + let doubled = name.replace(quote, &format!("{quote}{quote}")); + format!("{quote}{doubled}{quote}") +} + +/// Qualify `table` as `schema.table` when `schema` is non-empty (PostgreSQL etc.). +pub fn qualify_table_sql(schema: Option<&str>, table: &str, quote: char) -> String { + match schema.map(str::trim).filter(|s| !s.is_empty()) { + Some(schema) => format!( + "{}.{}", + quote_ident_sql(schema, quote), + quote_ident_sql(table, quote) + ), + None => quote_ident_sql(table, quote), + } +} + +/// Qualify a table reference for DML/SELECT without switching the session catalog. +/// +/// - MySQL/MariaDB/ClickHouse: `` `database`.`table` `` when `database` is set. +/// - SQL Server: `[database].[schema].[table]` with either qualifier set. +/// - PostgreSQL and similar: `"schema"."table"` when `schema` is set. +/// - Otherwise: bare `table`. +pub fn qualify_relation_sql( + family: &str, + database: Option<&str>, + schema: Option<&str>, + table: &str, + quote: char, +) -> String { + let family = family.to_ascii_lowercase(); + if matches!(family.as_str(), "mysql" | "mariadb" | "clickhouse") { + return match database.map(str::trim).filter(|s| !s.is_empty()) { + Some(db) => format!( + "{}.{}", + quote_ident_sql(db, quote), + quote_ident_sql(table, quote) + ), + None => quote_ident_sql(table, quote), + }; + } + if family == "sqlserver" { + return [ + database.map(str::trim).filter(|s| !s.is_empty()), + schema.map(str::trim).filter(|s| !s.is_empty()), + Some(table), + ] + .into_iter() + .flatten() + .map(|part| quote_ident_sql(part, quote)) + .collect::>() + .join("."); + } + qualify_table_sql(schema, table, quote) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_delimiters_are_escaped_as_one_identifier() { + assert_eq!(quote_ident_sql("a\"b.c", '"'), "\"a\"\"b.c\""); + assert_eq!(quote_ident_sql("a`b.c", '`'), "`a``b.c`"); + assert_eq!(quote_ident_sql("a]b.c", '['), "[a]]b.c]"); + } + + #[test] + fn legacy_relation_qualification_keeps_each_namespace_dimension() { + assert_eq!( + qualify_relation_sql("MYSQL", Some(" db`name "), Some("ignored"), "t`x", '`'), + "`db``name`.`t``x`" + ); + assert_eq!( + qualify_relation_sql("postgresql", Some("ignored"), Some(" s\"x "), "t", '"'), + "\"s\"\"x\".\"t\"" + ); + assert_eq!( + qualify_relation_sql("sqlserver", Some("db]x"), Some("s"), "t]x", '['), + "[db]]x].[s].[t]]x]" + ); + assert_eq!( + qualify_relation_sql("sqlserver", Some(" "), None, "t", '['), + "[t]" + ); + assert_eq!(qualify_table_sql(Some(" "), "a.b", '"'), "\"a.b\""); + } +} diff --git a/packages/migration-common/Cargo.toml b/packages/migration-common/Cargo.toml new file mode 100644 index 000000000..90daddc50 --- /dev/null +++ b/packages/migration-common/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "datazen-migration-common" +version = "0.0.1" +edition = "2021" +description = "Shared recordset bounds and endpoint pairing for DataZen migration engines" +license = "GPL-3.0-or-later" + +[dependencies] +datazen-driver-api = { workspace = true } +serde_json = "1" diff --git a/packages/migration-common/src/lib.rs b/packages/migration-common/src/lib.rs new file mode 100644 index 000000000..df6cd72e2 --- /dev/null +++ b/packages/migration-common/src/lib.rs @@ -0,0 +1,8 @@ +//! Pure algorithms shared by migration engines. +//! +//! No job runtime, host, transport or concrete driver dependencies. Each domain +//! owns its plans, validation policy and execution; this crate only supplies +//! typed recordset bounds and endpoint category/family classification. + +pub mod pairing; +pub mod recordset_bounds; diff --git a/packages/migration-common/src/pairing.rs b/packages/migration-common/src/pairing.rs new file mode 100644 index 000000000..dfe8897bc --- /dev/null +++ b/packages/migration-common/src/pairing.rs @@ -0,0 +1,76 @@ +//! Sync pairing policy: same dialect family → Direct; cross SQL → IR; cross category → forbidden. +//! +//! Category/family rules are driven by driver-api taxonomy (`sync_category_of` / +//! `sync_family_of`); frontend Transfer UI reads `DatabaseTypeMeta` instead. + +use datazen_driver_api::{sync_category_of, sync_family_of, SyncCategory}; + +/// Resolved sync path for a source/target database type pair. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SyncPairing { + Direct { family: String }, + Ir, + Unsupported { reason: String }, +} + +impl SyncPairing { + pub fn path_label(&self) -> &'static str { + match self { + Self::Direct { .. } => "direct", + Self::Ir => "ir", + Self::Unsupported { .. } => "unsupported", + } + } +} + +/// Normalize a database type id to its sync dialect family. +pub fn normalize_sync_family(raw: &str) -> String { + sync_family_of(raw) +} + +/// Map a database type id to a sync category. +pub fn sync_category(raw: &str) -> SyncCategory { + sync_category_of(raw) +} + +/// Classify how a source/target pair should sync. +pub fn resolve_sync_pairing(source: &str, target: &str) -> SyncPairing { + let src_cat = sync_category(source); + let tgt_cat = sync_category(target); + + if src_cat != tgt_cat { + return SyncPairing::Unsupported { + reason: format!( + "Sync between {} ({src_cat}) and {} ({tgt_cat}) is not supported", + source, target + ), + }; + } + + match src_cat { + SyncCategory::Other => SyncPairing::Unsupported { + reason: format!("Sync is not supported for database type '{source}'"), + }, + SyncCategory::Sql | SyncCategory::Document | SyncCategory::Kv => { + let src_family = normalize_sync_family(source); + let tgt_family = normalize_sync_family(target); + if src_family == tgt_family { + SyncPairing::Direct { family: src_family } + } else if src_cat == SyncCategory::Sql { + SyncPairing::Ir + } else { + SyncPairing::Unsupported { + reason: format!("Sync between {source} and {target} is not supported"), + } + } + } + } +} + +/// Fail fast when a pair is forbidden; returns the resolved pairing otherwise. +pub fn enforce_sync_pairing(source: &str, target: &str) -> Result { + match resolve_sync_pairing(source, target) { + SyncPairing::Unsupported { reason } => Err(reason), + ok @ (SyncPairing::Direct { .. } | SyncPairing::Ir) => Ok(ok), + } +} diff --git a/packages/data-sync/src/recordset_bounds.rs b/packages/migration-common/src/recordset_bounds.rs similarity index 98% rename from packages/data-sync/src/recordset_bounds.rs rename to packages/migration-common/src/recordset_bounds.rs index 25862728e..eb56b5458 100644 --- a/packages/data-sync/src/recordset_bounds.rs +++ b/packages/migration-common/src/recordset_bounds.rs @@ -1,4 +1,4 @@ -//! Canonical typed conversion and comparison for Transfer recordset bounds. +//! Canonical typed conversion and comparison for migration recordset bounds. use datazen_driver_api::Value; @@ -352,3 +352,7 @@ fn decimal_digit_at(value: &DecimalKey, index: usize) -> u8 { value.digits.as_bytes()[source as usize] } } + +#[cfg(test)] +#[path = "recordset_bounds_tests.rs"] +mod tests; diff --git a/packages/migration-common/src/recordset_bounds_tests.rs b/packages/migration-common/src/recordset_bounds_tests.rs new file mode 100644 index 000000000..6c32ef67a --- /dev/null +++ b/packages/migration-common/src/recordset_bounds_tests.rs @@ -0,0 +1,62 @@ +use super::*; +use std::cmp::Ordering; + +fn bound(raw: serde_json::Value, data_type: &str) -> (Value, BoundKey) { + canonical_bound_value(&raw, data_type, "start").unwrap() +} + +#[test] +fn unsigned_bigint_preserves_values_above_i64_without_float_rounding() { + let (value, maximum) = bound(serde_json::json!("18446744073709551615"), "bigint unsigned"); + let (_, previous) = bound(serde_json::json!("18446744073709551614"), "bigint unsigned"); + assert!(matches!(value, Value::String(text) if text == "18446744073709551615")); + assert_eq!( + compare_bound_keys(&previous, &maximum).unwrap(), + Ordering::Less + ); + assert!(canonical_bound_value( + &serde_json::json!("18446744073709551616"), + "bigint unsigned", + "end" + ) + .is_err()); +} + +#[test] +fn decimal_order_is_exact_and_normalizes_equivalent_spellings() { + let (_, a) = bound(serde_json::json!("9007199254740993.01"), "numeric(30,2)"); + let (_, b) = bound(serde_json::json!("9007199254740993.02"), "numeric(30,2)"); + assert_eq!(compare_bound_keys(&a, &b).unwrap(), Ordering::Less); + for (left, right) in [("1.20", "12e-1"), ("-0.000", "0"), ("-2.5", "-25e-1")] { + let (_, left) = bound(serde_json::json!(left), "decimal"); + let (_, right) = bound(serde_json::json!(right), "decimal"); + assert_eq!(compare_bound_keys(&left, &right).unwrap(), Ordering::Equal); + } + let (_, negative) = bound(serde_json::json!("-1.3"), "decimal"); + let (_, larger) = bound(serde_json::json!("-1.2"), "decimal"); + assert_eq!( + compare_bound_keys(&negative, &larger).unwrap(), + Ordering::Less + ); +} + +#[test] +fn text_bounds_keep_whitespace_and_reject_null_or_numeric_coercion() { + let (value, _) = bound(serde_json::json!(" 001 "), "varchar(20)"); + assert!(matches!(value, Value::String(text) if text == " 001 ")); + for raw in [serde_json::Value::Null, serde_json::json!(1)] { + assert!(canonical_bound_value(&raw, "text", "start").is_err()); + } +} + +#[test] +fn unknown_types_nonfinite_values_and_mixed_key_types_fail_closed() { + assert!(ensure_supported_bound_type("custom_type", "start").is_err()); + for raw in ["NaN", "inf", "-inf"] { + assert!(canonical_bound_value(&serde_json::json!(raw), "double", "start").is_err()); + } + let (_, number) = bound(serde_json::json!(1), "integer"); + let (_, text) = bound(serde_json::json!("1"), "text"); + assert!(compare_bound_keys(&number, &text).is_err()); + assert!(canonical_bound_value(&serde_json::json!(128), "tinyint", "start").is_err()); +} diff --git a/scripts/__tests__/check-platform-crate-boundaries.test.ts b/scripts/__tests__/check-platform-crate-boundaries.test.ts index 48209c77e..08c7f9ccf 100644 --- a/scripts/__tests__/check-platform-crate-boundaries.test.ts +++ b/scripts/__tests__/check-platform-crate-boundaries.test.ts @@ -700,3 +700,32 @@ describe('declaredDependencies — the manifest-side scan', () => { ]); }); }); + +// Shared migration algorithms must stay below every domain and platform host. +describe('F-08 shared migration algorithms', () => { + const common = { name: 'datazen-migration-common', dir: 'packages/migration-common' }; + const transfer = { name: 'datazen-data-transfer', dir: 'packages/data-transfer' }; + + it('allows the driver contract and excludes dev-only domain dependencies', () => { + const result = run(fixture([...CORE, common, transfer], { + 'datazen-migration-common': ['datazen-driver-api', 'datazen-data-transfer'], + }, [], { 'datazen-migration-common->datazen-data-transfer': 'dev' })); + expect(result.violations.filter((v) => v.startsWith('F-08'))).toEqual([]); + expect(result.evaluated.some((e) => e.rule === 'F-08')).toBe(true); + }); + + it('rejects a reverse dependency on a migration domain engine', () => { + const result = run(fixture([...CORE, common, transfer], { + 'datazen-migration-common': ['datazen-data-transfer'], + })); + expect(result.violations.join('\n')).toMatch(/F-08.*datazen-migration-common.*→ datazen-data-transfer/); + }); + + it('rejects transport dependencies reached indirectly through driver-api', () => { + const result = run(fixture([...CORE, common], { + 'datazen-migration-common': ['datazen-driver-api'], + 'datazen-driver-api': ['tauri'], + }, [{ name: 'tauri' }])); + expect(result.violations.join('\n')).toMatch(/F-08.*datazen-migration-common.*→ tauri/); + }); +}); diff --git a/scripts/check-platform-crate-boundaries.mjs b/scripts/check-platform-crate-boundaries.mjs index 4ebc0cd9f..5fd986322 100644 --- a/scripts/check-platform-crate-boundaries.mjs +++ b/scripts/check-platform-crate-boundaries.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * Platform crate dependency boundary gate — F-01..F-07 of + * Platform crate dependency boundary gate — F-01..F-08 of * `docs/architecture/platform/shared-boundaries-and-ports.md` §2.4. * * The requirement this implements (platform-development-plan.md:80): @@ -177,6 +177,16 @@ const RULES = Object.freeze([ specTokens: ['packages/backend-client', '@tauri-apps/', 'fetch(', 'XMLHttpRequest'], note: 'the browser client stays transport-agnostic', }, + { + id: 'F-08', + subjects: ['migration-common'], + subjectsFromSpec: true, + allowedLayers: ['migration-common', 'driver-api'], + forbiddenCrates: 'spec', + specTokens: ['packages/migration-common'], + specCrates: ['tauri', 'axum', 'actix-web', 'warp', 'tonic', 'react'], + note: 'shared migration algorithms cannot depend on domain engines, runtime or transports', + }, ]); /** diff --git a/scripts/lib/cargoWorkspace.mjs b/scripts/lib/cargoWorkspace.mjs index ee4d261cd..0172319a1 100644 --- a/scripts/lib/cargoWorkspace.mjs +++ b/scripts/lib/cargoWorkspace.mjs @@ -73,6 +73,7 @@ export const LAYERS = Object.freeze([ { id: 'ai-api', path: 'packages/ai-api', kind: 'rust' }, { id: 'driver', path: 'packages/drivers/*', kind: 'rust' }, { id: 'schema-diff', path: 'packages/schema-diff', kind: 'rust' }, + { id: 'migration-common', path: 'packages/migration-common', kind: 'rust' }, { id: 'data-sync', path: 'packages/data-sync', kind: 'rust' }, { id: 'data-transfer', path: 'packages/data-transfer', kind: 'rust' }, { id: 'backend-client', path: 'packages/backend-client', kind: 'ts' }, diff --git a/scripts/run-platform-crate-tests.mjs b/scripts/run-platform-crate-tests.mjs index 6e8fec465..1cb4d69f7 100644 --- a/scripts/run-platform-crate-tests.mjs +++ b/scripts/run-platform-crate-tests.mjs @@ -122,7 +122,7 @@ export const PREFIX = '[platform-crate-tests]'; * Layers whose crates must be unit-tested in CI. A crate appears here by * virtue of the directory it lives in, not by its name. */ -export const TESTED_LAYERS = Object.freeze(['runtime', 'application', 'platform-api', 'server', 'schema-diff', 'data-sync', 'data-transfer']); +export const TESTED_LAYERS = Object.freeze(['runtime', 'application', 'platform-api', 'server', 'schema-diff', 'migration-common', 'data-sync', 'data-transfer']); /** * Cargo target selectors that must also be tested, per crate, **on top of `--lib`**. diff --git a/src-tauri/src/commands/data_transfer/preview.rs b/src-tauri/src/commands/data_transfer/preview.rs index f54eea920..6e4ffba44 100644 --- a/src-tauri/src/commands/data_transfer/preview.rs +++ b/src-tauri/src/commands/data_transfer/preview.rs @@ -18,7 +18,7 @@ async fn count_scoped_source_rows( scope: &crate::data_transfer::recordset::SourceScope, limit: Option, ) -> Result { - let relation = crate::data_sync::sql::qualify_relation_sql( + let relation = datazen_driver_api::sql_identifiers::qualify_relation_sql( &driver.driver_type(), Some(&endpoint.database), endpoint.normalized_schema(), diff --git a/src-tauri/src/commands/sync/compare.rs b/src-tauri/src/commands/sync/compare.rs index 8dbb3640b..7f0516968 100644 --- a/src-tauri/src/commands/sync/compare.rs +++ b/src-tauri/src/commands/sync/compare.rs @@ -1,7 +1,7 @@ use crate::commands::error::{CmdExt, CommandError}; -use crate::data_sync::sql::qualify_relation_sql; use crate::schema_diff::types::{ChangedColumnDiff, ColumnChange, ColumnSnapshot, TableColumnDiff}; use crate::transfer::ir::{IRColumn, IRTable, IRType}; +use datazen_driver_api::sql_identifiers::qualify_relation_sql; use std::collections::HashMap; // ── Helpers ─────────────────────────────────────────────────────────